Files
modrinth/apps/app-frontend/src/components/ui/library/use-library.ts
T
Truman Gao a85b8eed3f 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
2026-08-20 19:30:42 +00:00

1366 lines
40 KiB
TypeScript

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,
watch,
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, edit_icon, remove } from '@/helpers/instance'
import {
create_group as createInstanceGroup,
delete_group as deleteInstanceGroup,
FAVORITES_GROUP_ID,
type InstanceGroupDefinition,
list_groups as listInstanceGroups,
MAX_INSTANCE_GROUP_NAME_LENGTH,
rename_group as renameInstanceGroup,
set_group_memberships as setInstanceGroupMemberships,
set_group_order as setInstanceGroupOrder,
} from '@/helpers/instance-groups'
import type { GameInstance, InstanceIconConfig } from '@/helpers/types'
export const librarySortOptions = [
'Name',
'Last played',
'Hours played',
'Date created',
'Date modified',
'Loader',
'Game version',
] as const
export const libraryGroupOptions = [
{ value: 'Group', label: 'Custom group' },
{ value: 'Instance type', label: 'Instance type' },
{ value: 'Loader', label: 'Loader' },
{ value: 'Game version', label: 'Game version' },
{ value: 'None', label: 'No grouping' },
] as const
const libraryInstanceTypeLabels = {
custom: 'Custom',
modpack: 'Modpack',
server: 'Server',
} as const
const libraryLoaderPriority: Record<string, number> = {
fabric: 0,
forge: 1,
neoforge: 2,
}
export type LibrarySort = (typeof librarySortOptions)[number]
export type LibraryGroupBy = (typeof libraryGroupOptions)[number]['value']
export type LibraryFilters = Record<'instanceType' | 'gameVersion' | 'loader', string[]>
export type InstanceGroup = {
id: string
key: string
instances: GameInstance[]
}
export type LibraryInstanceSelection = {
instanceId: string
groupId: string
}
export type ActiveInstanceGroupDrag = {
instances: LibraryInstanceSelection[]
primaryInstanceId: string
fromGroup: string | null
}
export const getLibraryInstanceSelectionKey = ({ instanceId, groupId }: LibraryInstanceSelection) =>
JSON.stringify([groupId, instanceId])
export type InstanceCard = {
instance: GameInstance
playing: boolean
play: (event: MouseEvent | null, context: string) => Promise<void>
stop: (event: MouseEvent | null, context: string) => Promise<void>
addContent: () => Promise<void>
seeInstance: () => Promise<void>
openFolder: () => Promise<void>
}
type InstanceContextMenu = {
showMenu: (event: MouseEvent, item: InstanceCard, options: unknown[]) => void
}
type ConfirmDeleteModal = {
show: () => void
}
type IconEditorModal = {
show: () => void
}
type ContextMenuSelection = {
option: string
item: InstanceCard
}
function createLibraryState(instances: Ref<GameInstance[]>) {
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const search = ref('')
const filters = useStorage<LibraryFilters>(
'Instances-grid-filters',
{
instanceType: [],
gameVersion: [],
loader: [],
},
localStorage,
{ mergeDefaults: true },
)
const serverProjectIds = ref(new Set<string>())
const libraryGroups = ref<InstanceGroupDefinition[]>([])
const libraryGroupsLoaded = ref(false)
const isNewGroupModalOpen = ref(false)
const newGroupName = ref('')
const newGroupSearch = ref('')
const selectedNewGroupInstanceIds = ref(new Set<string>())
const isGroupInstancesModalOpen = ref(false)
const groupInstancesModalGroupId = ref<string | null>(null)
const groupInstancesSearch = ref('')
const selectedGroupInstanceIds = ref(new Set<string>())
const savingGroupInstances = ref(false)
const selectedLibraryInstances = ref(new Map<string, LibraryInstanceSelection>())
const isLibraryInstanceSelectionActive = computed(() => selectedLibraryInstances.value.size > 0)
const creatingGroup = ref(false)
const reorderingGroups = ref(false)
const groupIdPendingNameEdit = ref<string | null>(null)
const activeInstanceGroupDrag = ref<ActiveInstanceGroupDrag | null>(null)
const pendingInstanceGroupIds = ref(new Map<string, string[]>())
const activeDraggedInstanceKeys = computed(
() =>
new Set(activeInstanceGroupDrag.value?.instances.map(getLibraryInstanceSelectionKey) ?? []),
)
const instanceGroupDragTarget = ref<string | null>(null)
const instanceGroupDragPointer = ref({ x: 0, y: 0 })
const isAddingInstanceToGroup = ref(false)
const instanceOptions = ref<InstanceContextMenu | null>(null)
const currentDeleteInstanceId = ref<string | null>(null)
const currentDeleteInstances = computed(() =>
instances.value.filter((instance) => instance.id === currentDeleteInstanceId.value),
)
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 },
)
if (!librarySortOptions.includes(displayState.value.sortBy)) {
displayState.value.sortBy = 'Last played'
}
if (!libraryGroupOptions.some((option) => option.value === displayState.value.group)) {
displayState.value.group = 'Group'
}
const linkedInstances = computed(() => instances.value.filter((instance) => instance.link))
const isSearching = computed(() => search.value.length > 0)
const collapsedSectionKeys = computed(() => new Set(displayState.value.collapsedGroups))
const groupNames = computed(
() =>
new Set(
libraryGroups.value
.filter((group) => group.id !== FAVORITES_GROUP_ID)
.map((group) => group.name)
.map((group) => group.trim())
.filter((group) => group && group.toLowerCase() !== 'none'),
),
)
const existingGroupNames = computed(
() => new Set(['none', ...Array.from(groupNames.value, (group) => group.toLowerCase())]),
)
const normalizedNewGroupName = computed(() =>
newGroupName.value.trim().substring(0, MAX_INSTANCE_GROUP_NAME_LENGTH),
)
const newGroupInstances = computed(() => {
const query = newGroupSearch.value.trim().toLowerCase()
return instances.value
.filter((instance) => !query || instance.name.toLowerCase().includes(query))
.slice()
.sort((a, b) => {
const groupedDifference = Number(a.group_ids.length > 0) - Number(b.group_ids.length > 0)
if (groupedDifference !== 0) return groupedDifference
return a.name.localeCompare(b.name)
})
})
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()
return instances.value
.filter((instance) => !query || instance.name.toLowerCase().includes(query))
.slice()
.sort((a, b) => a.name.localeCompare(b.name))
})
const canCreateGroup = computed(
() => normalizedNewGroupName.value.length > 0 && !creatingGroup.value,
)
const customLibraryGroups = computed(() =>
libraryGroups.value.filter((group) => group.id !== FAVORITES_GROUP_ID),
)
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 () => {
try {
libraryGroups.value = await listInstanceGroups()
} catch (error) {
handleError(toError(error))
} finally {
libraryGroupsLoaded.value = true
}
}
void refreshGroups()
watchEffect(async () => {
const projectIds = [
...new Set(
linkedInstances.value.flatMap((instance) =>
instance.link?.project_id ? [instance.link.project_id] : [],
),
),
]
if (projectIds.length === 0) {
serverProjectIds.value = new Set()
return
}
try {
const projects = (await get_project_v3_many(
projectIds,
'must_revalidate',
)) as Array<Labrinth.Projects.v3.Project | null>
serverProjectIds.value = new Set(
projects
.filter(
(project): project is Labrinth.Projects.v3.Project => project?.minecraft_server != null,
)
.map((project) => project.id),
)
} catch {
serverProjectIds.value = new Set()
}
})
const getInstanceType = (instance: GameInstance) => {
if (!instance.link) return 'custom'
if (serverProjectIds.value.has(instance.link.project_id ?? '')) return 'server'
return 'modpack'
}
const getEffectiveInstanceGroupIds = (instance: GameInstance) =>
pendingInstanceGroupIds.value.get(instance.id) ?? instance.group_ids
const haveSameGroupIds = (first: string[], second: string[]) =>
first.length === second.length && first.every((groupId) => second.includes(groupId))
watch(
instances,
(currentInstances) => {
if (pendingInstanceGroupIds.value.size === 0) return
const nextPendingInstanceGroupIds = new Map(pendingInstanceGroupIds.value)
for (const instance of currentInstances) {
const pendingGroupIds = nextPendingInstanceGroupIds.get(instance.id)
if (pendingGroupIds && haveSameGroupIds(pendingGroupIds, instance.group_ids)) {
nextPendingInstanceGroupIds.delete(instance.id)
}
}
if (nextPendingInstanceGroupIds.size !== pendingInstanceGroupIds.value.size) {
pendingInstanceGroupIds.value = nextPendingInstanceGroupIds
}
},
{ flush: 'sync' },
)
const filteredInstances = computed(() =>
instances.value.filter((instance) => {
const instanceTypeMatches =
filters.value.instanceType.length === 0 ||
filters.value.instanceType.includes(getInstanceType(instance))
const gameVersionMatches =
filters.value.gameVersion.length === 0 ||
filters.value.gameVersion.includes(instance.game_version)
const loaderMatches =
filters.value.loader.length === 0 || filters.value.loader.includes(instance.loader)
return instanceTypeMatches && gameVersionMatches && loaderMatches
}),
)
const instanceGroups = computed<InstanceGroup[]>(() => {
const visibleInstances = filteredInstances.value.filter((instance) =>
instance.name.toLowerCase().includes(search.value.toLowerCase()),
)
switch (displayState.value.sortBy) {
case 'Name':
visibleInstances.sort((a, b) => a.name.localeCompare(b.name))
break
case 'Loader':
visibleInstances.sort((a, b) =>
formatLoader(formatMessage, a.loader).localeCompare(
formatLoader(formatMessage, b.loader),
),
)
break
case 'Game version':
visibleInstances.sort((a, b) =>
a.game_version.localeCompare(b.game_version, undefined, { numeric: true }),
)
break
case 'Last played':
visibleInstances.sort((a, b) => dayjs(b.last_played ?? 0).diff(dayjs(a.last_played ?? 0)))
break
case 'Hours played':
visibleInstances.sort(
(a, b) =>
b.recent_time_played +
b.submitted_time_played -
(a.recent_time_played + a.submitted_time_played),
)
break
case 'Date created':
visibleInstances.sort((a, b) => dayjs(b.created).diff(dayjs(a.created)))
break
case 'Date modified':
visibleInstances.sort((a, b) => dayjs(b.modified).diff(dayjs(a.modified)))
break
}
const groupedInstances = new Map<string, { name: string; instances: GameInstance[] }>()
const addToGroup = (id: string, name: string, instance: GameInstance) => {
const group = groupedInstances.get(id) ?? { name, instances: [] }
group.instances.push(instance)
groupedInstances.set(id, group)
}
const groupsById = new Map(libraryGroups.value.map((group) => [group.id, group]))
for (const instance of visibleInstances) {
const instanceGroupIds = getEffectiveInstanceGroupIds(instance)
switch (displayState.value.group) {
case 'Instance type':
{
const instanceType = getInstanceType(instance)
addToGroup(
`Instance type:${instanceType}`,
libraryInstanceTypeLabels[instanceType],
instance,
)
}
break
case 'Loader':
{
const name = formatLoader(formatMessage, instance.loader)
addToGroup(`Loader:${name}`, name, instance)
}
break
case 'Game version':
addToGroup(`Game version:${instance.game_version}`, instance.game_version, instance)
break
case 'Group':
if (instanceGroupIds.length === 0) {
addToGroup('group:none', 'None', instance)
} else {
for (const groupId of instanceGroupIds) {
const group = groupsById.get(groupId)
addToGroup(groupId, group?.name ?? groupId, instance)
}
}
break
case 'None':
addToGroup('None:None', 'None', instance)
break
}
}
if (displayState.value.group === 'Group') {
if (!groupedInstances.has('group:none')) {
groupedInstances.set('group:none', { name: 'None', instances: [] })
}
for (const group of libraryGroups.value) {
if (!groupedInstances.has(group.id)) {
groupedInstances.set(group.id, { name: group.name, instances: [] })
}
}
}
const groups = Array.from(groupedInstances, ([id, group]) => ({
id,
key: group.name,
instances: group.instances,
}))
if (displayState.value.sortBy === 'Name') {
groups.sort((a, b) => a.key.localeCompare(b.key) || a.id.localeCompare(b.id))
}
if (displayState.value.group === 'Loader') {
groups.sort((a, b) => {
const aPriority = libraryLoaderPriority[a.key.toLowerCase()] ?? Number.MAX_SAFE_INTEGER
const bPriority = libraryLoaderPriority[b.key.toLowerCase()] ?? Number.MAX_SAFE_INTEGER
return aPriority - bPriority || a.key.localeCompare(b.key) || a.id.localeCompare(b.id)
})
}
if (displayState.value.group === 'Instance type') {
groups.sort((a, b) => a.key.localeCompare(b.key) || a.id.localeCompare(b.id))
}
if (displayState.value.group === 'Game version') {
groups.sort((a, b) => b.key.localeCompare(a.key, undefined, { numeric: true }))
}
if (displayState.value.group === 'Group') {
groups.sort((a, b) => {
if (a.id === b.id) return 0
if (a.id === FAVORITES_GROUP_ID) return -1
if (b.id === FAVORITES_GROUP_ID) return 1
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)
})
}
return groups
})
const getSectionKey = (sectionId: string) => `${displayState.value.group}:${sectionId}`
const isSectionCollapsed = (sectionId: string) =>
!isSearching.value && collapsedSectionKeys.value.has(getSectionKey(sectionId))
const setSectionCollapsed = (sectionId: string, collapsed: boolean) => {
if (isSearching.value) return
const sectionKey = getSectionKey(sectionId)
const collapsedSections = new Set(displayState.value.collapsedGroups)
if (collapsed) {
collapsedSections.add(sectionKey)
} else {
collapsedSections.delete(sectionKey)
}
displayState.value.collapsedGroups = [...collapsedSections]
}
const normalizeInstanceGroupId = (groupId: string) => (groupId === 'group:none' ? null : groupId)
const updateInstanceGroupDrag = (
pointer?: { x: number; y: number },
altKey = isAddingInstanceToGroup.value,
) => {
if (pointer) {
instanceGroupDragPointer.value = {
x: pointer.x,
y: pointer.y,
}
}
isAddingInstanceToGroup.value = altKey
}
const startInstanceGroupDrag = (
instanceId: string,
groupId: string,
pointer?: { x: number; y: number },
altKey = false,
) => {
if (displayState.value.group !== 'Group') return
const primaryInstance = { instanceId, groupId }
const primaryInstanceKey = getLibraryInstanceSelectionKey(primaryInstance)
const draggedInstances = selectedLibraryInstances.value.has(primaryInstanceKey)
? [
primaryInstance,
...[...selectedLibraryInstances.value.values()].filter(
(selectedInstance) =>
getLibraryInstanceSelectionKey(selectedInstance) !== primaryInstanceKey,
),
].filter((selectedInstance) =>
instances.value.some((instance) => instance.id === selectedInstance.instanceId),
)
: [primaryInstance]
activeInstanceGroupDrag.value = {
instances: draggedInstances,
primaryInstanceId: instanceId,
fromGroup: normalizeInstanceGroupId(groupId),
}
updateInstanceGroupDrag(pointer, altKey)
}
const finishInstanceGroupDrag = () => {
activeInstanceGroupDrag.value = null
instanceGroupDragTarget.value = null
isAddingInstanceToGroup.value = false
}
const setInstanceGroupDragTarget = (groupId: string | null) => {
instanceGroupDragTarget.value = groupId
}
const getInstanceGroupDropState = (groupId: string) => {
const drag = activeInstanceGroupDrag.value
const draggedSelectionsByInstanceId = new Map<string, LibraryInstanceSelection[]>()
for (const selection of drag?.instances ?? []) {
const selections = draggedSelectionsByInstanceId.get(selection.instanceId) ?? []
selections.push(selection)
draggedSelectionsByInstanceId.set(selection.instanceId, selections)
}
const draggedInstanceIds = new Set(
drag?.instances.map((selection) => selection.instanceId) ?? [],
)
const draggedInstances = instances.value.filter((instance) =>
draggedInstanceIds.has(instance.id),
)
const toGroup = normalizeInstanceGroupId(groupId)
const operation = isAddingInstanceToGroup.value ? 'add' : 'move'
const canAddToGroup = operation !== 'add' || toGroup !== null
const hasChanges = draggedInstances.some((instance) => {
const instanceGroupIds = getEffectiveInstanceGroupIds(instance)
if (operation === 'add') {
return toGroup !== null && !instanceGroupIds.includes(toGroup)
}
if (toGroup !== null) {
return !instanceGroupIds.includes(toGroup)
}
const sourceGroupIds = new Set(
(draggedSelectionsByInstanceId.get(instance.id) ?? [])
.map((selection) => normalizeInstanceGroupId(selection.groupId))
.filter((sourceGroupId): sourceGroupId is string => sourceGroupId !== null),
)
return instanceGroupIds.some((sourceGroupId) => sourceGroupIds.has(sourceGroupId))
})
return {
alreadyInGroup: draggedInstances.length > 0 && !hasChanges,
canDrop: !!drag && draggedInstances.length > 0 && hasChanges && canAddToGroup,
operation,
} as const
}
const instanceGroupDragStatus = computed(() => {
const target = instanceGroupDragTarget.value
if (target) {
const dropState = getInstanceGroupDropState(target)
const isSourceGroup =
normalizeInstanceGroupId(target) === activeInstanceGroupDrag.value?.fromGroup
if (!isSourceGroup && !dropState.canDrop && dropState.alreadyInGroup) {
return 'Already in this group'
}
if (!dropState.canDrop || dropState.operation !== 'add') return
const count = new Set(
activeInstanceGroupDrag.value?.instances.map((selection) => selection.instanceId) ?? [],
).size
return count > 1 ? `Duplicate ${count} instances to group` : 'Duplicate into group'
}
if (isAddingInstanceToGroup.value) return 'Duplicate to group'
return
})
const moveDraggedInstancesToGroup = async (groupId: string, addToGroup = false) => {
const drag = activeInstanceGroupDrag.value
const toGroup = normalizeInstanceGroupId(groupId)
if (!drag) return false
const dropState = getInstanceGroupDropState(groupId)
if (!dropState.canDrop) return false
const shouldAdd = addToGroup && toGroup !== null
const draggedSelectionsByInstanceId = new Map<string, LibraryInstanceSelection[]>()
for (const selection of drag.instances) {
const selections = draggedSelectionsByInstanceId.get(selection.instanceId) ?? []
selections.push(selection)
draggedSelectionsByInstanceId.set(selection.instanceId, selections)
}
const draggedInstanceIds = new Set(draggedSelectionsByInstanceId.keys())
const draggedInstances = instances.value.filter((instance) =>
draggedInstanceIds.has(instance.id),
)
const operations = draggedInstances.flatMap((instance) => {
const instanceGroupIds = getEffectiveInstanceGroupIds(instance)
if (toGroup && instanceGroupIds.includes(toGroup)) {
return []
}
const selections = draggedSelectionsByInstanceId.get(instance.id) ?? []
let nextGroupIds = [...instanceGroupIds]
if (shouldAdd && toGroup) {
nextGroupIds.push(toGroup)
} else {
const sourceGroupIds = new Set(
selections
.map((selection) => normalizeInstanceGroupId(selection.groupId))
.filter((sourceGroupId): sourceGroupId is string => sourceGroupId !== null),
)
nextGroupIds = nextGroupIds.filter((sourceGroupId) => !sourceGroupIds.has(sourceGroupId))
if (toGroup) {
nextGroupIds.push(toGroup)
}
}
return [
{
instanceId: instance.id,
nextGroupIds,
},
]
})
const nextPendingInstanceGroupIds = new Map(pendingInstanceGroupIds.value)
for (const operation of operations) {
nextPendingInstanceGroupIds.set(operation.instanceId, operation.nextGroupIds)
}
pendingInstanceGroupIds.value = nextPendingInstanceGroupIds
try {
await setInstanceGroupMemberships(
operations.map((operation) => ({
instance_id: operation.instanceId,
group_ids: operation.nextGroupIds,
})),
)
} catch (error) {
const pendingInstanceGroupIdsAfterFailure = new Map(pendingInstanceGroupIds.value)
for (const operation of operations) {
const pendingGroupIds = pendingInstanceGroupIdsAfterFailure.get(operation.instanceId)
if (pendingGroupIds && haveSameGroupIds(pendingGroupIds, operation.nextGroupIds)) {
pendingInstanceGroupIdsAfterFailure.delete(operation.instanceId)
}
}
pendingInstanceGroupIds.value = pendingInstanceGroupIdsAfterFailure
handleError(toError(error))
return false
}
selectedLibraryInstances.value = new Map()
return operations.length > 0
}
useEventListener(window, 'keydown', (event) => {
if (event.key === 'Alt' && activeInstanceGroupDrag.value) {
isAddingInstanceToGroup.value = true
}
})
useEventListener(window, 'keyup', (event) => {
if (event.key === 'Alt') {
isAddingInstanceToGroup.value = false
}
})
useEventListener(window, 'blur', () => {
isAddingInstanceToGroup.value = false
})
const getDefaultNewGroupName = () => {
let groupNumber = groupNames.value.size + 1
while (existingGroupNames.value.has(`group ${groupNumber}`.toLowerCase())) {
groupNumber++
}
return `Group ${groupNumber}`
}
const openNewGroupModal = (instanceIds: Iterable<string> = []) => {
newGroupName.value = getDefaultNewGroupName()
newGroupSearch.value = ''
selectedNewGroupInstanceIds.value = new Set(instanceIds)
isNewGroupModalOpen.value = true
}
const closeNewGroupModal = () => {
isNewGroupModalOpen.value = false
}
const openGroupInstancesModal = (groupId: string) => {
const group = libraryGroups.value.find((candidate) => candidate.id === groupId)
if (!group && groupId !== 'group:none') return
groupInstancesModalGroupId.value = groupId
groupInstancesSearch.value = ''
selectedGroupInstanceIds.value = new Set(
instances.value
.filter((instance) =>
groupId === 'group:none'
? instance.group_ids.length === 0
: instance.group_ids.includes(groupId),
)
.map((instance) => instance.id),
)
isGroupInstancesModalOpen.value = true
}
const closeGroupInstancesModal = () => {
isGroupInstancesModalOpen.value = false
groupInstancesModalGroupId.value = null
}
const toggleGroupInstance = (instanceId: string) => {
const selectedIds = new Set(selectedGroupInstanceIds.value)
if (selectedIds.has(instanceId)) {
if (groupInstancesModalGroupId.value === 'group:none') return
selectedIds.delete(instanceId)
} else {
selectedIds.add(instanceId)
}
selectedGroupInstanceIds.value = selectedIds
}
const saveGroupInstances = async () => {
const groupId = groupInstancesModalGroupId.value
if (!groupId || savingGroupInstances.value) return false
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 = isUngrouped
? []
: shouldIncludeGroup
? [...instance.group_ids, groupId]
: instance.group_ids.filter((instanceGroupId) => instanceGroupId !== groupId)
return {
instance,
shouldIncludeGroup,
nextGroupIds,
}
})
savingGroupInstances.value = true
try {
await setInstanceGroupMemberships(
operations.map(({ instance, nextGroupIds }) => ({
instance_id: instance.id,
group_ids: nextGroupIds,
})),
)
const nextSelectedInstances = new Map(selectedLibraryInstances.value)
for (const { instance, shouldIncludeGroup, nextGroupIds } of operations) {
const instanceSelections = [...nextSelectedInstances.values()].filter(
(selection) => selection.instanceId === instance.id,
)
if (shouldIncludeGroup && instanceSelections.length > 0) {
for (const selection of instanceSelections) {
nextSelectedInstances.delete(getLibraryInstanceSelectionKey(selection))
}
const destinationSelection = {
instanceId: instance.id,
groupId,
}
nextSelectedInstances.set(
getLibraryInstanceSelectionKey(destinationSelection),
destinationSelection,
)
continue
}
const validSelectionGroupIds = new Set(
nextGroupIds.length > 0 ? nextGroupIds : ['group:none'],
)
const invalidSelections = instanceSelections.filter(
(selection) => !validSelectionGroupIds.has(selection.groupId),
)
for (const selection of invalidSelections) {
nextSelectedInstances.delete(getLibraryInstanceSelectionKey(selection))
}
const hasValidSelection = [...nextSelectedInstances.values()].some(
(selection) => selection.instanceId === instance.id,
)
if (invalidSelections.length > 0 && !hasValidSelection) {
const replacementSelection = {
instanceId: instance.id,
groupId: nextGroupIds[0] ?? 'group:none',
}
nextSelectedInstances.set(
getLibraryInstanceSelectionKey(replacementSelection),
replacementSelection,
)
}
}
selectedLibraryInstances.value = nextSelectedInstances
return true
} catch (error) {
handleError(toError(error))
return false
} finally {
savingGroupInstances.value = false
}
}
const toggleNewGroupInstance = (instanceId: string) => {
const selectedIds = new Set(selectedNewGroupInstanceIds.value)
if (selectedIds.has(instanceId)) {
selectedIds.delete(instanceId)
} else {
selectedIds.add(instanceId)
}
selectedNewGroupInstanceIds.value = selectedIds
}
const clearLibraryInstanceSelection = () => {
selectedLibraryInstances.value = new Map()
}
watch(() => displayState.value.group, clearLibraryInstanceSelection)
const setSelectedLibraryInstances = (selections: Iterable<LibraryInstanceSelection>) => {
selectedLibraryInstances.value = new Map(
[...selections].map((selection) => [getLibraryInstanceSelectionKey(selection), selection]),
)
}
const toggleLibraryInstanceSelection = (selection: LibraryInstanceSelection) => {
const selectedInstances = new Map(selectedLibraryInstances.value)
const selectionKey = getLibraryInstanceSelectionKey(selection)
if (selectedInstances.has(selectionKey)) {
selectedInstances.delete(selectionKey)
} else {
selectedInstances.set(selectionKey, selection)
}
selectedLibraryInstances.value = selectedInstances
}
useEventListener(window, 'keydown', (event) => {
if (
event.key === 'Escape' &&
!event.defaultPrevented &&
isLibraryInstanceSelectionActive.value
) {
clearLibraryInstanceSelection()
}
})
const createGroup = async () => {
if (!canCreateGroup.value) return false
creatingGroup.value = true
try {
const group = await createInstanceGroup(normalizedNewGroupName.value)
libraryGroups.value = [
group,
...libraryGroups.value.filter((existingGroup) => existingGroup.id !== group.id),
]
await setInstanceGroupMemberships(
instances.value
.filter((instance) => selectedNewGroupInstanceIds.value.has(instance.id))
.map((instance) => ({
instance_id: instance.id,
group_ids: [...instance.group_ids, group.id],
})),
)
return true
} catch (error) {
handleError(toError(error))
await refreshGroups()
return false
} finally {
creatingGroup.value = false
}
}
const createDefaultGroup = async (instanceIds: Iterable<string> = []) => {
if (creatingGroup.value) return false
creatingGroup.value = true
try {
const group = await createInstanceGroup(getDefaultNewGroupName())
const selectedInstanceIds = new Set(instanceIds)
const instancesToAdd = instances.value.filter((instance) =>
selectedInstanceIds.has(instance.id),
)
await setInstanceGroupMemberships(
instancesToAdd.map((instance) => ({
instance_id: instance.id,
group_ids: [...instance.group_ids, group.id],
})),
)
libraryGroups.value = [
group,
...libraryGroups.value.filter((existingGroup) => existingGroup.id !== group.id),
]
displayState.value.group = 'Group'
groupIdPendingNameEdit.value = group.id
return true
} catch (error) {
handleError(toError(error))
await refreshGroups()
return false
} finally {
creatingGroup.value = false
}
}
const completePendingGroupNameEdit = (groupId: string) => {
if (groupIdPendingNameEdit.value === groupId) {
groupIdPendingNameEdit.value = null
}
}
const deleteGroup = async (groupId: string) => {
try {
await deleteInstanceGroup(groupId)
libraryGroups.value = libraryGroups.value.filter((group) => group.id !== groupId)
return true
} catch (error) {
handleError(toError(error))
await refreshGroups()
return false
}
}
const renameGroup = async (groupId: string, newName: string) => {
const normalizedNewName = newName.trim()
const currentGroup = libraryGroups.value.find((group) => group.id === groupId)
if (currentGroup?.name === normalizedNewName) return true
try {
const renamedGroup = await renameInstanceGroup(groupId, normalizedNewName)
libraryGroups.value = libraryGroups.value.map((group) =>
group.id === groupId ? renamedGroup : group,
)
return true
} catch (error) {
handleError(toError(error))
await refreshGroups()
return false
}
}
const canMoveGroupUp = (groupId: string) =>
!reorderingGroups.value &&
orderedLibraryGroupIds.value.findIndex((orderedGroupId) => orderedGroupId === groupId) > 0
const canMoveGroupDown = (groupId: string) => {
const groupIndex = orderedLibraryGroupIds.value.findIndex(
(orderedGroupId) => orderedGroupId === groupId,
)
return (
!reorderingGroups.value &&
groupIndex >= 0 &&
groupIndex < orderedLibraryGroupIds.value.length - 1
)
}
const reorderGroups = async (orderedGroupIds: string[]) => {
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) => !reorderableGroupIds.has(groupId))
) {
return false
}
let orderedGroupIndex = 0
const reorderedGroupIds = orderedLibraryGroupIds.value.map((groupId) =>
orderedGroupIdSet.has(groupId) ? orderedGroupIds[orderedGroupIndex++] : groupId,
)
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 {
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
} finally {
reorderingGroups.value = false
}
}
const moveGroup = async (groupId: string, direction: -1 | 1) => {
const orderedGroupIds = [...orderedLibraryGroupIds.value]
const groupIndex = orderedGroupIds.indexOf(groupId)
const targetIndex = groupIndex + direction
if (groupIndex < 0 || targetIndex < 0 || targetIndex >= orderedGroupIds.length) {
return false
}
const targetGroupId = orderedGroupIds[targetIndex]
orderedGroupIds[targetIndex] = orderedGroupIds[groupIndex]
orderedGroupIds[groupIndex] = targetGroupId
return await reorderGroups(orderedGroupIds)
}
const deleteInstance = async () => {
if (!currentDeleteInstanceId.value) return
await remove(currentDeleteInstanceId.value).catch((error) => handleError(toError(error)))
currentDeleteInstanceId.value = null
}
const duplicateInstance = async (instanceId: string) => {
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,
instanceGroupId: string,
) => {
currentContextGroupId.value =
displayState.value.group === 'Group' &&
instanceGroupId !== 'group:none' &&
instanceGroupId !== FAVORITES_GROUP_ID
? instanceGroupId
: null
const baseOptions = [
{
name: item.instance.group_ids.includes(FAVORITES_GROUP_ID)
? 'remove_from_favorites'
: 'add_to_favorites',
},
{ type: 'divider' },
...(!item.instance.quarantined && !item.instance.link
? [{ name: 'add_content' }, { type: 'divider' }]
: []),
{ name: 'edit' },
{ 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' }]),
{ name: 'delete', color: 'danger' },
]
instanceOptions.value?.showMenu(
event,
item,
item.playing
? [{ name: 'stop', color: 'danger' }, ...baseOptions]
: [
...(item.instance.quarantined ? [] : [{ name: 'play', color: 'primary' }]),
...baseOptions,
],
)
}
const handleInstanceOption = async ({ option, item }: ContextMenuSelection) => {
switch (option) {
case 'play':
await item.play(null, 'InstanceGridContextMenu')
break
case 'stop':
await item.stop(null, 'InstanceGridContextMenu')
break
case 'add_content':
await item.addContent()
break
case 'add_to_favorites':
await edit(item.instance.id, {
group_ids: [...new Set([...item.instance.group_ids, FAVORITES_GROUP_ID])],
}).catch((error) => handleError(toError(error)))
break
case 'remove_from_favorites':
await edit(item.instance.id, {
group_ids: item.instance.group_ids.filter(
(instanceGroupId) => instanceGroupId !== FAVORITES_GROUP_ID,
),
}).catch((error) => handleError(toError(error)))
break
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)
}
break
case 'open':
await item.openFolder()
break
case 'copy':
await navigator.clipboard.writeText(item.instance.id)
break
case 'remove_from_group':
if (currentContextGroupId.value) {
const groupId = currentContextGroupId.value
await edit(item.instance.id, {
group_ids: item.instance.group_ids.filter(
(instanceGroupId) => instanceGroupId !== groupId,
),
}).catch((error) => handleError(toError(error)))
}
break
case 'delete':
currentDeleteInstanceId.value = item.instance.id
confirmDeleteModal.value?.show()
break
}
}
return {
instances,
libraryGroups,
libraryGroupsLoaded,
search,
isSearching,
filters,
displayState,
instanceGroups,
isNewGroupModalOpen,
newGroupName,
newGroupSearch,
selectedNewGroupInstanceIds,
isGroupInstancesModalOpen,
groupInstancesModalGroup,
groupInstancesSearch,
selectedGroupInstanceIds,
savingGroupInstances,
selectedLibraryInstances,
isLibraryInstanceSelectionActive,
activeInstanceGroupDrag,
activeDraggedInstanceKeys,
instanceGroupDragTarget,
instanceGroupDragPointer,
instanceGroupDragStatus,
isAddingInstanceToGroup,
creatingGroup,
reorderingGroups,
groupIdPendingNameEdit,
newGroupInstances,
groupInstances,
canCreateGroup,
instanceOptions,
confirmDeleteModal,
iconEditorModal,
currentIconEditorInstance,
currentDeleteInstances,
isSectionCollapsed,
setSectionCollapsed,
startInstanceGroupDrag,
updateInstanceGroupDrag,
finishInstanceGroupDrag,
setInstanceGroupDragTarget,
getInstanceGroupDropState,
moveDraggedInstancesToGroup,
openNewGroupModal,
closeNewGroupModal,
openGroupInstancesModal,
closeGroupInstancesModal,
toggleNewGroupInstance,
toggleGroupInstance,
saveGroupInstances,
clearLibraryInstanceSelection,
setSelectedLibraryInstances,
toggleLibraryInstanceSelection,
createGroup,
createDefaultGroup,
completePendingGroupNameEdit,
deleteGroup,
renameGroup,
canMoveGroupUp,
canMoveGroupDown,
reorderGroups,
moveGroup,
deleteInstance,
handleInstanceContextMenu,
handleInstanceOption,
handleInstanceIconSaved,
}
}
export type LibraryState = ReturnType<typeof createLibraryState>
const libraryKey: InjectionKey<LibraryState> = Symbol('library')
export function provideLibrary(instances: Ref<GameInstance[]>) {
const library = createLibraryState(instances)
provide(libraryKey, library)
return library
}
export function useLibrary() {
const library = inject(libraryKey)
if (!library) {
throw new Error('useLibrary must be called within a library provider')
}
return library
}