Sync source to CNB / Sync Git ref (push) Skipped
移植 Axolotl 的拖拽式实例分组界面到 Starlight,接入阶段1的规范化分组后端: - 新增 helpers/analytics.ts(trackEvent 遥测桩) - 补全 helpers/instance-groups.ts(6 个 invoke 包装) - 新增 composables/useInstanceGroups.ts(分组状态 + 事件监听) - 升级 composables/useGridGrouping.ts(排序方向 isSortAscending/toggleSortDirection) - helpers/events.js 补 instance_groups_listener(对接 instance_groups_changed 事件) - 新增 components/ui/library/DraggableInstanceCard.vue - 新增 components/ui/library/InstanceGroup.vue(并补 group-drag-handle 修复组拖拽重排) - 新增 components/ui/modal/InstanceGroupModal.vue - GridDisplay.vue 替换为 Axolotl 拖拽版(DragDropProvider + vuedraggable) - 补 en-US/zh-CN i18n 键各 39 条
1113 lines
33 KiB
Vue
1113 lines
33 KiB
Vue
<script setup lang="ts">
|
||
import { DragDropProvider } from '@dnd-kit/vue'
|
||
import {
|
||
ArrowUpDownIcon,
|
||
ClipboardCopyIcon,
|
||
CollectionIcon,
|
||
EyeIcon,
|
||
FolderOpenIcon,
|
||
GridIcon,
|
||
LayersIcon,
|
||
MinusIcon,
|
||
MoreVerticalIcon,
|
||
PinIcon,
|
||
PlayIcon,
|
||
PlusIcon,
|
||
SearchIcon,
|
||
SortAscIcon,
|
||
SortDescIcon,
|
||
StarIcon,
|
||
StopCircleIcon,
|
||
TrashIcon,
|
||
XIcon,
|
||
} from '@modrinth/assets'
|
||
import {
|
||
Button,
|
||
Combobox,
|
||
commonMessages,
|
||
defineMessages,
|
||
FloatingActionBar,
|
||
formatLoader,
|
||
injectNotificationManager,
|
||
PopoutMenu,
|
||
StyledInput,
|
||
useVIntl,
|
||
} from '@modrinth/ui'
|
||
import { computed, ref, shallowRef, watch } from 'vue'
|
||
import { useRouter } from 'vue-router'
|
||
import Draggable from 'vuedraggable'
|
||
|
||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||
import InstanceGroup from '@/components/ui/library/InstanceGroup.vue'
|
||
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
|
||
import InstanceGroupModal from '@/components/ui/modal/InstanceGroupModal.vue'
|
||
import { UNGROUPED_GROUP_KEY, useGridGrouping } from '@/composables/useGridGrouping'
|
||
import { FAVORITES_GROUP_ID, useInstanceGroups } from '@/composables/useInstanceGroups'
|
||
import { trackEvent } from '@/helpers/analytics'
|
||
import { install_duplicate_instance } from '@/helpers/install'
|
||
import { kill, remove, run, set_pinned } from '@/helpers/instance'
|
||
import { create_group as createGroup } from '@/helpers/instance-groups'
|
||
import {
|
||
getLastLibraryDisplayMode,
|
||
setLastLibraryDisplayMode,
|
||
} from '@/helpers/library-display-mode'
|
||
import type { GameInstance } from '@/helpers/types'
|
||
import { showInstanceInFolder } from '@/helpers/utils.js'
|
||
|
||
const { handleError } = injectNotificationManager()
|
||
|
||
const { formatMessage } = useVIntl()
|
||
const router = useRouter()
|
||
|
||
const messages = defineMessages({
|
||
search: { id: 'app.instances.search', defaultMessage: 'Search' },
|
||
select: { id: 'app.instances.select', defaultMessage: 'Select...' },
|
||
groupBy: { id: 'app.instances.group-by', defaultMessage: 'Group by' },
|
||
addContent: { id: 'app.instances.add-content', defaultMessage: 'Add content' },
|
||
createInstance: {
|
||
id: 'app.library.create-instance',
|
||
defaultMessage: 'Create new instance',
|
||
},
|
||
newGroup: {
|
||
id: 'app.library.group.new',
|
||
defaultMessage: 'New group',
|
||
},
|
||
viewInstance: { id: 'app.instances.view-instance', defaultMessage: 'View instance' },
|
||
duplicateInstance: {
|
||
id: 'app.instances.duplicate-instance',
|
||
defaultMessage: 'Duplicate instance',
|
||
},
|
||
copyPath: { id: 'app.instances.copy-path', defaultMessage: 'Copy path' },
|
||
pinToHome: { id: 'app.instances.pin-to-home', defaultMessage: 'Pin to Home' },
|
||
unpinFromHome: { id: 'app.instances.unpin-from-home', defaultMessage: 'Unpin from Home' },
|
||
name: { id: 'app.instances.sort.name', defaultMessage: 'Name' },
|
||
lastPlayed: { id: 'app.instances.sort.last-played', defaultMessage: 'Last played' },
|
||
dateCreated: { id: 'app.instances.sort.date-created', defaultMessage: 'Date created' },
|
||
dateModified: { id: 'app.instances.sort.date-modified', defaultMessage: 'Date modified' },
|
||
gameVersion: { id: 'app.instances.group.game-version', defaultMessage: 'Game version' },
|
||
group: { id: 'app.instances.group.group', defaultMessage: 'Custom group' },
|
||
loader: { id: 'app.instances.group.loader', defaultMessage: 'Loader' },
|
||
none: { id: 'app.instances.group.none', defaultMessage: 'No grouping' },
|
||
ungrouped: { id: 'app.instances.group.ungrouped', defaultMessage: 'No group' },
|
||
editGroups: { id: 'app.instances.edit-groups', defaultMessage: 'Edit groups' },
|
||
selectAll: { id: 'app.instances.select-all', defaultMessage: 'Select all' },
|
||
deselectAll: { id: 'app.instances.deselect-all', defaultMessage: 'Deselect all' },
|
||
selectedCount: {
|
||
id: 'app.instances.selected-count',
|
||
defaultMessage: '{count, plural, one {# selected} other {# selected}}',
|
||
},
|
||
view: { id: 'app.library.view', defaultMessage: 'View' },
|
||
standardView: { id: 'app.library.view.standard', defaultMessage: 'Standard grid' },
|
||
cardsView: { id: 'app.library.view.cards', defaultMessage: 'Library cards' },
|
||
sortBy: { id: 'app.instances.sort-by', defaultMessage: 'Sort by' },
|
||
ascAlphabetical: {
|
||
id: 'app.instances.sort.direction.asc-alphabetical',
|
||
defaultMessage: 'A–Z',
|
||
},
|
||
descAlphabetical: {
|
||
id: 'app.instances.sort.direction.desc-alphabetical',
|
||
defaultMessage: 'Z–A',
|
||
},
|
||
ascVersion: {
|
||
id: 'app.instances.sort.direction.asc-version',
|
||
defaultMessage: 'Oldest version first',
|
||
},
|
||
descVersion: {
|
||
id: 'app.instances.sort.direction.desc-version',
|
||
defaultMessage: 'Newest version first',
|
||
},
|
||
ascRecency: {
|
||
id: 'app.instances.sort.direction.asc-recency',
|
||
defaultMessage: 'Least recent first',
|
||
},
|
||
descRecency: {
|
||
id: 'app.instances.sort.direction.desc-recency',
|
||
defaultMessage: 'Most recent first',
|
||
},
|
||
newGroupFromSelection: {
|
||
id: 'app.library.selection.new-group',
|
||
defaultMessage: 'New group',
|
||
},
|
||
removeFromGroup: {
|
||
id: 'app.library.selection.remove-from-group',
|
||
defaultMessage: 'Remove from group',
|
||
},
|
||
addToPinned: {
|
||
id: 'app.library.context-menu.pin-instance',
|
||
defaultMessage: 'Pin instance',
|
||
},
|
||
removeFromPinned: {
|
||
id: 'app.library.context-menu.unpin-instance',
|
||
defaultMessage: 'Unpin instance',
|
||
},
|
||
removeFromContextMenu: {
|
||
id: 'app.library.context-menu.remove-from-group',
|
||
defaultMessage: 'Remove from group',
|
||
},
|
||
ascDate: {
|
||
id: 'app.instances.sort.direction.asc-date',
|
||
defaultMessage: 'Oldest first',
|
||
},
|
||
descDate: {
|
||
id: 'app.instances.sort.direction.desc-date',
|
||
defaultMessage: 'Newest first',
|
||
},
|
||
})
|
||
|
||
const optionMessages = {
|
||
Name: messages.name,
|
||
'Last played': messages.lastPlayed,
|
||
'Date created': messages.dateCreated,
|
||
'Date modified': messages.dateModified,
|
||
'Game version': messages.gameVersion,
|
||
Group: messages.group,
|
||
Loader: messages.loader,
|
||
None: messages.none,
|
||
}
|
||
|
||
const formatOption = (option) =>
|
||
optionMessages[option] ? formatMessage(optionMessages[option]) : option
|
||
|
||
const props = withDefaults(
|
||
defineProps<{
|
||
instances?: GameInstance[]
|
||
label?: string
|
||
}>(),
|
||
{
|
||
instances: () => [],
|
||
label: '',
|
||
},
|
||
)
|
||
|
||
const instanceOptions = ref(null)
|
||
const backgroundContextMenu = ref(null)
|
||
const currentDeleteInstance = ref(null)
|
||
const currentContextSectionKey = ref('')
|
||
const batchDeleteCount = ref(0)
|
||
const confirmModal = ref(null)
|
||
const search = ref('')
|
||
const displayMode = ref(getLastLibraryDisplayMode())
|
||
|
||
function removeGroup(groupKey: string) {
|
||
if (groupKey === FAVORITES_GROUP_ID) return
|
||
deleteGroupById(groupKey).catch(handleError)
|
||
}
|
||
|
||
function handleRenameGroup(oldKey: string, newKey: string) {
|
||
if (oldKey === FAVORITES_GROUP_ID) return
|
||
renameGroupById(oldKey, newKey).catch(handleError)
|
||
}
|
||
|
||
function canMoveGroupUp(groupKey: string) {
|
||
if (grouping.value !== 'Group' || groupKey === FAVORITES_GROUP_ID) return false
|
||
const idx = draggableSections.value.findIndex((s) => s.key === groupKey)
|
||
return idx > 0 && orderedLibraryGroupIds.value.length > 1
|
||
}
|
||
|
||
function canMoveGroupDown(groupKey: string) {
|
||
if (grouping.value !== 'Group' || groupKey === FAVORITES_GROUP_ID) return false
|
||
const idx = draggableSections.value.findIndex((s) => s.key === groupKey)
|
||
return idx >= 0 && idx < draggableSections.value.length - 1
|
||
}
|
||
|
||
function handleMoveGroup(groupKey: string, direction: -1 | 1) {
|
||
if (grouping.value !== 'Group') return
|
||
const sections = [...draggableSections.value]
|
||
const idx = sections.findIndex((s) => s.key === groupKey)
|
||
if (idx < 0) return
|
||
const newIdx = idx + direction
|
||
if (newIdx < 0 || newIdx >= sections.length) return
|
||
const [item] = sections.splice(idx, 1)
|
||
sections.splice(newIdx, 0, item)
|
||
draggableSections.value = sections
|
||
onGroupReorder()
|
||
reorderGroups(
|
||
draggableSections.value
|
||
.map((s) => s.key)
|
||
.filter((key) => key !== UNGROUPED_GROUP_KEY && key !== FAVORITES_GROUP_ID),
|
||
).catch(handleError)
|
||
}
|
||
|
||
const addToExistingGroupModal = ref(null)
|
||
const addToExistingGroupName = ref('')
|
||
const addToExistingGroupId = ref('')
|
||
|
||
function handleAddToGroup(groupKey: string) {
|
||
const group = libraryGroups.value.find((g) => g.id === groupKey)
|
||
addToExistingGroupName.value = group?.name || groupKey
|
||
addToExistingGroupId.value = groupKey
|
||
addToExistingGroupModal.value?.show()
|
||
}
|
||
|
||
const handleNewInstance = () => {
|
||
router.push('/create')
|
||
}
|
||
|
||
function clearLibraryInstanceSelection() {
|
||
selectedInstanceIds.value.clear()
|
||
selectMode.value = false
|
||
anchorInstanceId.value = null
|
||
}
|
||
|
||
function createGroupFromSelection() {
|
||
if (busy.value) return
|
||
|
||
const instanceIds = [...selectedInstanceIds.value]
|
||
clearLibraryInstanceSelection()
|
||
|
||
let groupNumber = libraryGroups.value.filter((g) => g.id !== FAVORITES_GROUP_ID).length + 1
|
||
const existingNames = new Set(libraryGroups.value.map((g) => g.name.toLowerCase()))
|
||
while (existingNames.has(`group ${groupNumber}`)) {
|
||
groupNumber++
|
||
}
|
||
const groupName = `Group ${groupNumber}`
|
||
|
||
createGroup(groupName)
|
||
.then((group) => {
|
||
const updates = instanceIds.map((instanceId) => {
|
||
return {
|
||
instance_id: instanceId,
|
||
group_ids: [group.id],
|
||
}
|
||
})
|
||
return setMemberships(updates).then(() => group)
|
||
})
|
||
.then((group) => {
|
||
state.value.group = 'Group'
|
||
groupPendingNameEdit.value = group.id
|
||
})
|
||
.catch(handleError)
|
||
}
|
||
|
||
const busy = computed(
|
||
() => creatingGroup.value || removingFromGroup.value || deletingInstances.value,
|
||
)
|
||
|
||
const selectedGroupedInstances = computed(() => {
|
||
if (grouping.value !== 'Group') return []
|
||
return props.instances.filter(
|
||
(i) =>
|
||
selectedInstanceIds.value.has(i.id) &&
|
||
(i.groups || []).length > 0 &&
|
||
!(i.groups || []).includes(UNGROUPED_GROUP_KEY),
|
||
)
|
||
})
|
||
|
||
async function removeSelectedInstancesFromGroups() {
|
||
if (busy.value || selectedGroupedInstances.value.length === 0) return
|
||
|
||
removingFromGroup.value = true
|
||
try {
|
||
const updates = [...selectedInstanceIds.value].map((instanceId) => ({
|
||
instance_id: instanceId,
|
||
group_ids: [],
|
||
}))
|
||
await setMemberships(updates)
|
||
clearLibraryInstanceSelection()
|
||
} catch (err) {
|
||
handleError(err)
|
||
} finally {
|
||
removingFromGroup.value = false
|
||
}
|
||
}
|
||
|
||
const newGroupModal = ref(null)
|
||
|
||
function openNewGroupModal() {
|
||
newGroupModal.value?.show([...selectedInstanceIds.value])
|
||
}
|
||
|
||
const displayModeOptions = computed(() => [
|
||
{ id: 'standard', label: formatMessage(messages.standardView), icon: GridIcon },
|
||
{ id: 'cards', label: formatMessage(messages.cardsView), icon: CollectionIcon },
|
||
])
|
||
|
||
const currentDisplayMode = computed(() =>
|
||
displayModeOptions.value.find((option) => option.id === displayMode.value),
|
||
)
|
||
|
||
function setDisplayMode(mode) {
|
||
displayMode.value = mode
|
||
setLastLibraryDisplayMode(mode)
|
||
}
|
||
|
||
const filteredInstances = computed(() =>
|
||
props.instances.filter((instance) =>
|
||
instance.name.toLowerCase().includes(search.value.toLowerCase()),
|
||
),
|
||
)
|
||
|
||
const {
|
||
state,
|
||
grouping,
|
||
filteredResults,
|
||
isSectionCollapsed,
|
||
setSectionCollapsed,
|
||
isSortAscending,
|
||
toggleSortDirection,
|
||
} = useGridGrouping(props.label, filteredInstances, {
|
||
formatLoader: (loader) => formatLoader(formatMessage, loader),
|
||
})
|
||
|
||
const {
|
||
libraryGroups,
|
||
orderedLibraryGroupIds,
|
||
renameGroupById,
|
||
deleteGroupById,
|
||
reorderGroups,
|
||
setMemberships,
|
||
} = useInstanceGroups(filteredInstances)
|
||
|
||
const groupPendingNameEdit = ref<string | null>(null)
|
||
|
||
const groupStableIds = ref(new Map<string, string>())
|
||
let groupStableIdCounter = 0
|
||
|
||
const groupNameMap = computed(() => {
|
||
const map = new Map<string, string>()
|
||
for (const group of libraryGroups.value) {
|
||
map.set(group.id, group.name)
|
||
}
|
||
return map
|
||
})
|
||
|
||
function generateGroupStableId(): string {
|
||
return `gid_${++groupStableIdCounter}`
|
||
}
|
||
|
||
function getOrCreateGroupStableId(key: string): string {
|
||
if (groupStableIds.value.has(key)) {
|
||
return groupStableIds.value.get(key)!
|
||
}
|
||
const id = generateGroupStableId()
|
||
const newMap = new Map(groupStableIds.value)
|
||
newMap.set(key, id)
|
||
groupStableIds.value = newMap
|
||
return id
|
||
}
|
||
|
||
type DisplaySection = { id: string; key: string; instances: typeof props.instances }
|
||
|
||
const displaySections = computed(() => {
|
||
const resultMap = new Map(filteredResults.value)
|
||
const result: DisplaySection[] = []
|
||
|
||
if (state.value.group === 'Group') {
|
||
if (resultMap.has(UNGROUPED_GROUP_KEY)) {
|
||
result.push({
|
||
id: getOrCreateGroupStableId(UNGROUPED_GROUP_KEY),
|
||
key: UNGROUPED_GROUP_KEY,
|
||
instances: resultMap.get(UNGROUPED_GROUP_KEY)!,
|
||
})
|
||
resultMap.delete(UNGROUPED_GROUP_KEY)
|
||
}
|
||
|
||
for (const key of orderedLibraryGroupIds.value) {
|
||
if (key === FAVORITES_GROUP_ID) {
|
||
resultMap.delete(key)
|
||
continue
|
||
}
|
||
if (resultMap.has(key)) {
|
||
result.push({
|
||
id: getOrCreateGroupStableId(key),
|
||
key,
|
||
instances: resultMap.get(key)!,
|
||
})
|
||
resultMap.delete(key)
|
||
} else if (libraryGroups.value.some((g) => g.id === key)) {
|
||
result.push({
|
||
id: getOrCreateGroupStableId(key),
|
||
key,
|
||
instances: [] as typeof props.instances,
|
||
})
|
||
}
|
||
}
|
||
|
||
const remaining = Array.from(resultMap.entries()).sort((a, b) => a[0].localeCompare(b[0]))
|
||
for (const [key, instances] of remaining) {
|
||
result.push({ id: getOrCreateGroupStableId(key), key, instances })
|
||
}
|
||
} else {
|
||
const remaining = Array.from(resultMap.entries())
|
||
if (state.value.group === 'Game version') {
|
||
remaining.sort((a, b) => a[0].localeCompare(b[0], undefined, { numeric: true }))
|
||
} else {
|
||
remaining.sort((a, b) => a[0].localeCompare(b[0]))
|
||
}
|
||
|
||
for (const [key, instances] of remaining) {
|
||
result.push({ id: getOrCreateGroupStableId(key), key, instances })
|
||
}
|
||
}
|
||
|
||
return result
|
||
})
|
||
|
||
const pinnedSection = computed(() => {
|
||
if (state.value.group !== 'Group') return null
|
||
const instances = filteredResults.value.get(FAVORITES_GROUP_ID)
|
||
if (!instances || instances.length === 0) return null
|
||
return {
|
||
id: getOrCreateGroupStableId(FAVORITES_GROUP_ID),
|
||
key: FAVORITES_GROUP_ID,
|
||
instances,
|
||
}
|
||
})
|
||
|
||
const draggableSections = shallowRef<DisplaySection[]>([])
|
||
|
||
const hideUngroupedHeader = computed(() => {
|
||
if (grouping.value !== 'Group') return false
|
||
return !draggableSections.value.some((s) => s.key !== UNGROUPED_GROUP_KEY)
|
||
})
|
||
|
||
const flatVisibleInstanceIds = computed(() => {
|
||
const ids: string[] = []
|
||
if (pinnedSection.value) {
|
||
for (const inst of pinnedSection.value.instances) {
|
||
ids.push(inst.id)
|
||
}
|
||
}
|
||
for (const section of draggableSections.value) {
|
||
for (const inst of section.instances) {
|
||
ids.push(inst.id)
|
||
}
|
||
}
|
||
return ids
|
||
})
|
||
|
||
watch(
|
||
displaySections,
|
||
(sections) => {
|
||
draggableSections.value = sections
|
||
},
|
||
{ immediate: true },
|
||
)
|
||
|
||
const sortDirectionLabels = {
|
||
Name: { asc: messages.ascAlphabetical, desc: messages.descAlphabetical },
|
||
'Game version': { asc: messages.ascVersion, desc: messages.descVersion },
|
||
'Last played': { asc: messages.ascRecency, desc: messages.descRecency },
|
||
'Date created': { asc: messages.ascDate, desc: messages.descDate },
|
||
'Date modified': { asc: messages.ascDate, desc: messages.descDate },
|
||
}
|
||
|
||
const sortDirectionLabel = computed(() => {
|
||
const labels = sortDirectionLabels[state.value.sortBy] ?? sortDirectionLabels['Name']
|
||
return formatMessage(isSortAscending.value ? labels.asc : labels.desc)
|
||
})
|
||
|
||
async function deleteInstance() {
|
||
if (currentDeleteInstance.value) {
|
||
await remove(currentDeleteInstance.value.id).catch(handleError)
|
||
}
|
||
batchDeleteCount.value = 0
|
||
}
|
||
|
||
async function duplicateInstance(p) {
|
||
await install_duplicate_instance(p).catch(handleError)
|
||
}
|
||
|
||
function getInstanceProxy(instanceId) {
|
||
const instanceData = props.instances.find((i) => i.id === instanceId)
|
||
if (!instanceData) return null
|
||
return {
|
||
instance: instanceData,
|
||
playing: undefined,
|
||
play: (_e, context) =>
|
||
run(instanceId).finally(() => {
|
||
trackEvent('InstanceStart', {
|
||
loader: instanceData.loader,
|
||
game_version: instanceData.game_version,
|
||
source: context,
|
||
})
|
||
}),
|
||
stop: (_e, context) =>
|
||
kill(instanceId).finally(() => {
|
||
trackEvent('InstanceStop', {
|
||
loader: instanceData.loader,
|
||
game_version: instanceData.game_version,
|
||
source: context,
|
||
})
|
||
}),
|
||
seeInstance: () => router.push(`/instance/${encodeURIComponent(instanceId)}`),
|
||
openFolder: () => showInstanceInFolder(instanceId),
|
||
addContent: () =>
|
||
router.push({
|
||
path: `/browse/${instanceData.loader === 'vanilla' ? 'datapack' : 'mod'}`,
|
||
query: { i: instanceId },
|
||
}),
|
||
}
|
||
}
|
||
|
||
const handleRightClick = (event, instanceId, sectionKey) => {
|
||
const item = getInstanceProxy(instanceId)
|
||
if (!item) return
|
||
currentContextSectionKey.value = sectionKey
|
||
const isInCustomGroup =
|
||
grouping.value === 'Group' &&
|
||
sectionKey !== UNGROUPED_GROUP_KEY &&
|
||
sectionKey !== FAVORITES_GROUP_ID
|
||
const baseOptions = [
|
||
{
|
||
name: item.instance.groups?.includes(FAVORITES_GROUP_ID)
|
||
? 'remove_from_pinned'
|
||
: 'add_to_pinned',
|
||
},
|
||
{ type: 'divider' },
|
||
{ name: 'add_content' },
|
||
{ name: 'edit' },
|
||
{ name: 'duplicate' },
|
||
{ name: item.instance.pinned_at ? 'unpin' : 'pin' },
|
||
{ name: 'open' },
|
||
{ name: 'copy' },
|
||
...(isInCustomGroup
|
||
? [{ name: 'remove_from_group' }, { type: 'divider' }]
|
||
: [{ type: 'divider' }]),
|
||
{
|
||
name: 'delete',
|
||
color: 'danger',
|
||
},
|
||
]
|
||
|
||
instanceOptions.value.showMenu(
|
||
event,
|
||
item,
|
||
item.playing
|
||
? [
|
||
{
|
||
name: 'stop',
|
||
color: 'danger',
|
||
},
|
||
...baseOptions,
|
||
]
|
||
: [
|
||
{
|
||
name: 'play',
|
||
color: 'primary',
|
||
},
|
||
...baseOptions,
|
||
],
|
||
)
|
||
}
|
||
|
||
const handleBackgroundContextMenu = (event: MouseEvent) => {
|
||
if (
|
||
event.target instanceof HTMLElement &&
|
||
(event.target.isContentEditable || event.target.matches('input, textarea, select'))
|
||
)
|
||
return
|
||
event.preventDefault()
|
||
backgroundContextMenu.value?.showMenu(event, null, [{ name: 'create_instance' }])
|
||
}
|
||
|
||
const handleBackgroundOption = ({ option }: { option: string }) => {
|
||
if (option === 'create_instance') {
|
||
handleNewInstance()
|
||
}
|
||
}
|
||
|
||
const handleOptionsClick = async (args) => {
|
||
switch (args.option) {
|
||
case 'play':
|
||
args.item.play(null, 'InstanceGridContextMenu')
|
||
break
|
||
case 'stop':
|
||
args.item.stop(null, 'InstanceGridContextMenu')
|
||
break
|
||
case 'add_content':
|
||
await args.item.addContent()
|
||
break
|
||
case 'edit':
|
||
await args.item.seeInstance()
|
||
break
|
||
case 'duplicate':
|
||
if (args.item.instance.install_stage == 'installed')
|
||
await duplicateInstance(args.item.instance.id)
|
||
break
|
||
case 'pin':
|
||
await set_pinned(args.item.instance.id, true).catch(handleError)
|
||
break
|
||
case 'unpin':
|
||
await set_pinned(args.item.instance.id, false).catch(handleError)
|
||
break
|
||
case 'open':
|
||
await args.item.openFolder()
|
||
break
|
||
case 'copy':
|
||
await navigator.clipboard.writeText(args.item.instance.id)
|
||
break
|
||
case 'add_to_pinned':
|
||
await setMemberships([
|
||
{
|
||
instance_id: args.item.instance.id,
|
||
group_ids: [FAVORITES_GROUP_ID],
|
||
},
|
||
]).catch(handleError)
|
||
break
|
||
case 'remove_from_pinned':
|
||
await setMemberships([
|
||
{
|
||
instance_id: args.item.instance.id,
|
||
group_ids: [],
|
||
},
|
||
]).catch(handleError)
|
||
break
|
||
case 'remove_from_group':
|
||
await setMemberships([
|
||
{
|
||
instance_id: args.item.instance.id,
|
||
group_ids: [],
|
||
},
|
||
]).catch(handleError)
|
||
break
|
||
case 'delete':
|
||
currentDeleteInstance.value = args.item.instance
|
||
confirmModal.value.show()
|
||
break
|
||
}
|
||
}
|
||
|
||
// Selection mode
|
||
const selectMode = ref(false)
|
||
const selectedInstanceIds = ref(new Set())
|
||
const anchorInstanceId = ref<string | null>(null)
|
||
const creatingGroup = ref(false)
|
||
const removingFromGroup = ref(false)
|
||
const deletingInstances = ref(false)
|
||
const batchEditModal = ref(null)
|
||
|
||
let longPressTimer = null
|
||
let longPressTriggered = false
|
||
|
||
function startLongPress(instanceId) {
|
||
longPressTriggered = false
|
||
longPressTimer = setTimeout(() => {
|
||
longPressTriggered = true
|
||
if (!selectMode.value) {
|
||
selectMode.value = true
|
||
}
|
||
toggleInstanceSelection(instanceId)
|
||
}, 500)
|
||
}
|
||
|
||
function cancelLongPress() {
|
||
if (longPressTimer) {
|
||
clearTimeout(longPressTimer)
|
||
longPressTimer = null
|
||
}
|
||
}
|
||
|
||
function handleCardClick(instanceId, event) {
|
||
if (longPressTriggered) {
|
||
longPressTriggered = false
|
||
return
|
||
}
|
||
const shiftKey = event?.shiftKey ?? false
|
||
if (selectMode.value || shiftKey) {
|
||
if (!selectMode.value) {
|
||
selectMode.value = true
|
||
}
|
||
handleToggleInstance(instanceId, shiftKey)
|
||
}
|
||
}
|
||
|
||
function handleToggleInstance(instanceId, shiftKey) {
|
||
const flatIds = flatVisibleInstanceIds.value
|
||
const anchor = anchorInstanceId.value
|
||
|
||
if (shiftKey && anchor && flatIds.length) {
|
||
const anchorIndex = flatIds.indexOf(anchor)
|
||
const targetIndex = flatIds.indexOf(instanceId)
|
||
|
||
if (anchorIndex === -1 || targetIndex === -1) {
|
||
toggleInstanceSelection(instanceId)
|
||
return
|
||
}
|
||
|
||
const start = Math.min(anchorIndex, targetIndex)
|
||
const end = Math.max(anchorIndex, targetIndex)
|
||
const range = flatIds.slice(start, end + 1)
|
||
const newSet = new Set(selectedInstanceIds.value)
|
||
|
||
if (newSet.has(instanceId)) {
|
||
for (const id of range) {
|
||
newSet.delete(id)
|
||
}
|
||
} else {
|
||
for (const id of range) {
|
||
newSet.add(id)
|
||
}
|
||
}
|
||
|
||
selectedInstanceIds.value = newSet
|
||
if (newSet.size === 0) selectMode.value = false
|
||
anchorInstanceId.value = null
|
||
return
|
||
}
|
||
|
||
toggleInstanceSelection(instanceId)
|
||
anchorInstanceId.value = selectedInstanceIds.value.has(instanceId) ? instanceId : null
|
||
}
|
||
|
||
function toggleInstanceSelection(instanceId) {
|
||
const newSet = new Set(selectedInstanceIds.value)
|
||
if (newSet.has(instanceId)) {
|
||
newSet.delete(instanceId)
|
||
} else {
|
||
newSet.add(instanceId)
|
||
}
|
||
selectedInstanceIds.value = newSet
|
||
if (newSet.size === 0) {
|
||
selectMode.value = false
|
||
}
|
||
}
|
||
|
||
function handleCheckboxClick(instanceId, event) {
|
||
if (!selectMode.value) {
|
||
selectMode.value = true
|
||
}
|
||
const shiftKey = event?.shiftKey ?? false
|
||
handleToggleInstance(instanceId, shiftKey)
|
||
}
|
||
|
||
const batchDeleteConfirmModal = ref(null)
|
||
|
||
async function batchDeleteInstances() {
|
||
for (const id of selectedInstanceIds.value) {
|
||
await remove(id).catch(handleError)
|
||
}
|
||
selectedInstanceIds.value.clear()
|
||
selectMode.value = false
|
||
}
|
||
|
||
function onBatchEditApplied() {
|
||
selectedInstanceIds.value.clear()
|
||
selectMode.value = false
|
||
}
|
||
|
||
function onGroupReorder() {
|
||
if (grouping.value !== 'Group') return
|
||
const groupIds = draggableSections.value
|
||
.map((s) => s.key)
|
||
.filter((key) => key !== UNGROUPED_GROUP_KEY)
|
||
reorderGroups(groupIds).catch(handleError)
|
||
}
|
||
|
||
type InstanceDragData = { instanceId: string; fromGroup: string }
|
||
type InstanceGroupDropData = { groupId: string }
|
||
|
||
async function handleInstanceDragEnd(event: {
|
||
operation: { source?: { data?: unknown }; target?: { data?: unknown } }
|
||
}) {
|
||
const sourceData = event.operation.source?.data as InstanceDragData | undefined
|
||
const targetData = event.operation.target?.data as InstanceGroupDropData | undefined
|
||
if (!sourceData || !targetData) return
|
||
if (sourceData.fromGroup === targetData.groupId) return
|
||
|
||
const instance = props.instances.find((i: GameInstance) => i.id === sourceData.instanceId)
|
||
if (!instance) return
|
||
|
||
const newGroups = targetData.groupId === UNGROUPED_GROUP_KEY ? [] : [targetData.groupId]
|
||
await setMemberships([{ instance_id: sourceData.instanceId, group_ids: newGroups }]).catch(
|
||
handleError,
|
||
)
|
||
}
|
||
</script>
|
||
<template>
|
||
<div class="flex flex-col gap-4 pb-16" @contextmenu="handleBackgroundContextMenu">
|
||
<div class="flex gap-2">
|
||
<StyledInput
|
||
v-model="search"
|
||
:icon="SearchIcon"
|
||
type="text"
|
||
:placeholder="formatMessage(messages.search)"
|
||
clearable
|
||
wrapper-class="flex-1"
|
||
/>
|
||
<Button @click="openNewGroupModal"
|
||
><PlusIcon />
|
||
{{ formatMessage(messages.newGroup) }}
|
||
</Button>
|
||
<Button type="colored" color="brand" @click="router.push('/create')"
|
||
><PlusIcon />
|
||
{{ formatMessage(messages.createInstance) }}
|
||
</Button>
|
||
</div>
|
||
<div class="flex flex-wrap items-center gap-2">
|
||
<Combobox
|
||
v-model="state.sortBy"
|
||
v-tooltip="{ content: formatMessage(messages.sortBy), triggers: ['hover'] }"
|
||
class="!w-auto"
|
||
:options="
|
||
['Name', 'Last played', 'Date created', 'Date modified', 'Game version'].map((value) => ({
|
||
value,
|
||
label: formatOption(value),
|
||
}))
|
||
"
|
||
:placeholder="formatMessage(messages.select)"
|
||
>
|
||
<template #selected="{ label: selectedLabel }">
|
||
<div class="flex items-center gap-1">
|
||
<ArrowUpDownIcon class="size-5 shrink-0 text-[var(--color-text-default)]" />
|
||
<span class="font-semibold text-[var(--color-text-tertiary)]">{{ selectedLabel }}</span>
|
||
</div>
|
||
</template>
|
||
</Combobox>
|
||
<button
|
||
v-tooltip="{ content: sortDirectionLabel, triggers: ['hover'] }"
|
||
type="button"
|
||
class="flex h-[40px] w-[40px] shrink-0 cursor-pointer items-center justify-center rounded-xl border-none bg-surface-4 p-0 text-button-text transition-all hover:bg-surface-4 hover:text-[var(--color-text-primary)] active:scale-[0.97]"
|
||
:aria-label="sortDirectionLabel"
|
||
:aria-pressed="isSortAscending"
|
||
@click="toggleSortDirection()"
|
||
>
|
||
<SortAscIcon v-if="isSortAscending" class="size-5" />
|
||
<SortDescIcon v-else class="size-5" />
|
||
</button>
|
||
<div class="mx-2 h-6 w-px bg-surface-5" />
|
||
<Combobox
|
||
v-model="state.group"
|
||
v-tooltip="{ content: formatMessage(messages.groupBy), triggers: ['hover'] }"
|
||
:options="
|
||
['Group', 'Loader', 'Game version', 'None'].map((value) => ({
|
||
value,
|
||
label: formatOption(value),
|
||
}))
|
||
"
|
||
:placeholder="formatMessage(messages.select)"
|
||
>
|
||
<template #selected="{ label: selectedLabel }">
|
||
<div class="flex items-center gap-1">
|
||
<LayersIcon class="size-5 shrink-0 text-[var(--color-text-default)]" />
|
||
<span class="font-semibold text-[var(--color-text-tertiary)]">{{ selectedLabel }}</span>
|
||
</div>
|
||
</template>
|
||
</Combobox>
|
||
<PopoutMenu :tooltip="formatMessage(messages.view)" placement="bottom-end">
|
||
<Button circular icon-only :aria-label="formatMessage(messages.view)"
|
||
><component :is="currentDisplayMode?.icon" />
|
||
</Button>
|
||
<template #menu>
|
||
<div class="flex w-44 flex-col gap-1 p-1">
|
||
<Button
|
||
v-for="option in displayModeOptions"
|
||
:key="option.id"
|
||
:type="displayMode === option.id ? 'base' : 'quiet'"
|
||
class="flex w-full items-center gap-2 !justify-start text-left"
|
||
:aria-pressed="displayMode === option.id"
|
||
@click="setDisplayMode(option.id)"
|
||
>
|
||
<component :is="option.icon" class="size-4" />
|
||
{{ option.label }}
|
||
</Button>
|
||
</div>
|
||
</template>
|
||
</PopoutMenu>
|
||
</div>
|
||
<DragDropProvider @drag-end="handleInstanceDragEnd">
|
||
<InstanceGroup
|
||
v-if="pinnedSection"
|
||
:key="pinnedSection.id"
|
||
:section-key="pinnedSection.key"
|
||
:group-name="groupNameMap.get(pinnedSection.key) || pinnedSection.key"
|
||
:instances="pinnedSection.instances"
|
||
:grouping="grouping"
|
||
:is-collapsed="isSectionCollapsed(pinnedSection.key)"
|
||
:display-mode="displayMode"
|
||
:select-mode="selectMode"
|
||
:selected-instance-ids="selectedInstanceIds"
|
||
:is-sort-ascending="isSortAscending"
|
||
@toggle-collapse="(key: string) => setSectionCollapsed(key, !isSectionCollapsed(key))"
|
||
@handle-context-menu="
|
||
(event: MouseEvent, instanceId: string, sectionKey: string) =>
|
||
handleRightClick(event, instanceId, sectionKey)
|
||
"
|
||
@handle-checkbox-click="
|
||
(instanceId: string, event: MouseEvent) => handleCheckboxClick(instanceId, event)
|
||
"
|
||
@handle-card-click="
|
||
(instanceId: string, event: MouseEvent) => handleCardClick(instanceId, event)
|
||
"
|
||
@start-long-press="(instanceId: string) => startLongPress(instanceId)"
|
||
@cancel-long-press="cancelLongPress"
|
||
@new-instance="handleNewInstance"
|
||
>
|
||
<template #moreIcon>
|
||
<MoreVerticalIcon />
|
||
</template>
|
||
</InstanceGroup>
|
||
<Draggable
|
||
v-model="draggableSections"
|
||
:group="grouping === 'Group' ? 'groups' : undefined"
|
||
:disabled="grouping !== 'Group'"
|
||
item-key="id"
|
||
:animation="250"
|
||
:swap-threshold="0.75"
|
||
:invert-swap="true"
|
||
:force-fallback="true"
|
||
:fallback-on-body="true"
|
||
:fallback-tolerance="4"
|
||
filter=".instance-group-reorder-ignore, input, textarea"
|
||
handle=".group-drag-handle"
|
||
:prevent-on-filter="false"
|
||
ghost-class="opacity-60"
|
||
chosen-class="shadow-lg"
|
||
drag-class="opacity-80"
|
||
@change="onGroupReorder"
|
||
>
|
||
<template #item="{ element: instanceSection }">
|
||
<div :key="instanceSection.id" class="min-w-0 w-full">
|
||
<InstanceGroup
|
||
:section-key="instanceSection.key"
|
||
:group-name="groupNameMap.get(instanceSection.key) || instanceSection.key"
|
||
:instances="instanceSection.instances"
|
||
:grouping="grouping"
|
||
:is-collapsed="isSectionCollapsed(instanceSection.key)"
|
||
:display-mode="displayMode"
|
||
:select-mode="selectMode"
|
||
:selected-instance-ids="selectedInstanceIds"
|
||
:hide-header="
|
||
(hideUngroupedHeader && instanceSection.key === UNGROUPED_GROUP_KEY) ||
|
||
grouping === 'None'
|
||
"
|
||
:is-sort-ascending="isSortAscending"
|
||
:can-move-up="canMoveGroupUp(instanceSection.key)"
|
||
:can-move-down="canMoveGroupDown(instanceSection.key)"
|
||
:group-pending-name-edit="groupPendingNameEdit"
|
||
@toggle-collapse="(key: string) => setSectionCollapsed(key, !isSectionCollapsed(key))"
|
||
@handle-context-menu="
|
||
(event: MouseEvent, instanceId: string, sectionKey: string) =>
|
||
handleRightClick(event, instanceId, sectionKey)
|
||
"
|
||
@handle-checkbox-click="
|
||
(instanceId: string, event: MouseEvent) => handleCheckboxClick(instanceId, event)
|
||
"
|
||
@handle-card-click="
|
||
(instanceId: string, event: MouseEvent) => handleCardClick(instanceId, event)
|
||
"
|
||
@start-long-press="(instanceId: string) => startLongPress(instanceId)"
|
||
@cancel-long-press="cancelLongPress"
|
||
@delete-group="removeGroup"
|
||
@rename-group="handleRenameGroup"
|
||
@move-group="handleMoveGroup"
|
||
@add-to-group="handleAddToGroup"
|
||
@new-instance="handleNewInstance"
|
||
@rename-complete="groupPendingNameEdit = null"
|
||
>
|
||
<template #moreIcon>
|
||
<MoreVerticalIcon />
|
||
</template>
|
||
</InstanceGroup>
|
||
</div>
|
||
</template>
|
||
</Draggable>
|
||
</DragDropProvider>
|
||
<ConfirmDeleteInstanceModal
|
||
ref="confirmModal"
|
||
:symlink-target="currentDeleteInstance?.symlink_target"
|
||
:count="batchDeleteCount"
|
||
@delete="batchDeleteCount > 0 ? batchDeleteInstances() : deleteInstance()"
|
||
/>
|
||
<ConfirmDeleteInstanceModal
|
||
ref="batchDeleteConfirmModal"
|
||
:count="selectedInstanceIds.size"
|
||
@delete="batchDeleteInstances"
|
||
/>
|
||
<InstanceGroupModal
|
||
ref="batchEditModal"
|
||
:instance-ids="[...selectedInstanceIds]"
|
||
@applied="onBatchEditApplied"
|
||
/>
|
||
<InstanceGroupModal ref="newGroupModal" :instance-ids="[]" @applied="onBatchEditApplied" />
|
||
<FloatingActionBar
|
||
:shown="selectMode"
|
||
:aria-label="formatMessage(messages.selectedCount, { count: selectedInstanceIds.size })"
|
||
hide-when-modal-open
|
||
>
|
||
<div class="flex items-center gap-0.5">
|
||
<span
|
||
class="px-3 py-2 text-base font-semibold text-[var(--color-text-primary)] tabular-nums"
|
||
>
|
||
{{ formatMessage(messages.selectedCount, { count: selectedInstanceIds.size }) }}
|
||
</span>
|
||
<div class="mx-0.5 h-6 w-px bg-surface-5" />
|
||
<Button
|
||
type="quiet"
|
||
class="!text-[var(--color-text-default)]"
|
||
:disabled="busy"
|
||
@click="clearLibraryInstanceSelection"
|
||
><XIcon class="hidden cq-show-icon" />
|
||
<span class="bar-label">{{ formatMessage(commonMessages.clearButton) }}</span>
|
||
</Button>
|
||
</div>
|
||
<div class="ml-auto flex items-center gap-0.5">
|
||
<Button
|
||
v-if="grouping === 'Group'"
|
||
type="quiet"
|
||
:disabled="busy"
|
||
@click="createGroupFromSelection"
|
||
><PlusIcon />
|
||
<span class="bar-label">{{ formatMessage(messages.newGroupFromSelection) }}</span>
|
||
</Button>
|
||
<Button
|
||
v-if="selectedGroupedInstances.length > 0"
|
||
type="quiet"
|
||
:disabled="busy"
|
||
@click="removeSelectedInstancesFromGroups"
|
||
><MinusIcon />
|
||
<span class="bar-label">{{ formatMessage(messages.removeFromGroup) }}</span>
|
||
</Button>
|
||
<div
|
||
v-if="grouping === 'Group' || selectedGroupedInstances.length > 0"
|
||
class="mx-1 h-6 w-px bg-surface-5"
|
||
/>
|
||
<Button type="quiet" color="red" :disabled="busy" @click="batchDeleteConfirmModal?.show()"
|
||
><TrashIcon />
|
||
<span class="bar-label">{{ formatMessage(commonMessages.deleteLabel) }}</span>
|
||
</Button>
|
||
</div>
|
||
</FloatingActionBar>
|
||
<InstanceGroupModal
|
||
ref="addToExistingGroupModal"
|
||
:instance-ids="[]"
|
||
:existing-group-name="addToExistingGroupName"
|
||
:existing-group-id="addToExistingGroupId"
|
||
@applied="onBatchEditApplied"
|
||
/>
|
||
<ContextMenu ref="instanceOptions" @option-clicked="handleOptionsClick">
|
||
<template #play> <PlayIcon /> {{ formatMessage(commonMessages.playButton) }} </template>
|
||
<template #stop> <StopCircleIcon /> {{ formatMessage(commonMessages.stopButton) }} </template>
|
||
<template #add_content> <PlusIcon /> {{ formatMessage(messages.addContent) }} </template>
|
||
<template #edit> <EyeIcon /> {{ formatMessage(messages.viewInstance) }} </template>
|
||
<template #duplicate>
|
||
<ClipboardCopyIcon /> {{ formatMessage(messages.duplicateInstance) }}
|
||
</template>
|
||
<template #pin> <PinIcon /> {{ formatMessage(messages.pinToHome) }} </template>
|
||
<template #unpin>
|
||
<PinIcon class="rotate-45" /> {{ formatMessage(messages.unpinFromHome) }}
|
||
</template>
|
||
<template #delete> <TrashIcon /> {{ formatMessage(commonMessages.deleteLabel) }} </template>
|
||
<template #open>
|
||
<FolderOpenIcon /> {{ formatMessage(commonMessages.openFolderButton) }}
|
||
</template>
|
||
<template #copy> <ClipboardCopyIcon /> {{ formatMessage(messages.copyPath) }} </template>
|
||
<template #add_to_pinned> <StarIcon /> {{ formatMessage(messages.addToPinned) }} </template>
|
||
<template #remove_from_pinned>
|
||
<StarIcon style="color: var(--color-text-default); fill: var(--color-text-default)" />
|
||
{{ formatMessage(messages.removeFromPinned) }}
|
||
</template>
|
||
<template #remove_from_group>
|
||
<MinusIcon />
|
||
{{ formatMessage(messages.removeFromContextMenu) }}
|
||
</template>
|
||
</ContextMenu>
|
||
<ContextMenu ref="backgroundContextMenu" @option-clicked="handleBackgroundOption">
|
||
<template #create_instance>
|
||
<PlusIcon /> {{ formatMessage(messages.createInstance) }}
|
||
</template>
|
||
</ContextMenu>
|
||
</div>
|
||
</template>
|
||
<style lang="scss" scoped></style>
|