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