diff --git a/Cargo.lock b/Cargo.lock
index 89ee75a3b7..459048aa57 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2676,10 +2676,10 @@ dependencies = [
"futures",
"indexmap 2.11.4",
"itertools 0.14.0",
+ "quick-xml 0.38.3",
"reqwest 0.12.24",
"rust-s3",
"serde",
- "serde-xml-rs",
"serde_json",
"sha1_smol",
"thiserror 2.0.17",
@@ -9433,18 +9433,6 @@ dependencies = [
"typeid",
]
-[[package]]
-name = "serde-xml-rs"
-version = "0.8.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "53630160a98edebde0123eb4dfd0fce6adff091b2305db3154a9e920206eb510"
-dependencies = [
- "log",
- "serde",
- "thiserror 1.0.69",
- "xml-rs",
-]
-
[[package]]
name = "serde_bytes"
version = "0.11.19"
diff --git a/Cargo.toml b/Cargo.toml
index 783f73631a..ce90524457 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -192,7 +192,6 @@ serde_cbor = "0.11.2"
serde_ini = "0.2.0"
serde_json = "1.0.145"
serde_with = "3.15.0"
-serde-xml-rs = "0.8.1" # Also an XML (de)serializer, consider dropping yaserde in favor of this
sha1 = "0.10.6"
sha1_smol = { version = "1.0.1", features = ["std"] }
sha2 = "0.10.9"
diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue
index 7456fa2dec..4e884871bb 100644
--- a/apps/app-frontend/src/App.vue
+++ b/apps/app-frontend/src/App.vue
@@ -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,
diff --git a/apps/app-frontend/src/assets/instance-icons/fabric.png b/apps/app-frontend/src/assets/instance-icons/fabric.png
new file mode 100644
index 0000000000..c763735e73
Binary files /dev/null and b/apps/app-frontend/src/assets/instance-icons/fabric.png differ
diff --git a/apps/app-frontend/src/assets/instance-icons/forge.png b/apps/app-frontend/src/assets/instance-icons/forge.png
new file mode 100644
index 0000000000..09f54201e8
Binary files /dev/null and b/apps/app-frontend/src/assets/instance-icons/forge.png differ
diff --git a/apps/app-frontend/src/assets/instance-icons/neoforge.png b/apps/app-frontend/src/assets/instance-icons/neoforge.png
new file mode 100644
index 0000000000..f82d367d38
Binary files /dev/null and b/apps/app-frontend/src/assets/instance-icons/neoforge.png differ
diff --git a/apps/app-frontend/src/assets/instance-icons/quilt.png b/apps/app-frontend/src/assets/instance-icons/quilt.png
new file mode 100644
index 0000000000..a829f58a44
Binary files /dev/null and b/apps/app-frontend/src/assets/instance-icons/quilt.png differ
diff --git a/apps/app-frontend/src/components/ui/AccountsCard.vue b/apps/app-frontend/src/components/ui/AccountsCard.vue
index 9e3631192d..6dacd0f6a2 100644
--- a/apps/app-frontend/src/components/ui/AccountsCard.vue
+++ b/apps/app-frontend/src/components/ui/AccountsCard.vue
@@ -20,6 +20,7 @@
-
+
-
-
-
-
-
-
-
diff --git a/apps/app-frontend/src/components/ui/context-menu/index.vue b/apps/app-frontend/src/components/ui/context-menu/index.vue
new file mode 100644
index 0000000000..93732168bb
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/context-menu/index.vue
@@ -0,0 +1,236 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/context-menu/types.ts b/apps/app-frontend/src/components/ui/context-menu/types.ts
new file mode 100644
index 0000000000..fe45d2b6ff
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/context-menu/types.ts
@@ -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
diff --git a/apps/app-frontend/src/components/ui/context-menu/use-context-menu-position.ts b/apps/app-frontend/src/components/ui/context-menu/use-context-menu-position.ts
new file mode 100644
index 0000000000..7ffa4fef4f
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/context-menu/use-context-menu-position.ts
@@ -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
+ activeOption: Ref
+ activeOptionIndex: Ref
+ isMobileSubmenuLayout: Ref
+ contextMenu: Ref
+ submenu: Ref
+ optionButtonRefs: Map
+}
+
+export function useContextMenuPosition({
+ shown,
+ activeOption,
+ activeOptionIndex,
+ isMobileSubmenuLayout,
+ contextMenu,
+ submenu,
+ optionButtonRefs,
+}: ContextMenuPositionOptions) {
+ const menuStyle = ref({ left: '0px', top: '0px' })
+ const menuAnchor = ref({ x: 0, y: 0 })
+ const submenuPosition = ref({ x: 0, y: 0 })
+ const hasSubmenuPosition = ref(false)
+ let positionRafId: number | null = null
+
+ const submenuStyle = computed(() => {
+ 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'
+}
diff --git a/apps/app-frontend/src/components/ui/context-menu/use-context-menu.ts b/apps/app-frontend/src/components/ui/context-menu/use-context-menu.ts
new file mode 100644
index 0000000000..a74b5012e0
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/context-menu/use-context-menu.ts
@@ -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(null)
+ const contextMenu = ref(null)
+ const submenu = ref(null)
+ const options = ref([])
+ const shown = ref(false)
+ const activeOptionIndex = ref(null)
+ const pendingOptionIndex = ref(null)
+ const isCursorInsideSubmenu = ref(false)
+ const isMobileSubmenuLayout = ref(false)
+ const lastMousePosition = ref(null)
+ const contextMenuId = Symbol()
+ const optionButtonRefs = new Map()
+ const submenuButtonRefs = new Map()
+ let previousMousePosition: Point | null = null
+ let pendingOptionTimeout: ReturnType | null = null
+ let mobileSubmenuMediaQuery: MediaQueryList | null = null
+
+ const activeOption = computed(() => {
+ 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,
+ index: number,
+ element: ButtonRefElement,
+) {
+ if (element instanceof HTMLElement) {
+ buttonRefs.set(index, element)
+ } else {
+ buttonRefs.delete(index)
+ }
+}
diff --git a/apps/app-frontend/src/components/ui/context-menu/utils.ts b/apps/app-frontend/src/components/ui/context-menu/utils.ts
new file mode 100644
index 0000000000..e7bd7c79d2
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/context-menu/utils.ts
@@ -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) {
+ 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)
+}
diff --git a/apps/app-frontend/src/components/ui/friends/FriendsSection.vue b/apps/app-frontend/src/components/ui/friends/FriendsSection.vue
index e53e7cc0f6..e9678ec7b7 100644
--- a/apps/app-frontend/src/components/ui/friends/FriendsSection.vue
+++ b/apps/app-frontend/src/components/ui/friends/FriendsSection.vue
@@ -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()
diff --git a/apps/app-frontend/src/components/ui/instance_settings/icon-editor-modal/editor-catalog.ts b/apps/app-frontend/src/components/ui/instance_settings/icon-editor-modal/editor-catalog.ts
index 18f6d06435..fb1f5f90fa 100644
--- a/apps/app-frontend/src/components/ui/instance_settings/icon-editor-modal/editor-catalog.ts
+++ b/apps/app-frontend/src/components/ui/instance_settings/icon-editor-modal/editor-catalog.ts
@@ -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,6 +201,10 @@ 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 const backgroundOptions = [
@@ -438,6 +446,15 @@ 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' },
+
+ /////////////////////////
+ // loaders
+ /////////////////////////
+
+ { id: 'fabric', name: names.fabric, asset: fabric, category: 'loader' },
+ { id: 'forge', name: names.forge, asset: forge, category: 'loader' },
+ { id: 'neoforge', name: names.neoForge, asset: neoForge, category: 'loader' },
+ { id: 'quilt', name: names.quilt, asset: quilt, category: 'loader' },
] as const
export type BackgroundId = (typeof backgroundOptions)[number]['id']
diff --git a/apps/app-frontend/src/components/ui/instance_settings/icon-editor-modal/index.vue b/apps/app-frontend/src/components/ui/instance_settings/icon-editor-modal/index.vue
index 12c53f983d..e8dddf3804 100644
--- a/apps/app-frontend/src/components/ui/instance_settings/icon-editor-modal/index.vue
+++ b/apps/app-frontend/src/components/ui/instance_settings/icon-editor-modal/index.vue
@@ -1,6 +1,7 @@
-
-
- {{ formatMessage(messages.jumpIn) }}
-
-
+
+
+
+ {{ formatMessage(messages.jumpIn) }}
+
+
+
-
-
-
- {{ formatMessage(messages.jumpIn) }}
-
-
-
-
+
+
+
+ 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)"
- />
- markInstancePlayed(item)"
- />
-
+ ? 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)"
+ />
+ markInstancePlayed(item)"
+ />
+
+
+
-
+
+
+
diff --git a/apps/app-frontend/src/components/ui/world/WorldItem.vue b/apps/app-frontend/src/components/ui/world/WorldItem.vue
index 919c0b82b0..8147d0bce0 100644
--- a/apps/app-frontend/src/components/ui/world/WorldItem.vue
+++ b/apps/app-frontend/src/components/ui/world/WorldItem.vue
@@ -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({
}}
-
-
+
-
+
+
+ {{
+ formatMessage(messages.playersOnline, {
+ count: formatNumber(serverStatus.players?.online ?? 0),
+ })
+ }}
+
+
+
+
+ {{ player.name }}
+
+
+
+
+
diff --git a/apps/app-frontend/src/composables/use-app-settings.ts b/apps/app-frontend/src/composables/use-app-settings.ts
index 4ef0a51609..d5e9ca22b0 100644
--- a/apps/app-frontend/src/composables/use-app-settings.ts
+++ b/apps/app-frontend/src/composables/use-app-settings.ts
@@ -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,
diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json
index 2753c00a8d..b68c532493 100644
--- a/apps/app-frontend/src/locales/en-US/index.json
+++ b/apps/app-frontend/src/locales/en-US/index.json
@@ -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"
},
diff --git a/apps/app-frontend/src/pages/Browse.vue b/apps/app-frontend/src/pages/Browse.vue
index fda335e37c..dc586a74e9 100644
--- a/apps/app-frontend/src/pages/Browse.vue
+++ b/apps/app-frontend/src/pages/Browse.vue
@@ -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'
diff --git a/apps/app-frontend/src/pages/Index.vue b/apps/app-frontend/src/pages/Index.vue
index 04a9b84833..c6157960d5 100644
--- a/apps/app-frontend/src/pages/Index.vue
+++ b/apps/app-frontend/src/pages/Index.vue
@@ -1,10 +1,10 @@
diff --git a/packages/ui/src/components/search/SearchFilterControl.vue b/packages/ui/src/components/search/SearchFilterControl.vue
index a2a9186c1f..9d5b6c0e84 100644
--- a/packages/ui/src/components/search/SearchFilterControl.vue
+++ b/packages/ui/src/components/search/SearchFilterControl.vue
@@ -1,15 +1,32 @@
Clear all filters
+
+
+ {{
+ formatMessage(includedProjectsMessage, {
+ projects: formatProjectNames(selectedIncludedProjectItems),
+ })
+ }}
+
+
+
+
+ {{
+ formatMessage(excludedProjectsMessage, {
+ projects: formatProjectNames(selectedExcludedProjectItems),
+ })
+ }}
+
@@ -37,23 +54,28 @@