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:
Truman Gao
2026-08-20 19:30:42 +00:00
committed by GitHub
parent 52e055b20f
commit a85b8eed3f
41 changed files with 1727 additions and 435 deletions
+1
View File
@@ -1007,6 +1007,7 @@ watch(
if (behavior && appSettings.syncBehaviorAcrossDevices) {
const behaviorFeatureFlags = {
worlds_in_home: behavior.show_jump_in,
compact_instance_cards: behavior.compact_instance_cards,
show_instance_play_time: behavior.show_play_time,
skip_unknown_pack_warning: !behavior.warn_on_unknown_modpacks,
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">
<Avatar
size="36px"
disable-conditional-icon-padding
:src="
selectedAccount
? avatarUrl
@@ -46,7 +47,11 @@
class="w-5 h-5 text-brand shrink-0"
/>
<RadioButtonIcon v-else class="w-5 h-5 text-secondary shrink-0" />
<Avatar :src="getAccountAvatarUrl(account)" size="24px" />
<Avatar
:src="getAccountAvatarUrl(account)"
size="24px"
disable-conditional-icon-padding
/>
<p
class="m-0 truncate min-w-0"
: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 { 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'
const { formatMessage } = useVIntl()
@@ -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 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 enderDragon from '@/assets/instance-icons/ender-dragon.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 gizmo from '@/assets/instance-icons/gizmo.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 moobloom from '@/assets/instance-icons/moobloom.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 oxygenDistributor from '@/assets/instance-icons/oxygen-distributor.png'
import pancakes from '@/assets/instance-icons/pancakes.png'
import pickaxe from '@/assets/instance-icons/pickaxe.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 sculkSensor from '@/assets/instance-icons/sculk-sensor.png'
import skeleton from '@/assets/instance-icons/skeleton.png'
@@ -197,8 +201,20 @@ const names = defineMessages({
defaultMessage: 'Modrinth Wrench',
},
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 = [
{
id: 'rose',
@@ -339,6 +355,39 @@ export const backgroundOptions = [
] as const
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
{ 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: 'tnt', name: names.tnt, asset: tnt, 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 SymbolId = (typeof symbolOptions)[number]['id']
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { CheckIcon, InfoIcon, RefreshCwIcon, SaveIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import {
Avatar,
Button,
commonMessages,
defineMessages,
@@ -26,6 +27,7 @@ import {
DEFAULT_SYMBOL_ID,
RANDOM_CONFIG_BLACKLIST,
type SymbolId,
type SymbolOption,
symbolOptions,
} from './editor-catalog'
@@ -201,7 +203,8 @@ function surpriseMe() {
const configurations = backgroundOptions.flatMap((background) =>
symbolOptions
.filter(
(symbol) =>
(symbol: SymbolOption) =>
!symbol.excludeFromRandomization &&
background.id !== selectedBackground.value &&
symbol.id !== selectedSymbol.value &&
!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"
>
<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
class="icon-outline relative size-[132px] overflow-hidden rounded-[20px]"
<Avatar
:src="selectedSymbolOption.asset"
size="132px"
:style="backgroundStyle(selectedBackgroundOption.background)"
>
<img :src="selectedSymbolOption.asset" alt="" class="size-full object-cover" />
</div>
no-shadow
/>
<div class="flex items-center gap-2.5">
<div
class="icon-outline relative size-12 overflow-hidden rounded-2xl"
<Avatar
:src="selectedSymbolOption.asset"
size="40px"
:style="backgroundStyle(selectedBackgroundOption.background)"
>
<img :src="selectedSymbolOption.asset" alt="" class="size-full object-cover" />
</div>
<div
class="icon-outline relative size-8 overflow-hidden rounded-[10px]"
no-shadow
/>
<Avatar
:src="selectedSymbolOption.asset"
size="30px"
:style="backgroundStyle(selectedBackgroundOption.background)"
>
<img :src="selectedSymbolOption.asset" alt="" class="size-full object-cover" />
</div>
<div
class="icon-outline relative size-4 overflow-hidden rounded-[5px]"
no-shadow
/>
<Avatar
:src="selectedSymbolOption.asset"
size="20px"
:style="backgroundStyle(selectedBackgroundOption.background)"
>
<img :src="selectedSymbolOption.asset" alt="" class="size-full object-cover" />
</div>
no-shadow
/>
</div>
</div>
<Button class="w-full !shadow-none" @click="surpriseMe">
<Button class="w-full" @click="surpriseMe">
<RefreshCwIcon />
{{ formatMessage(messages.surpriseMe) }}
</Button>
@@ -12,7 +12,10 @@
<span class="text-2xl font-semibold text-contrast">
{{
formatMessage(messages.title, {
groupName: groupInstancesModalGroup?.name ?? '',
groupName:
groupInstancesModalGroup?.id === 'group:none'
? formatMessage(messages.ungrouped)
: (groupInstancesModalGroup?.name ?? ''),
})
}}
</span>
@@ -53,6 +56,10 @@
</div>
<Button
:type="selectedGroupInstanceIds.has(instance.id) ? 'outlined' : 'base'"
:disabled="
groupInstancesModalGroup?.id === 'group:none' &&
selectedGroupInstanceIds.has(instance.id)
"
@click="toggleGroupInstance(instance.id)"
>
<CheckIcon v-if="selectedGroupInstanceIds.has(instance.id)" />
@@ -100,6 +107,10 @@ import { getInstanceIconUrl } from '@/helpers/instance'
const { formatMessage } = useVIntl()
const messages = defineMessages({
ungrouped: {
id: 'app.library.group.ungrouped',
defaultMessage: 'Ungrouped',
},
title: {
id: 'app.library.group.instances-modal.title',
defaultMessage: 'Add instances to "{groupName}"',
@@ -1,20 +1,24 @@
<script setup lang="ts">
import {
ClipboardCopyIcon,
EditIcon,
EyeIcon,
FolderOpenIcon,
MinusIcon,
PaletteIcon,
PlayIcon,
PlusIcon,
StarIcon,
StopCircleIcon,
TrashIcon,
UploadIcon,
} from '@modrinth/assets'
import { defineMessages, useVIntl } from '@modrinth/ui'
import { computed, nextTick, onDeactivated, onUnmounted, ref, toRef, watch } from 'vue'
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 InstanceGroup from '@/components/ui/library/instance-group/index.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',
defaultMessage: 'View instance',
},
editIcon: {
id: 'instance.settings.tabs.general.edit-icon',
defaultMessage: 'Edit icon',
},
selectIcon: {
id: 'instance.settings.tabs.general.edit-icon.select',
defaultMessage: 'Select icon',
},
replaceIcon: {
id: 'instance.settings.tabs.general.edit-icon.replace',
defaultMessage: 'Replace icon',
},
createIcon: {
id: 'instance.settings.tabs.general.edit-icon.create',
defaultMessage: 'Create an icon',
},
editCreatedIcon: {
id: 'instance.settings.tabs.general.edit-icon.edit-created',
defaultMessage: 'Edit icon',
},
removeIcon: {
id: 'instance.settings.tabs.general.edit-icon.remove',
defaultMessage: 'Remove icon',
},
duplicateInstance: {
id: 'app.library.instance.action.duplicate',
defaultMessage: 'Duplicate instance',
@@ -78,10 +106,13 @@ const {
reorderGroups,
instanceOptions,
confirmDeleteModal,
iconEditorModal,
currentIconEditorInstance,
currentDeleteInstances,
clearLibraryInstanceSelection,
deleteInstance,
handleInstanceOption,
handleInstanceIconSaved,
selectedLibraryInstances,
setSelectedLibraryInstances,
toggleLibraryInstanceSelection,
@@ -100,40 +131,35 @@ const visibleInstanceGroups = computed(() =>
),
)
const visibleCustomGroups = computed(() =>
const visibleReorderableGroups = computed(() =>
displayState.value.group === 'Group'
? visibleInstanceGroups.value.filter(
(group) => group.id !== FAVORITES_GROUP_ID && group.id !== 'group:none',
)
? visibleInstanceGroups.value.filter((group) => group.id !== FAVORITES_GROUP_ID)
: [],
)
const visibleFavoritesGroup = computed(() =>
visibleInstanceGroups.value.find((group) => group.id === FAVORITES_GROUP_ID),
)
const visibleUngroupedGroup = computed(() =>
visibleInstanceGroups.value.find((group) => group.id === 'group:none'),
)
const draggableCustomGroups = ref<InstanceGroupType[]>([])
const draggableGroups = ref<InstanceGroupType[]>([])
const libraryGroupsContainer = ref<HTMLElement>()
const isDraggingGroup = ref(false)
const GROUP_REORDERING_CLASS = 'instance-group-reordering'
const canDragReorderGroups = computed(
() => !reorderingGroups.value && draggableCustomGroups.value.length > 1,
() => !reorderingGroups.value && draggableGroups.value.length > 1,
)
watch(
visibleCustomGroups,
visibleReorderableGroups,
(groups) => {
if (!isDraggingGroup.value) {
const previousGroupTops = getCustomGroupTops()
draggableCustomGroups.value = [...groups]
void nextTick(() => animateCustomGroupReorder(previousGroupTops))
const previousGroupTops = getReorderableGroupTops()
draggableGroups.value = [...groups]
void nextTick(() => animateGroupReorder(previousGroupTops))
}
},
{ immediate: true },
)
function getCustomGroupTops() {
function getReorderableGroupTops() {
const groupTops = new Map<string, number>()
const groupElements = libraryGroupsContainer.value?.querySelectorAll<HTMLElement>(
'[data-instance-group-reorder-id]',
@@ -149,7 +175,7 @@ function getCustomGroupTops() {
return groupTops
}
function animateCustomGroupReorder(previousGroupTops: Map<string, number>) {
function animateGroupReorder(previousGroupTops: Map<string, number>) {
if (
previousGroupTops.size === 0 ||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
@@ -185,10 +211,10 @@ function onGroupDragEnd() {
isDraggingGroup.value = false
document.documentElement.classList.remove(GROUP_REORDERING_CLASS)
const currentGroupIds = visibleCustomGroups.value.map((group) => group.id)
const orderedGroupIds = draggableCustomGroups.value.map((group) => group.id)
const currentGroupIds = visibleReorderableGroups.value.map((group) => group.id)
const orderedGroupIds = draggableGroups.value.map((group) => group.id)
if (orderedGroupIds.every((groupId, index) => groupId === currentGroupIds[index])) {
draggableCustomGroups.value = [...visibleCustomGroups.value]
draggableGroups.value = [...visibleReorderableGroups.value]
return
}
@@ -259,6 +285,10 @@ function setConfirmDeleteModal(component: unknown) {
confirmDeleteModal.value = component as InstanceType<typeof ConfirmDeleteInstanceModal> | null
}
function setIconEditorModal(component: unknown) {
iconEditorModal.value = component as InstanceType<typeof IconEditorModal> | null
}
watch(selectedLibraryInstances, (selectedInstances) => {
if (selectedInstances.size === 0) {
anchorInstance.value = null
@@ -313,7 +343,7 @@ watch(selectedLibraryInstances, (selectedInstances) => {
</div>
<Draggable
:list="draggableCustomGroups"
:list="draggableGroups"
class="flex flex-col"
item-key="id"
:disabled="!canDragReorderGroups"
@@ -341,6 +371,7 @@ watch(selectedLibraryInstances, (selectedInstances) => {
>
<InstanceGroup
:can-drag-reorder="canDragReorderGroups"
:hide-header="visibleInstanceGroups.length === 1"
:instance-group="instanceGroup"
:selection-anchor-instance-id="
anchorInstance?.groupId === instanceGroup.id ? anchorInstance?.instanceId : null
@@ -353,20 +384,6 @@ watch(selectedLibraryInstances, (selectedInstances) => {
</div>
</template>
</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>
<TransitionGroup
@@ -407,6 +424,12 @@ watch(selectedLibraryInstances, (selectedInstances) => {
:instances="currentDeleteInstances"
@delete="deleteInstance"
/>
<IconEditorModal
:ref="setIconEditorModal"
:instance-id="currentIconEditorInstance?.id"
:config="currentIconEditorInstance?.icon_config"
@saved="handleInstanceIconSaved"
/>
<ContextMenu :ref="setInstanceOptions" @option-clicked="handleInstanceOption">
<template #play> <PlayIcon /> {{ formatMessage(messages.play) }} </template>
<template #stop> <StopCircleIcon /> {{ formatMessage(messages.stop) }} </template>
@@ -419,6 +442,14 @@ watch(selectedLibraryInstances, (selectedInstances) => {
</template>
<template #add_content> <PlusIcon /> {{ formatMessage(messages.addContent) }} </template>
<template #edit> <EyeIcon /> {{ formatMessage(messages.viewInstance) }} </template>
<template #edit_icon> <EditIcon /> {{ formatMessage(messages.editIcon) }} </template>
<template #select_icon> <UploadIcon /> {{ formatMessage(messages.selectIcon) }} </template>
<template #replace_icon> <UploadIcon /> {{ formatMessage(messages.replaceIcon) }} </template>
<template #create_icon> <PaletteIcon /> {{ formatMessage(messages.createIcon) }} </template>
<template #edit_created_icon>
<PaletteIcon /> {{ formatMessage(messages.editCreatedIcon) }}
</template>
<template #remove_icon> <TrashIcon /> {{ formatMessage(messages.removeIcon) }} </template>
<template #duplicate>
<ClipboardCopyIcon /> {{ formatMessage(messages.duplicateInstance) }}
</template>
@@ -2,16 +2,25 @@
import { ArrowDownIcon, ArrowUpIcon, EditIcon, SquarePlusIcon, TrashIcon } from '@modrinth/assets'
import { defineMessages, IconButton, useVIntl } from '@modrinth/ui'
defineProps<{
deleting?: boolean
canMoveDown: boolean
canMoveUp: boolean
onAddToGroup: () => void
onDeleteGroup: () => void
onEditGroupName: () => void
onMoveDown: () => void
onMoveUp: () => void
}>()
withDefaults(
defineProps<{
deleting?: boolean
canMoveDown: boolean
canMoveUp: boolean
onAddToGroup: () => void
onDeleteGroup: () => void
onEditGroupName: () => void
onMoveDown: () => void
onMoveUp: () => void
showDelete?: boolean
showEdit?: boolean
}>(),
{
deleting: false,
showDelete: true,
showEdit: true,
},
)
const { formatMessage } = useVIntl()
@@ -64,6 +73,7 @@ const messages = defineMessages({
<ArrowDownIcon />
</IconButton>
<IconButton
v-if="showEdit"
v-tooltip="formatMessage(messages.editGroupName)"
:label="formatMessage(messages.editGroupName)"
type="quiet"
@@ -82,6 +92,7 @@ const messages = defineMessages({
<SquarePlusIcon />
</IconButton>
<IconButton
v-if="showDelete"
v-tooltip="formatMessage(messages.deleteGroup)"
:label="formatMessage(messages.deleteGroup)"
type="quiet"
@@ -21,7 +21,7 @@ import {
} from '@modrinth/ui'
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 InstanceCard from '@/components/ui/library/instance-group/instance-card.vue'
import type {
@@ -29,6 +29,7 @@ import type {
InstanceGroup as InstanceGroupType,
} 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'
const INSTANCE_GRID_OBSERVER_ACTIVATION_DELAY = 500
@@ -48,6 +49,8 @@ const props = withDefaults(
)
const { formatMessage } = useVIntl()
const appSettings = useAppSettings()
const compactMode = computed(() => appSettings.getFeatureFlag('compact_instance_cards'))
const { addNotification } = injectNotificationManager()
const {
isSectionCollapsed,
@@ -84,6 +87,9 @@ const isFavorites = computed(() => props.instanceGroup.id === FAVORITES_GROUP_ID
const isCustomGroup = computed(
() => displayState.value.group === 'Group' && !isUngrouped.value && !isFavorites.value,
)
const isReorderableGroup = computed(
() => displayState.value.group === 'Group' && !isFavorites.value,
)
const groupContextMenuOpen = ref(false)
const isGroupToggleBlocked = computed(
() => isSearching.value || groupContextMenuOpen.value || Boolean(groupNameInput.value?.isEditing),
@@ -383,7 +389,7 @@ onMounted(startInstanceGridResizeObserver)
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="{
'instance-group-reorder-handle': isCustomGroup && canDragReorder,
'instance-group-reorder-handle': isReorderableGroup && canDragReorder,
}"
>
<div
@@ -442,7 +448,7 @@ onMounted(startInstanceGridResizeObserver)
</div>
<div class="min-w-0 flex-1" />
<GroupActionButtons
v-if="isCustomGroup"
v-if="isCustomGroup || isUngrouped"
:can-move-down="canMoveGroupDown(instanceGroup.id)"
:can-move-up="canMoveGroupUp(instanceGroup.id)"
:deleting="deletingGroup"
@@ -451,6 +457,8 @@ onMounted(startInstanceGridResizeObserver)
:on-edit-group-name="() => groupNameInput?.startEditing()"
:on-move-down="() => moveGroup(instanceGroup.id, 1)"
:on-move-up="() => moveGroup(instanceGroup.id, -1)"
:show-delete="!isUngrouped"
:show-edit="!isUngrouped"
/>
</div>
<Accordion
@@ -470,7 +478,12 @@ onMounted(startInstanceGridResizeObserver)
<div ref="instanceGridContent">
<TransitionGroup
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"
enter-active-class="transition-[opacity,transform] duration-[150ms] ease-out motion-reduce:transition-none"
enter-from-class="opacity-0"
@@ -2,6 +2,7 @@
import { Avatar, truncatedTooltip } from '@modrinth/ui'
import { computed, ref } from 'vue'
import { useAppSettings } from '@/composables/use-app-settings.ts'
import { getInstanceIconUrl } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
@@ -16,6 +17,8 @@ const props = withDefaults(
)
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 versionRef = ref<HTMLElement | null>(null)
@@ -23,30 +26,40 @@ const versionRef = ref<HTMLElement | null>(null)
<template>
<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="{
'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':
selected,
'border-surface-4': !selected,
}"
>
<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
class="pointer-events-none !rounded-2xl outline-none"
class="pointer-events-none outline-none"
:class="compactMode ? '!rounded-lg' : '!rounded-2xl'"
size="100%"
:src="iconSrc"
:tint-by="instance.id"
alt=""
no-shadow
/>
<slot name="loading" />
<div class="absolute bottom-1.5 right-1.5 z-[1] flex size-12 items-center justify-center">
<slot name="leading" />
<slot name="loading" :compact="compactMode" />
<div
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 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
ref="nameRef"
v-tooltip="truncatedTooltip(nameRef, instance.name)"
@@ -62,6 +75,6 @@ const versionRef = ref<HTMLElement | null>(null)
{{ instance.loader }} {{ instance.game_version }}
</p>
</div>
<slot name="overlay" />
<slot name="overlay" :compact="compactMode" />
</div>
</template>
@@ -285,7 +285,7 @@ onMounted(() => {
@mouseenter="checkProcess"
@pointerdown="handlePointerDown"
>
<template #loading>
<template #loading="{ compact }">
<div
v-if="loadingIndicatorVisible"
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" />
<SpinnerIcon
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"
/>
</div>
</template>
<template #leading>
<div class="relative flex size-12 shrink-0 items-center justify-center">
<template #leading="{ compact }">
<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">
<IconButton
v-if="playing"
@@ -307,7 +311,7 @@ onMounted(() => {
:label="formatMessage(messages.stop)"
type="colored"
color="red"
size="lg"
:size="compact ? 'md' : 'lg'"
@click="(e) => stop(e, 'InstanceCard')"
@mouseenter="checkProcess"
>
@@ -325,7 +329,7 @@ onMounted(() => {
:label="formatMessage(messages.repair)"
type="colored"
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"
@click="(e) => repair(e)"
>
@@ -342,7 +346,7 @@ onMounted(() => {
:label="formatMessage(messages.play)"
type="colored"
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"
@click="(e) => play(e, 'InstanceCard')"
@mouseenter="checkProcess"
@@ -352,18 +356,21 @@ onMounted(() => {
</div>
</div>
</template>
<template #overlay>
<template #overlay="{ compact }">
<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-pressed="selected"
@click.stop="toggleSelection"
>
<span
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="{
'size-[20px]': compact,
'size-[24px]': !compact,
'border-0 !opacity-100': selected,
'border-2 border-solid border-primary bg-transparent': !selected,
'[outline:3px_solid_var(--color-purple)] outline-offset-1':
@@ -1,11 +1,13 @@
import type { Labrinth } from '@modrinth/api-client'
import { formatLoader, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { open as openDialog } from '@tauri-apps/plugin-dialog'
import { useEventListener, useStorage } from '@vueuse/core'
import dayjs from 'dayjs'
import {
computed,
inject,
type InjectionKey,
nextTick,
provide,
type Ref,
ref,
@@ -13,10 +15,11 @@ import {
watchEffect,
} from 'vue'
import { trackEvent } from '@/helpers/analytics'
import { get_project_v3_many } from '@/helpers/cache.js'
import { toError } from '@/helpers/errors'
import { install_duplicate_instance } from '@/helpers/install'
import { edit, remove } from '@/helpers/instance'
import { edit, edit_icon, remove } from '@/helpers/instance'
import {
create_group as createInstanceGroup,
delete_group as deleteInstanceGroup,
@@ -28,7 +31,7 @@ import {
set_group_memberships as setInstanceGroupMemberships,
set_group_order as setInstanceGroupOrder,
} from '@/helpers/instance-groups'
import type { GameInstance } from '@/helpers/types'
import type { GameInstance, InstanceIconConfig } from '@/helpers/types'
export const librarySortOptions = [
'Name',
@@ -102,6 +105,10 @@ type ConfirmDeleteModal = {
show: () => void
}
type IconEditorModal = {
show: () => void
}
type ContextMenuSelection = {
option: string
item: InstanceCard
@@ -155,17 +162,25 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
)
const currentContextGroupId = ref<string | 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<{
group: LibraryGroupBy
sortBy: LibrarySort
collapsedGroups: string[]
ungroupedGroupPosition: number
}>(
'Instances-grid-display-state',
{
group: 'Group',
sortBy: 'Last played',
collapsedGroups: [],
ungroupedGroupPosition: Number.MAX_SAFE_INTEGER,
},
localStorage,
{ mergeDefaults: true },
@@ -211,10 +226,15 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
return a.name.localeCompare(b.name)
})
})
const groupInstancesModalGroup = computed(
() =>
libraryGroups.value.find((group) => group.id === groupInstancesModalGroupId.value) ?? null,
)
const groupInstancesModalGroup = computed(() => {
if (groupInstancesModalGroupId.value === 'group:none') {
return { id: 'group:none', name: 'None' }
}
return (
libraryGroups.value.find((group) => group.id === groupInstancesModalGroupId.value) ?? null
)
})
const groupInstances = computed(() => {
const query = groupInstancesSearch.value.trim().toLowerCase()
@@ -229,8 +249,23 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
const customLibraryGroups = computed(() =>
libraryGroups.value.filter((group) => group.id !== FAVORITES_GROUP_ID),
)
const customGroupOrder = computed(
() => new Map(customLibraryGroups.value.map((group, index) => [group.id, index])),
const orderedLibraryGroupIds = computed(() => {
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 () => {
@@ -450,11 +485,9 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
if (a.id === b.id) return 0
if (a.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 bOrder = customGroupOrder.value.get(b.id) ?? Number.MAX_SAFE_INTEGER
const aOrder = libraryGroupOrder.value.get(a.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)
})
}
@@ -715,13 +748,17 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
const openGroupInstancesModal = (groupId: string) => {
const group = libraryGroups.value.find((candidate) => candidate.id === groupId)
if (!group) return
if (!group && groupId !== 'group:none') return
groupInstancesModalGroupId.value = groupId
groupInstancesSearch.value = ''
selectedGroupInstanceIds.value = new Set(
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),
)
isGroupInstancesModalOpen.value = true
@@ -736,6 +773,7 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
const selectedIds = new Set(selectedGroupInstanceIds.value)
if (selectedIds.has(instanceId)) {
if (groupInstancesModalGroupId.value === 'group:none') return
selectedIds.delete(instanceId)
} else {
selectedIds.add(instanceId)
@@ -748,15 +786,20 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
const groupId = groupInstancesModalGroupId.value
if (!groupId || savingGroupInstances.value) return false
const changedInstances = instances.value.filter(
(instance) =>
instance.group_ids.includes(groupId) !== selectedGroupInstanceIds.value.has(instance.id),
)
const isUngrouped = groupId === 'group:none'
const changedInstances = instances.value.filter((instance) => {
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 shouldIncludeGroup = selectedGroupInstanceIds.value.has(instance.id)
const nextGroupIds = shouldIncludeGroup
? [...instance.group_ids, groupId]
: instance.group_ids.filter((instanceGroupId) => instanceGroupId !== groupId)
const nextGroupIds = isUngrouped
? []
: shouldIncludeGroup
? [...instance.group_ids, groupId]
: instance.group_ids.filter((instanceGroupId) => instanceGroupId !== groupId)
return {
instance,
@@ -978,14 +1021,16 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
const canMoveGroupUp = (groupId: string) =>
!reorderingGroups.value &&
customLibraryGroups.value.findIndex((group) => group.id === groupId) > 0
orderedLibraryGroupIds.value.findIndex((orderedGroupId) => orderedGroupId === groupId) > 0
const canMoveGroupDown = (groupId: string) => {
const groupIndex = customLibraryGroups.value.findIndex((group) => group.id === groupId)
const groupIndex = orderedLibraryGroupIds.value.findIndex(
(orderedGroupId) => orderedGroupId === groupId,
)
return (
!reorderingGroups.value &&
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
const previousGroups = libraryGroups.value
const previousUngroupedGroupPosition = displayState.value.ungroupedGroupPosition
const customGroupsById = new Map(customLibraryGroups.value.map((group) => [group.id, group]))
const reorderableGroupIds = new Set([...customGroupsById.keys(), 'group:none'])
const orderedGroupIdSet = new Set(orderedGroupIds)
if (
orderedGroupIdSet.size !== orderedGroupIds.length ||
orderedGroupIds.some((groupId) => !customGroupsById.has(groupId))
orderedGroupIds.some((groupId) => !reorderableGroupIds.has(groupId))
) {
return false
}
const orderedGroups = orderedGroupIds.map((groupId) => customGroupsById.get(groupId)!)
let orderedGroupIndex = 0
const reorderedCustomGroups = customLibraryGroups.value.map((group) =>
orderedGroupIdSet.has(group.id) ? orderedGroups[orderedGroupIndex++] : group,
const reorderedGroupIds = orderedLibraryGroupIds.value.map((groupId) =>
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
}
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)
libraryGroups.value = [...favoriteGroups, ...reorderedCustomGroups]
displayState.value.ungroupedGroupPosition = reorderedGroupIds.indexOf('group:none')
reorderingGroups.value = true
try {
await setInstanceGroupOrder(reorderedCustomGroups.map((group) => group.id))
if (customGroupOrderChanged) {
await setInstanceGroupOrder(reorderedCustomGroups.map((group) => group.id))
}
return true
} catch (error) {
libraryGroups.value = previousGroups
displayState.value.ungroupedGroupPosition = previousUngroupedGroupPosition
handleError(toError(error))
await refreshGroups()
return false
@@ -1031,7 +1089,7 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
}
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 targetIndex = groupIndex + direction
@@ -1057,6 +1115,47 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
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 = (
event: MouseEvent,
item: InstanceCard,
@@ -1083,6 +1182,14 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
{ name: 'duplicate' },
{ name: 'open' },
{ name: 'copy' },
{
name: 'edit_icon',
children: [
{ name: item.instance.icon_path ? 'replace_icon' : 'select_icon' },
{ name: item.instance.icon_config ? 'edit_created_icon' : 'create_icon' },
...(item.instance.icon_path ? [{ name: 'remove_icon' }] : []),
],
},
...(currentContextGroupId.value
? [{ name: 'remove_from_group' }, { type: 'divider' }]
: [{ type: 'divider' }]),
@@ -1127,6 +1234,17 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
case 'edit':
await item.seeInstance()
break
case 'select_icon':
case 'replace_icon':
await selectInstanceIcon(item)
break
case 'create_icon':
case 'edit_created_icon':
await openInstanceIconEditor(item)
break
case 'remove_icon':
await removeInstanceIcon(item)
break
case 'duplicate':
if (item.instance.install_stage === 'installed') {
await duplicateInstance(item.instance.id)
@@ -1189,6 +1307,8 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
canCreateGroup,
instanceOptions,
confirmDeleteModal,
iconEditorModal,
currentIconEditorInstance,
currentDeleteInstances,
isSectionCollapsed,
setSectionCollapsed,
@@ -1220,6 +1340,7 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
deleteInstance,
handleInstanceContextMenu,
handleInstanceOption,
handleInstanceIconSaved,
}
}
@@ -154,7 +154,7 @@ defineExpose({ show, hide })
:disable-close="applying"
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">
<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"
@@ -24,6 +24,7 @@ const { updatePreferences } = injectUserPreferences()
const settingsModal = inject(appSettingsModalContextKey, null)
const worldsInHomeFlag: FeatureFlag = 'worlds_in_home'
const compactInstanceCardsFlag: FeatureFlag = 'compact_instance_cards'
const skipNonEssentialWarningsFlag: FeatureFlag = 'skip_non_essential_warnings'
const skipUnknownPackWarningFlag: FeatureFlag = 'skip_unknown_pack_warning'
const showPlayTimeFlag: FeatureFlag = 'show_instance_play_time'
@@ -87,6 +88,14 @@ const messages = defineMessages({
defaultMessage:
'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: {
id: 'app.appearance-settings.show-play-time.title',
defaultMessage: 'Show play time',
@@ -128,6 +137,7 @@ type BehaviorSettingsState = {
minimizeApp: boolean
hideRightSidebar: boolean
showJumpIn: boolean
compactInstanceCards: boolean
showPlayTime: boolean
hideNametag: boolean
warnOnUnknownModpacks: boolean
@@ -142,6 +152,9 @@ function getBehaviorSettingsState(settings: AppSettings): BehaviorSettingsState
minimizeApp: settings.hide_on_process_start,
hideRightSidebar: settings.toggle_sidebar,
showJumpIn: settings.feature_flags[worldsInHomeFlag] ?? DEFAULT_FEATURE_FLAGS[worldsInHomeFlag],
compactInstanceCards:
settings.feature_flags[compactInstanceCardsFlag] ??
DEFAULT_FEATURE_FLAGS[compactInstanceCardsFlag],
showPlayTime:
settings.feature_flags[showPlayTimeFlag] ?? DEFAULT_FEATURE_FLAGS[showPlayTimeFlag],
hideNametag: settings.hide_nametag_skins_page,
@@ -166,6 +179,7 @@ const { saved, current, changes, saving, hasChanges, reset, save } = useSavable(
minimize_app: value.minimizeApp,
hide_right_sidebar: value.hideRightSidebar,
show_jump_in: value.showJumpIn,
compact_instance_cards: value.compactInstanceCards,
show_play_time: value.showPlayTime,
hide_nametag: value.hideNametag,
warn_on_unknown_modpacks: value.warnOnUnknownModpacks,
@@ -183,6 +197,7 @@ const { saved, current, changes, saving, hasChanges, reset, save } = useSavable(
feature_flags: {
...persistedSettings.value.feature_flags,
[worldsInHomeFlag]: value.showJumpIn,
[compactInstanceCardsFlag]: value.compactInstanceCards,
[showPlayTimeFlag]: value.showPlayTime,
[skipUnknownPackWarningFlag]: !value.warnOnUnknownModpacks,
[skipNonEssentialWarningsFlag]: value.skipNonEssentialWarnings,
@@ -195,6 +210,7 @@ const { saved, current, changes, saving, hasChanges, reset, save } = useSavable(
appSettings.toggleSidebar = value.hideRightSidebar
appSettings.hideNametagSkinsPage = value.hideNametag
appSettings.featureFlags[worldsInHomeFlag] = value.showJumpIn
appSettings.featureFlags[compactInstanceCardsFlag] = value.compactInstanceCards
appSettings.featureFlags[showPlayTimeFlag] = value.showPlayTime
appSettings.featureFlags[skipUnknownPackWarningFlag] = !value.warnOnUnknownModpacks
appSettings.featureFlags[skipNonEssentialWarningsFlag] = value.skipNonEssentialWarnings
@@ -298,6 +314,16 @@ onBeforeUnmount(() => {
<Toggle id="jump-back-into-worlds" v-model="current.showJumpIn" />
</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>
<h3 class="m-0 text-lg font-semibold text-contrast">
@@ -1,11 +1,17 @@
<script setup lang="ts">
import { LoaderCircleIcon } from '@modrinth/assets'
import type { GameVersion } from '@modrinth/ui'
import { defineMessages, GAME_MODES, injectNotificationManager, useVIntl } from '@modrinth/ui'
import {
Accordion,
defineMessages,
GAME_MODES,
type GameVersion,
injectNotificationManager,
useVIntl,
} from '@modrinth/ui'
import { platform } from '@tauri-apps/plugin-os'
import type { 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 WorldItem from '@/components/ui/world/WorldItem.vue'
@@ -36,6 +42,7 @@ const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
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<{
@@ -54,6 +61,100 @@ const gameVersions = ref<GameVersion[]>(await get_game_versions().catch(() => []
const MAX_JUMP_BACK_IN = 5
const MAX_NEW_INSTANCES = 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
const isLinux = platform() === 'linux'
@@ -276,100 +377,180 @@ onMounted(() => {
checkProcesses()
linuxPopulateCount.value = 0
})
onUnmounted(() => {
document.body.classList.remove('recent-worlds-resizing')
clearOverdragFlash()
})
</script>
<template>
<div v-if="loading" class="flex flex-col gap-2">
<span
class="flex mt-1 mb-3 leading-none items-center gap-1 text-2xl font-semibold text-contrast"
>
{{ formatMessage(messages.jumpIn) }}
</span>
<div class="text-center py-4">
<Accordion
v-if="loading || jumpBackInItems.length > 0"
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"
>
<template #title>
<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" />
</div>
</div>
<div v-else-if="jumpBackInItems.length > 0" class="flex flex-col gap-2">
<span
class="flex mt-1 mb-3 leading-none items-center gap-1 text-2xl font-semibold text-contrast"
>
{{ formatMessage(messages.jumpIn) }}
</span>
<div class="grid-when-huge flex flex-col w-full gap-3">
<template
v-for="item in jumpBackInItems"
:key="`${item.instance.id}-${item.type === 'world' ? getWorldIdentifier(item.world) : 'instance'}`"
>
<WorldItem
v-if="item.type === 'world'"
:world="item.world"
:playing-instance="runningInstances.includes(item.instance.id)"
: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="
() =>
<div v-else class="grid-when-huge relative flex w-full flex-col gap-3">
<TransitionGroup name="jump-back-in-item">
<div
v-for="item in visibleJumpBackInItems"
:key="`${item.instance.id}-${item.type === 'world' ? getWorldIdentifier(item.world) : 'instance'}`"
class="jump-back-in-item min-w-0"
>
<WorldItem
v-if="item.type === 'world'"
:world="item.world"
:playing-instance="runningInstances.includes(item.instance.id)"
:playing-world="
currentInstance === item.instance.id &&
currentWorld === getWorldIdentifier(item.world)
"
:refreshing="
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)"
/>
</template>
? 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'
? 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>
</Accordion>
</template>
<style scoped lang="scss">
.grid-when-huge {
display: grid;
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>
@@ -9,6 +9,7 @@ import {
MoreVerticalIcon,
NoSignalIcon,
PlayIcon,
SignalIcon,
SkullIcon,
SpinnerIcon,
StopCircleIcon,
@@ -25,15 +26,17 @@ import {
commonMessages,
defineMessages,
injectNotificationManager,
ServerOnlinePlayers,
SmartClickable,
TagItem,
TeleportOverflowMenu,
useFormatDateTime,
useFormatNumber,
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import { getPingLevel } from '@modrinth/utils'
import dayjs from 'dayjs'
import { Tooltip } from 'floating-vue'
import type { Component } from 'vue'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
@@ -53,6 +56,7 @@ import { LockIcon } from '../../../../../../packages/assets/generated-icons'
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const formatNumber = useFormatNumber()
const formatDateTime = useFormatDateTime({
timeStyle: 'short',
dateStyle: 'long',
@@ -121,6 +125,9 @@ const props = withDefaults(
)
const playingOtherWorld = computed(() => props.playingInstance && !props.playingWorld)
const hasPlayersTooltip = computed(
() => !!props.serverStatus?.players?.sample && props.serverStatus.players.sample.length > 0,
)
const serverIncompatible = computed(
() =>
!!props.serverStatus &&
@@ -238,6 +245,10 @@ const messages = defineMessages({
id: 'app.world.world-item.incompatible-version',
defaultMessage: 'Incompatible version {version}',
},
playersOnline: {
id: 'app.world.world-item.players-online',
defaultMessage: '{count} online',
},
offline: {
id: 'app.world.world-item.offline',
defaultMessage: 'Offline',
@@ -315,13 +326,34 @@ const messages = defineMessages({
}}
</span>
</template>
<div v-else class="flex items-center gap-2">
<ServerOnlinePlayers
:online="serverStatus.players?.online ?? 0"
status-online
hide-label
<template v-else>
<SignalIcon
v-tooltip="`${serverStatus.ping}ms`"
aria-hidden="true"
:style="`--_signal-${getPingLevel(serverStatus.ping ?? 0)}: var(--color-green)`"
stroke-width="3px"
class="shrink-0 smart-clickable:allow-pointer-events"
/>
</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 v-else>
<NoSignalIcon aria-hidden="true" stroke-width="3px" class="shrink-0" />
@@ -13,6 +13,7 @@ export const DEFAULT_FEATURE_FLAGS = {
pride_fundraiser: true,
i18n_debug: false,
show_instance_play_time: true,
compact_instance_cards: false,
advanced_filters_collapsed: true,
always_show_copy_details: false,
hide_installed_modpacks: false,
@@ -149,6 +149,12 @@
"app.ads-consent.title": {
"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": {
"message": "Home"
},
@@ -323,6 +329,9 @@
"app.home.jump-back-in.new-instance": {
"message": "New instance"
},
"app.home.jump-back-in.resize": {
"message": "Drag to resize"
},
"app.home.jump-back-in.title": {
"message": "Jump in"
},
@@ -1739,6 +1748,9 @@
"app.world.world-item.offline": {
"message": "Offline"
},
"app.world.world-item.players-online": {
"message": "{count} online"
},
"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."
},
@@ -2066,6 +2078,12 @@
"instance.icon-editor.symbol.engine": {
"message": "Engine"
},
"instance.icon-editor.symbol.fabric": {
"message": "Fabric"
},
"instance.icon-editor.symbol.forge": {
"message": "Forge"
},
"instance.icon-editor.symbol.furnace": {
"message": "Furnace"
},
@@ -2087,6 +2105,9 @@
"instance.icon-editor.symbol.mr-pack": {
"message": "Mr Pack"
},
"instance.icon-editor.symbol.neoforge": {
"message": "NeoForge"
},
"instance.icon-editor.symbol.orb": {
"message": "Orb"
},
@@ -2102,6 +2123,9 @@
"instance.icon-editor.symbol.poke-ball": {
"message": "Poke Ball"
},
"instance.icon-editor.symbol.quilt": {
"message": "Quilt"
},
"instance.icon-editor.symbol.redstone-block": {
"message": "Redstone Block"
},
+1 -1
View File
@@ -38,7 +38,7 @@ import { computed, onBeforeUnmount, ref, shallowRef, watch } from 'vue'
import type { LocationQuery } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import ContextMenu from '@/components/ui/context-menu/index.vue'
import { useAppServerBrowse } from '@/composables/browse/use-app-server-browse'
import { useAppEvent } from '@/composables/use-app-event'
import { useAppSettings } from '@/composables/use-app-settings.ts'
+4 -4
View File
@@ -1,10 +1,10 @@
<script setup lang="ts">
import { HomeIcon, PlusIcon } from '@modrinth/assets'
import { PlayIcon, PlusIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import dayjs from 'dayjs'
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 WelcomeScreen from '@/components/ui/WelcomeScreen.vue'
import RecentWorldsList from '@/components/ui/world/RecentWorldsList.vue'
@@ -43,7 +43,7 @@ const homeBreadcrumb = useRootBreadcrumb({
id: 'home',
label: formatMessage(messages.home),
to: '/',
visual: { type: 'icon', component: HomeIcon },
visual: { type: 'icon', component: PlayIcon },
})
onActivated(homeBreadcrumb.reset)
@@ -102,7 +102,7 @@ function handlePageOption({ option }: { option: string }) {
<div
v-else-if="isReady"
data-library-page-background
class="flex flex-col gap-6 p-6"
class="flex flex-col gap-3 p-6"
@contextmenu="openPageContextMenu"
>
<RecentWorldsList
+1 -1
View File
@@ -16,7 +16,7 @@ const client = injectModrinthClient()
useRootBreadcrumb({
slot: 'root',
id: 'servers',
label: 'Servers',
label: 'Hosting',
to: '/hosting/manage/',
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 { 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 ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.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 { 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 {
fetchCachedServerStatus,