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