feat: create group button, fix instance selection, and increase group name max length

This commit is contained in:
tdgao
2026-07-29 15:13:58 -07:00
parent 1454e5baa8
commit 304b8338f8
11 changed files with 166 additions and 82 deletions
@@ -150,7 +150,7 @@ const addCategory = async () => {
if (text.length > 0) {
try {
const group = await create_group(text.substring(0, 32))
const group = await create_group(text.substring(0, 128))
availableGroups.value.push(group)
groupIds.value.push(group.id)
newCategoryInput.value = ''
@@ -383,6 +383,7 @@ const messages = defineMessages({
<StyledInput
v-model="newCategoryInput"
:placeholder="formatMessage(messages.libraryGroupsEnterName)"
:maxlength="128"
class="w-full max-w-[300px]"
@submit="() => addCategory"
/>
@@ -6,7 +6,7 @@
>
<div class="flex items-center gap-0.5">
<span class="px-4 py-2.5 text-base font-semibold text-contrast tabular-nums">
{{ formatMessage(messages.selectedCount, { count: selectedLibraryInstanceIds.size }) }}
{{ formatMessage(messages.selectedCount, { count: selectedLibraryInstances.size }) }}
</span>
<div class="mx-1 h-6 w-px bg-surface-5" />
<ButtonStyled type="transparent">
@@ -21,6 +21,12 @@
</ButtonStyled>
</div>
<div class="ml-auto flex items-center gap-0.5">
<ButtonStyled type="transparent">
<button type="button" :disabled="busy" @click="createGroupFromSelection">
<PlusIcon />
<span class="bar-label">{{ formatMessage(messages.createGroup) }}</span>
</button>
</ButtonStyled>
<ButtonStyled v-if="selectedGroupedInstances.length > 0" type="transparent">
<button type="button" :disabled="busy" @click="removeSelectedInstancesFromGroups">
<MinusIcon />
@@ -43,13 +49,13 @@
</FloatingActionBar>
<ConfirmDeleteInstanceModal
ref="confirmDeleteModal"
:count="selectedInstanceCount"
:count="selectedInstanceIds.size"
@delete="deleteSelectedInstances"
/>
</template>
<script setup lang="ts">
import { MinusIcon, TrashIcon } from '@modrinth/assets'
import { MinusIcon, PlusIcon, TrashIcon } from '@modrinth/assets'
import {
ButtonStyled,
commonMessages,
@@ -60,7 +66,7 @@ import {
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import { useLibrary } from '@/components/ui/library/use-library'
import { getLibraryInstanceSelectionKey, useLibrary } from '@/components/ui/library/use-library'
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
import { toError } from '@/helpers/errors'
import { edit, remove } from '@/helpers/instance'
@@ -69,20 +75,25 @@ const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const {
instances,
selectedLibraryInstanceIds,
selectedLibraryInstances,
clearLibraryInstanceSelection,
setSelectedLibraryInstanceIds,
setSelectedLibraryInstances,
openNewGroupModal,
} = useLibrary()
const confirmDeleteModal = ref<InstanceType<typeof ConfirmDeleteInstanceModal>>()
const deleting = ref(false)
const removingFromGroup = ref(false)
const selectedInstanceCount = computed(() => selectedLibraryInstanceIds.value.size)
const selectedInstanceCount = computed(() => selectedLibraryInstances.value.size)
const selectedInstanceIds = computed(
() =>
new Set([...selectedLibraryInstances.value.values()].map((selection) => selection.instanceId)),
)
const selectedGroupedInstances = computed(() =>
instances.value.filter(
(instance) =>
selectedLibraryInstanceIds.value.has(instance.id) && instance.group_ids.length > 0,
),
[...selectedLibraryInstances.value.values()].flatMap((selection) => {
const instance = instances.value.find((candidate) => candidate.id === selection.instanceId)
return instance?.group_ids.includes(selection.groupId) ? [{ instance, selection }] : []
}),
)
const busy = computed(() => deleting.value || removingFromGroup.value)
@@ -99,26 +110,59 @@ const messages = defineMessages({
id: 'app.library.selection.deleting',
defaultMessage: 'Deleting selected instances',
},
createGroup: {
id: 'app.library.selection.create-group',
defaultMessage: 'Create group',
},
removeFromGroup: {
id: 'app.library.selection.remove-from-group',
defaultMessage: 'Remove from group',
},
})
function createGroupFromSelection() {
openNewGroupModal(selectedInstanceIds.value)
}
async function removeSelectedInstancesFromGroups() {
if (busy.value || selectedGroupedInstances.value.length === 0) return
removingFromGroup.value = true
const selectedGroupIdsByInstanceId = new Map<string, Set<string>>()
for (const { instance, selection } of selectedGroupedInstances.value) {
const groupIds = selectedGroupIdsByInstanceId.get(instance.id) ?? new Set()
groupIds.add(selection.groupId)
selectedGroupIdsByInstanceId.set(instance.id, groupIds)
}
const operations = [...selectedGroupIdsByInstanceId].flatMap(([instanceId, groupIds]) => {
const instance = instances.value.find((candidate) => candidate.id === instanceId)
return instance ? [{ instance, groupIds }] : []
})
const results = await Promise.allSettled(
selectedGroupedInstances.value.map((instance) => edit(instance.id, { group_ids: [] })),
operations.map(({ instance, groupIds }) => {
return edit(instance.id, {
group_ids: instance.group_ids.filter((groupId) => !groupIds.has(groupId)),
})
}),
)
for (const result of results) {
const nextSelectedInstances = new Map(selectedLibraryInstances.value)
for (const [index, result] of results.entries()) {
if (result.status === 'rejected') {
handleError(toError(result.reason))
} else {
for (const groupId of operations[index].groupIds) {
nextSelectedInstances.delete(
getLibraryInstanceSelectionKey({
instanceId: operations[index].instance.id,
groupId,
}),
)
}
}
}
setSelectedLibraryInstances(nextSelectedInstances.values())
removingFromGroup.value = false
}
@@ -126,19 +170,23 @@ async function deleteSelectedInstances() {
if (busy.value || selectedInstanceCount.value === 0) return
deleting.value = true
const instanceIds = [...selectedLibraryInstanceIds.value]
const instanceIds = [...selectedInstanceIds.value]
const results = await Promise.allSettled(instanceIds.map((instanceId) => remove(instanceId)))
const nextSelectedInstanceIds = new Set(selectedLibraryInstanceIds.value)
const deletedInstanceIds = new Set<string>()
for (const [index, result] of results.entries()) {
if (result.status === 'rejected') {
handleError(toError(result.reason))
} else {
nextSelectedInstanceIds.delete(instanceIds[index])
deletedInstanceIds.add(instanceIds[index])
}
}
setSelectedLibraryInstanceIds(nextSelectedInstanceIds)
setSelectedLibraryInstances(
[...selectedLibraryInstances.value.values()].filter(
(selection) => !deletedInstanceIds.has(selection.instanceId),
),
)
deleting.value = false
}
</script>
@@ -16,7 +16,7 @@ import InstanceGroup from '@/components/ui/library/instance-group/index.vue'
import InstanceGroupDnd from '@/components/ui/library/instance-group/instance-group-dnd.vue'
import LibraryToolbar from '@/components/ui/library/library-toolbar/index.vue'
import LibrarySelectionActionBar from '@/components/ui/library/LibrarySelectionActionBar.vue'
import { provideLibrary } from '@/components/ui/library/use-library'
import { getLibraryInstanceSelectionKey, provideLibrary } from '@/components/ui/library/use-library'
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
import type { GameInstance } from '@/helpers/types'
@@ -30,8 +30,8 @@ const {
confirmDeleteModal,
deleteInstance,
handleInstanceOption,
selectedLibraryInstanceIds,
setSelectedLibraryInstanceIds,
selectedLibraryInstances,
setSelectedLibraryInstances,
toggleLibraryInstanceSelection,
} = provideLibrary(toRef(props, 'instances'))
@@ -62,36 +62,37 @@ function handleToggleInstance(groupId: string, instanceId: string, shiftKey: boo
)
if (anchorIndex === -1 || targetIndex === -1) {
toggleLibraryInstanceSelection(instanceId)
toggleLibraryInstanceSelection({ groupId, instanceId })
return
}
const start = Math.min(anchorIndex, targetIndex)
const end = Math.max(anchorIndex, targetIndex)
const range = displayedInstances.slice(start, end + 1)
const nextSelectedIds = new Set(selectedLibraryInstanceIds.value)
const nextSelectedInstances = new Map(selectedLibraryInstances.value)
const targetKey = getLibraryInstanceSelectionKey({ groupId, instanceId })
if (nextSelectedIds.has(instanceId)) {
if (nextSelectedInstances.has(targetKey)) {
for (const instance of range) {
nextSelectedIds.delete(instance.instanceId)
nextSelectedInstances.delete(getLibraryInstanceSelectionKey(instance))
}
} else {
for (const instance of range) {
nextSelectedIds.add(instance.instanceId)
nextSelectedInstances.set(getLibraryInstanceSelectionKey(instance), instance)
}
}
setSelectedLibraryInstanceIds(nextSelectedIds)
setSelectedLibraryInstances(nextSelectedInstances.values())
anchorInstance.value = null
return
}
toggleLibraryInstanceSelection(instanceId)
toggleLibraryInstanceSelection({ groupId, instanceId })
anchorInstance.value = { groupId, instanceId }
}
watch(
() => selectedLibraryInstanceIds.value.size,
() => selectedLibraryInstances.value.size,
(selectedInstanceCount) => {
if (selectedInstanceCount === 0) {
anchorInstance.value = null
@@ -44,10 +44,8 @@ const draggedInstances = computed(() => {
const drag = activeInstanceGroupDrag.value
if (!drag) return []
return drag.instanceIds.flatMap((instanceId) => {
const instance = props.instances.find((candidate) => candidate.id === instanceId)
return instance ? [instance] : []
})
const draggedInstanceIds = new Set(drag.instances.map((selection) => selection.instanceId))
return props.instances.filter((instance) => draggedInstanceIds.has(instance.id))
})
const draggedInstance = computed(() => {
const drag = activeInstanceGroupDrag.value
@@ -8,7 +8,7 @@ import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import InstanceFileIcon from '@/assets/icons/instance-file.svg'
import { useLibrary } from '@/components/ui/library/use-library'
import { getLibraryInstanceSelectionKey, useLibrary } from '@/components/ui/library/use-library'
import { trackEvent } from '@/helpers/analytics'
import { process_listener } from '@/helpers/events'
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
@@ -28,9 +28,9 @@ type ProcessEventPayload = {
const { handleError } = injectNotificationManager()
const {
displayState,
selectedLibraryInstanceIds,
selectedLibraryInstances,
isLibraryInstanceSelectionActive,
activeDraggedInstanceIds,
activeDraggedInstanceKeys,
} = useLibrary()
const props = defineProps<{
@@ -55,10 +55,16 @@ const modLoading = computed(
)
const installing = computed(() => props.instance.install_stage.includes('installing'))
const installed = computed(() => props.instance.install_stage === 'installed')
const selected = computed(() => selectedLibraryInstanceIds.value.has(props.instance.id))
const selectionKey = computed(() =>
getLibraryInstanceSelectionKey({
instanceId: props.instance.id,
groupId: props.instanceGroupId,
}),
)
const selected = computed(() => selectedLibraryInstances.value.has(selectionKey.value))
const keys = useMagicKeys()
const holdingShift = computed(() => keys.shift.value)
const isPartOfActiveDrag = computed(() => activeDraggedInstanceIds.value.has(props.instance.id))
const isPartOfActiveDrag = computed(() => activeDraggedInstanceKeys.value.has(selectionKey.value))
const { isDragging } = useDraggable({
id: computed(() => `instance:${props.instanceGroupId}:${props.instance.id}`),
element: instanceCard,
@@ -59,7 +59,7 @@ export function useInstanceDragGather(instances: Ref<GameInstance[]>) {
clear()
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
if (!drag || drag.instanceIds.length < 2 || reduceMotion) return
if (!drag || drag.instances.length < 2 || reduceMotion) return
const instanceCards = Array.from(
document.querySelectorAll<HTMLElement>('[data-library-instance-card]'),
@@ -78,13 +78,13 @@ export function useInstanceDragGather(instances: Ref<GameInstance[]>) {
}
}
items.value = drag.instanceIds.flatMap((instanceId) => {
items.value = drag.instances.flatMap(({ instanceId, groupId }) => {
const instance = instances.value.find((candidate) => candidate.id === instanceId)
const matchingCards = instanceCards.filter(
(card) => card.dataset.instanceId === instanceId && card.getClientRects().length > 0,
)
const card =
matchingCards.find((candidate) => candidate.dataset.instanceGroup === source.fromGroup) ??
matchingCards.find((candidate) => candidate.dataset.instanceGroup === groupId) ??
matchingCards[0]
if (!instance || !card) return []
@@ -24,7 +24,7 @@ const showCreationModal = inject<() => void>('showCreationModal')
wrapper-class="min-w-[16rem] flex-1"
/>
<ButtonStyled>
<button type="button" @click="openNewGroupModal">
<button type="button" @click="openNewGroupModal()">
<PlusIcon />
New group
</button>
@@ -19,7 +19,7 @@
ref="groupNameInput"
v-model="newGroupName"
placeholder="Enter group name"
:maxlength="32"
:maxlength="128"
@click="groupNameInput?.select()"
/>
</div>
@@ -2,7 +2,16 @@ import type { Labrinth } from '@modrinth/api-client'
import { formatLoader, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { useEventListener, useStorage } from '@vueuse/core'
import dayjs from 'dayjs'
import { computed, inject, type InjectionKey, provide, type Ref, ref, watchEffect } from 'vue'
import {
computed,
inject,
type InjectionKey,
provide,
type Ref,
ref,
watch,
watchEffect,
} from 'vue'
import { get_project_v3_many } from '@/helpers/cache.js'
import { toError } from '@/helpers/errors'
@@ -43,12 +52,20 @@ export type InstanceGroup = {
instances: GameInstance[]
}
export type LibraryInstanceSelection = {
instanceId: string
groupId: string
}
export type ActiveInstanceGroupDrag = {
instanceIds: string[]
instances: LibraryInstanceSelection[]
primaryInstanceId: string
fromGroup: string | null
}
export const getLibraryInstanceSelectionKey = ({ instanceId, groupId }: LibraryInstanceSelection) =>
JSON.stringify([groupId, instanceId])
export type InstanceCard = {
instance: GameInstance
playing: boolean
@@ -88,13 +105,13 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
const newGroupName = ref('')
const newGroupSearch = ref('')
const selectedNewGroupInstanceIds = ref(new Set<string>())
/** Instance-level selection shared by every group representation of an instance. */
const selectedLibraryInstanceIds = ref(new Set<string>())
const isLibraryInstanceSelectionActive = computed(() => selectedLibraryInstanceIds.value.size > 0)
const selectedLibraryInstances = ref(new Map<string, LibraryInstanceSelection>())
const isLibraryInstanceSelectionActive = computed(() => selectedLibraryInstances.value.size > 0)
const creatingGroup = ref(false)
const activeInstanceGroupDrag = ref<ActiveInstanceGroupDrag | null>(null)
const activeDraggedInstanceIds = computed(
() => new Set(activeInstanceGroupDrag.value?.instanceIds ?? []),
const activeDraggedInstanceKeys = computed(
() =>
new Set(activeInstanceGroupDrag.value?.instances.map(getLibraryInstanceSelectionKey) ?? []),
)
const instanceGroupDragTarget = ref<string | null>(null)
const instanceGroupDragPointer = ref({ x: 0, y: 0 })
@@ -137,7 +154,7 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
const existingGroupNames = computed(
() => new Set(['none', ...Array.from(groupNames.value, (group) => group.toLowerCase())]),
)
const normalizedNewGroupName = computed(() => newGroupName.value.trim().substring(0, 32))
const normalizedNewGroupName = computed(() => newGroupName.value.trim().substring(0, 128))
const newGroupInstances = computed(() => {
const query = newGroupSearch.value.trim().toLowerCase()
@@ -360,17 +377,22 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
) => {
if (displayState.value.group !== 'Group') return
const instanceIds = selectedLibraryInstanceIds.value.has(instanceId)
const primaryInstance = { instanceId, groupId }
const primaryInstanceKey = getLibraryInstanceSelectionKey(primaryInstance)
const draggedInstances = selectedLibraryInstances.value.has(primaryInstanceKey)
? [
instanceId,
...[...selectedLibraryInstanceIds.value].filter(
(selectedId) => selectedId !== instanceId,
primaryInstance,
...[...selectedLibraryInstances.value.values()].filter(
(selectedInstance) =>
getLibraryInstanceSelectionKey(selectedInstance) !== primaryInstanceKey,
),
].filter((selectedId) => instances.value.some((instance) => instance.id === selectedId))
: [instanceId]
].filter((selectedInstance) =>
instances.value.some((instance) => instance.id === selectedInstance.instanceId),
)
: [primaryInstance]
activeInstanceGroupDrag.value = {
instanceIds,
instances: draggedInstances,
primaryInstanceId: instanceId,
fromGroup: normalizeInstanceGroupId(groupId),
}
@@ -389,9 +411,12 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
const getInstanceGroupDropState = (groupId: string) => {
const drag = activeInstanceGroupDrag.value
const draggedInstances = drag
? instances.value.filter((instance) => drag.instanceIds.includes(instance.id))
: []
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
@@ -418,7 +443,9 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
const dropState = getInstanceGroupDropState(target)
if (!dropState.canDrop || dropState.operation !== 'add') return
const count = activeInstanceGroupDrag.value?.instanceIds.length ?? 0
const count = new Set(
activeInstanceGroupDrag.value?.instances.map((selection) => selection.instanceId) ?? [],
).size
return count > 1 ? `Duplicate ${count} instances to group` : 'Duplicate into group'
}
@@ -435,8 +462,9 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
if (!dropState.canDrop) return false
const shouldAdd = addToGroup && toGroup !== null
const draggedInstanceIds = new Set(drag.instances.map((selection) => selection.instanceId))
const draggedInstances = instances.value.filter((instance) =>
drag.instanceIds.includes(instance.id),
draggedInstanceIds.has(instance.id),
)
const results = await Promise.allSettled(
draggedInstances.map((instance) => {
@@ -475,7 +503,7 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
isAddingInstanceToGroup.value = false
})
const openNewGroupModal = () => {
const openNewGroupModal = (instanceIds: Iterable<string> = []) => {
let groupNumber = groupNames.value.size + 1
while (existingGroupNames.value.has(`group ${groupNumber}`.toLowerCase())) {
groupNumber++
@@ -483,7 +511,7 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
newGroupName.value = `Group ${groupNumber}`
newGroupSearch.value = ''
selectedNewGroupInstanceIds.value = new Set()
selectedNewGroupInstanceIds.value = new Set(instanceIds)
isNewGroupModalOpen.value = true
}
@@ -504,23 +532,28 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
}
const clearLibraryInstanceSelection = () => {
selectedLibraryInstanceIds.value = new Set()
selectedLibraryInstances.value = new Map()
}
const setSelectedLibraryInstanceIds = (instanceIds: Iterable<string>) => {
selectedLibraryInstanceIds.value = new Set(instanceIds)
watch(() => displayState.value.group, clearLibraryInstanceSelection)
const setSelectedLibraryInstances = (selections: Iterable<LibraryInstanceSelection>) => {
selectedLibraryInstances.value = new Map(
[...selections].map((selection) => [getLibraryInstanceSelectionKey(selection), selection]),
)
}
const toggleLibraryInstanceSelection = (instanceId: string) => {
const selectedIds = new Set(selectedLibraryInstanceIds.value)
const toggleLibraryInstanceSelection = (selection: LibraryInstanceSelection) => {
const selectedInstances = new Map(selectedLibraryInstances.value)
const selectionKey = getLibraryInstanceSelectionKey(selection)
if (selectedIds.has(instanceId)) {
selectedIds.delete(instanceId)
if (selectedInstances.has(selectionKey)) {
selectedInstances.delete(selectionKey)
} else {
selectedIds.add(instanceId)
selectedInstances.set(selectionKey, selection)
}
selectedLibraryInstanceIds.value = selectedIds
selectedLibraryInstances.value = selectedInstances
}
useEventListener(window, 'keydown', (event) => {
@@ -693,10 +726,10 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
newGroupName,
newGroupSearch,
selectedNewGroupInstanceIds,
selectedLibraryInstanceIds,
selectedLibraryInstances,
isLibraryInstanceSelectionActive,
activeInstanceGroupDrag,
activeDraggedInstanceIds,
activeDraggedInstanceKeys,
instanceGroupDragTarget,
instanceGroupDragPointer,
instanceGroupDragStatus,
@@ -718,7 +751,7 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
closeNewGroupModal,
toggleNewGroupInstance,
clearLibraryInstanceSelection,
setSelectedLibraryInstanceIds,
setSelectedLibraryInstances,
toggleLibraryInstanceSelection,
createGroup,
deleteGroup,
+1 -1
View File
@@ -5,7 +5,7 @@ use crate::state::instances::adapters::sqlite::instance_rows;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
const MAX_GROUP_NAME_LENGTH: usize = 32;
const MAX_GROUP_NAME_LENGTH: usize = 128;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct InstanceGroup {
@@ -68,10 +68,7 @@
<component :is="currentStage?.stageContent" />
<template #actions>
<div
class="flex flex-col justify-end gap-2 sm:flex-row"
:class="leftButtonConfig || rightButtonConfig ? 'mt-4' : ''"
>
<div class="flex flex-col justify-end gap-2 sm:flex-row">
<ButtonStyled v-if="leftButtonConfig" type="outlined">
<button
v-tooltip="leftButtonConfig.tooltip"