mirror of
https://github.com/modrinth/code.git
synced 2026-08-27 01:54:47 +00:00
fix: app library post release (#7211)
* fix: fixed height on modal * fix: conditional auto padding being applied on user avatars * feat: allow ungrouped to be dragged * feat: add drag handle to resize num items in jump in * qa * feat: add context menu submenus * feat: hook up icon editing in sub menu * fix: breadcrumb and sidebar not matching * fix: bring back signal icon for ping and players online * feat: add compact mode for library * smaller checkmark * pnpm prepr * feat: bring back loader/game version sort * add loader symbols * refactor: compact mode with sync'd app settings * pnpm prepr * move loaders up * qa * feat: exclude loaders from randomizing
This commit is contained in:
@@ -12,7 +12,10 @@
|
||||
<span class="text-2xl font-semibold text-contrast">
|
||||
{{
|
||||
formatMessage(messages.title, {
|
||||
groupName: groupInstancesModalGroup?.name ?? '',
|
||||
groupName:
|
||||
groupInstancesModalGroup?.id === 'group:none'
|
||||
? formatMessage(messages.ungrouped)
|
||||
: (groupInstancesModalGroup?.name ?? ''),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
@@ -53,6 +56,10 @@
|
||||
</div>
|
||||
<Button
|
||||
:type="selectedGroupInstanceIds.has(instance.id) ? 'outlined' : 'base'"
|
||||
:disabled="
|
||||
groupInstancesModalGroup?.id === 'group:none' &&
|
||||
selectedGroupInstanceIds.has(instance.id)
|
||||
"
|
||||
@click="toggleGroupInstance(instance.id)"
|
||||
>
|
||||
<CheckIcon v-if="selectedGroupInstanceIds.has(instance.id)" />
|
||||
@@ -100,6 +107,10 @@ import { getInstanceIconUrl } from '@/helpers/instance'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
ungrouped: {
|
||||
id: 'app.library.group.ungrouped',
|
||||
defaultMessage: 'Ungrouped',
|
||||
},
|
||||
title: {
|
||||
id: 'app.library.group.instances-modal.title',
|
||||
defaultMessage: 'Add instances to "{groupName}"',
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ClipboardCopyIcon,
|
||||
EditIcon,
|
||||
EyeIcon,
|
||||
FolderOpenIcon,
|
||||
MinusIcon,
|
||||
PaletteIcon,
|
||||
PlayIcon,
|
||||
PlusIcon,
|
||||
StarIcon,
|
||||
StopCircleIcon,
|
||||
TrashIcon,
|
||||
UploadIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { computed, nextTick, onDeactivated, onUnmounted, ref, toRef, watch } from 'vue'
|
||||
import Draggable from 'vuedraggable'
|
||||
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import ContextMenu from '@/components/ui/context-menu/index.vue'
|
||||
import IconEditorModal from '@/components/ui/instance_settings/icon-editor-modal/index.vue'
|
||||
import GroupInstancesModal from '@/components/ui/library/group-instances-modal.vue'
|
||||
import InstanceGroup from '@/components/ui/library/instance-group/index.vue'
|
||||
import InstanceGroupDnd from '@/components/ui/library/instance-group/instance-group-dnd.vue'
|
||||
@@ -55,6 +59,30 @@ const messages = defineMessages({
|
||||
id: 'app.library.instance.action.view-instance',
|
||||
defaultMessage: 'View instance',
|
||||
},
|
||||
editIcon: {
|
||||
id: 'instance.settings.tabs.general.edit-icon',
|
||||
defaultMessage: 'Edit icon',
|
||||
},
|
||||
selectIcon: {
|
||||
id: 'instance.settings.tabs.general.edit-icon.select',
|
||||
defaultMessage: 'Select icon',
|
||||
},
|
||||
replaceIcon: {
|
||||
id: 'instance.settings.tabs.general.edit-icon.replace',
|
||||
defaultMessage: 'Replace icon',
|
||||
},
|
||||
createIcon: {
|
||||
id: 'instance.settings.tabs.general.edit-icon.create',
|
||||
defaultMessage: 'Create an icon',
|
||||
},
|
||||
editCreatedIcon: {
|
||||
id: 'instance.settings.tabs.general.edit-icon.edit-created',
|
||||
defaultMessage: 'Edit icon',
|
||||
},
|
||||
removeIcon: {
|
||||
id: 'instance.settings.tabs.general.edit-icon.remove',
|
||||
defaultMessage: 'Remove icon',
|
||||
},
|
||||
duplicateInstance: {
|
||||
id: 'app.library.instance.action.duplicate',
|
||||
defaultMessage: 'Duplicate instance',
|
||||
@@ -78,10 +106,13 @@ const {
|
||||
reorderGroups,
|
||||
instanceOptions,
|
||||
confirmDeleteModal,
|
||||
iconEditorModal,
|
||||
currentIconEditorInstance,
|
||||
currentDeleteInstances,
|
||||
clearLibraryInstanceSelection,
|
||||
deleteInstance,
|
||||
handleInstanceOption,
|
||||
handleInstanceIconSaved,
|
||||
selectedLibraryInstances,
|
||||
setSelectedLibraryInstances,
|
||||
toggleLibraryInstanceSelection,
|
||||
@@ -100,40 +131,35 @@ const visibleInstanceGroups = computed(() =>
|
||||
),
|
||||
)
|
||||
|
||||
const visibleCustomGroups = computed(() =>
|
||||
const visibleReorderableGroups = computed(() =>
|
||||
displayState.value.group === 'Group'
|
||||
? visibleInstanceGroups.value.filter(
|
||||
(group) => group.id !== FAVORITES_GROUP_ID && group.id !== 'group:none',
|
||||
)
|
||||
? visibleInstanceGroups.value.filter((group) => group.id !== FAVORITES_GROUP_ID)
|
||||
: [],
|
||||
)
|
||||
const visibleFavoritesGroup = computed(() =>
|
||||
visibleInstanceGroups.value.find((group) => group.id === FAVORITES_GROUP_ID),
|
||||
)
|
||||
const visibleUngroupedGroup = computed(() =>
|
||||
visibleInstanceGroups.value.find((group) => group.id === 'group:none'),
|
||||
)
|
||||
const draggableCustomGroups = ref<InstanceGroupType[]>([])
|
||||
const draggableGroups = ref<InstanceGroupType[]>([])
|
||||
const libraryGroupsContainer = ref<HTMLElement>()
|
||||
const isDraggingGroup = ref(false)
|
||||
const GROUP_REORDERING_CLASS = 'instance-group-reordering'
|
||||
const canDragReorderGroups = computed(
|
||||
() => !reorderingGroups.value && draggableCustomGroups.value.length > 1,
|
||||
() => !reorderingGroups.value && draggableGroups.value.length > 1,
|
||||
)
|
||||
|
||||
watch(
|
||||
visibleCustomGroups,
|
||||
visibleReorderableGroups,
|
||||
(groups) => {
|
||||
if (!isDraggingGroup.value) {
|
||||
const previousGroupTops = getCustomGroupTops()
|
||||
draggableCustomGroups.value = [...groups]
|
||||
void nextTick(() => animateCustomGroupReorder(previousGroupTops))
|
||||
const previousGroupTops = getReorderableGroupTops()
|
||||
draggableGroups.value = [...groups]
|
||||
void nextTick(() => animateGroupReorder(previousGroupTops))
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function getCustomGroupTops() {
|
||||
function getReorderableGroupTops() {
|
||||
const groupTops = new Map<string, number>()
|
||||
const groupElements = libraryGroupsContainer.value?.querySelectorAll<HTMLElement>(
|
||||
'[data-instance-group-reorder-id]',
|
||||
@@ -149,7 +175,7 @@ function getCustomGroupTops() {
|
||||
return groupTops
|
||||
}
|
||||
|
||||
function animateCustomGroupReorder(previousGroupTops: Map<string, number>) {
|
||||
function animateGroupReorder(previousGroupTops: Map<string, number>) {
|
||||
if (
|
||||
previousGroupTops.size === 0 ||
|
||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
@@ -185,10 +211,10 @@ function onGroupDragEnd() {
|
||||
isDraggingGroup.value = false
|
||||
document.documentElement.classList.remove(GROUP_REORDERING_CLASS)
|
||||
|
||||
const currentGroupIds = visibleCustomGroups.value.map((group) => group.id)
|
||||
const orderedGroupIds = draggableCustomGroups.value.map((group) => group.id)
|
||||
const currentGroupIds = visibleReorderableGroups.value.map((group) => group.id)
|
||||
const orderedGroupIds = draggableGroups.value.map((group) => group.id)
|
||||
if (orderedGroupIds.every((groupId, index) => groupId === currentGroupIds[index])) {
|
||||
draggableCustomGroups.value = [...visibleCustomGroups.value]
|
||||
draggableGroups.value = [...visibleReorderableGroups.value]
|
||||
return
|
||||
}
|
||||
|
||||
@@ -259,6 +285,10 @@ function setConfirmDeleteModal(component: unknown) {
|
||||
confirmDeleteModal.value = component as InstanceType<typeof ConfirmDeleteInstanceModal> | null
|
||||
}
|
||||
|
||||
function setIconEditorModal(component: unknown) {
|
||||
iconEditorModal.value = component as InstanceType<typeof IconEditorModal> | null
|
||||
}
|
||||
|
||||
watch(selectedLibraryInstances, (selectedInstances) => {
|
||||
if (selectedInstances.size === 0) {
|
||||
anchorInstance.value = null
|
||||
@@ -313,7 +343,7 @@ watch(selectedLibraryInstances, (selectedInstances) => {
|
||||
</div>
|
||||
|
||||
<Draggable
|
||||
:list="draggableCustomGroups"
|
||||
:list="draggableGroups"
|
||||
class="flex flex-col"
|
||||
item-key="id"
|
||||
:disabled="!canDragReorderGroups"
|
||||
@@ -341,6 +371,7 @@ watch(selectedLibraryInstances, (selectedInstances) => {
|
||||
>
|
||||
<InstanceGroup
|
||||
:can-drag-reorder="canDragReorderGroups"
|
||||
:hide-header="visibleInstanceGroups.length === 1"
|
||||
:instance-group="instanceGroup"
|
||||
:selection-anchor-instance-id="
|
||||
anchorInstance?.groupId === instanceGroup.id ? anchorInstance?.instanceId : null
|
||||
@@ -353,20 +384,6 @@ watch(selectedLibraryInstances, (selectedInstances) => {
|
||||
</div>
|
||||
</template>
|
||||
</Draggable>
|
||||
|
||||
<div v-if="visibleUngroupedGroup" class="min-w-0">
|
||||
<InstanceGroup
|
||||
:hide-header="visibleInstanceGroups.length === 1"
|
||||
:instance-group="visibleUngroupedGroup"
|
||||
:selection-anchor-instance-id="
|
||||
anchorInstance?.groupId === 'group:none' ? anchorInstance.instanceId : null
|
||||
"
|
||||
@toggle-selection="
|
||||
(instanceId: string, shiftKey: boolean) =>
|
||||
handleToggleInstance('group:none', instanceId, shiftKey)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TransitionGroup
|
||||
@@ -407,6 +424,12 @@ watch(selectedLibraryInstances, (selectedInstances) => {
|
||||
:instances="currentDeleteInstances"
|
||||
@delete="deleteInstance"
|
||||
/>
|
||||
<IconEditorModal
|
||||
:ref="setIconEditorModal"
|
||||
:instance-id="currentIconEditorInstance?.id"
|
||||
:config="currentIconEditorInstance?.icon_config"
|
||||
@saved="handleInstanceIconSaved"
|
||||
/>
|
||||
<ContextMenu :ref="setInstanceOptions" @option-clicked="handleInstanceOption">
|
||||
<template #play> <PlayIcon /> {{ formatMessage(messages.play) }} </template>
|
||||
<template #stop> <StopCircleIcon /> {{ formatMessage(messages.stop) }} </template>
|
||||
@@ -419,6 +442,14 @@ watch(selectedLibraryInstances, (selectedInstances) => {
|
||||
</template>
|
||||
<template #add_content> <PlusIcon /> {{ formatMessage(messages.addContent) }} </template>
|
||||
<template #edit> <EyeIcon /> {{ formatMessage(messages.viewInstance) }} </template>
|
||||
<template #edit_icon> <EditIcon /> {{ formatMessage(messages.editIcon) }} </template>
|
||||
<template #select_icon> <UploadIcon /> {{ formatMessage(messages.selectIcon) }} </template>
|
||||
<template #replace_icon> <UploadIcon /> {{ formatMessage(messages.replaceIcon) }} </template>
|
||||
<template #create_icon> <PaletteIcon /> {{ formatMessage(messages.createIcon) }} </template>
|
||||
<template #edit_created_icon>
|
||||
<PaletteIcon /> {{ formatMessage(messages.editCreatedIcon) }}
|
||||
</template>
|
||||
<template #remove_icon> <TrashIcon /> {{ formatMessage(messages.removeIcon) }} </template>
|
||||
<template #duplicate>
|
||||
<ClipboardCopyIcon /> {{ formatMessage(messages.duplicateInstance) }}
|
||||
</template>
|
||||
|
||||
+21
-10
@@ -2,16 +2,25 @@
|
||||
import { ArrowDownIcon, ArrowUpIcon, EditIcon, SquarePlusIcon, TrashIcon } from '@modrinth/assets'
|
||||
import { defineMessages, IconButton, useVIntl } from '@modrinth/ui'
|
||||
|
||||
defineProps<{
|
||||
deleting?: boolean
|
||||
canMoveDown: boolean
|
||||
canMoveUp: boolean
|
||||
onAddToGroup: () => void
|
||||
onDeleteGroup: () => void
|
||||
onEditGroupName: () => void
|
||||
onMoveDown: () => void
|
||||
onMoveUp: () => void
|
||||
}>()
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
deleting?: boolean
|
||||
canMoveDown: boolean
|
||||
canMoveUp: boolean
|
||||
onAddToGroup: () => void
|
||||
onDeleteGroup: () => void
|
||||
onEditGroupName: () => void
|
||||
onMoveDown: () => void
|
||||
onMoveUp: () => void
|
||||
showDelete?: boolean
|
||||
showEdit?: boolean
|
||||
}>(),
|
||||
{
|
||||
deleting: false,
|
||||
showDelete: true,
|
||||
showEdit: true,
|
||||
},
|
||||
)
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
@@ -64,6 +73,7 @@ const messages = defineMessages({
|
||||
<ArrowDownIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-if="showEdit"
|
||||
v-tooltip="formatMessage(messages.editGroupName)"
|
||||
:label="formatMessage(messages.editGroupName)"
|
||||
type="quiet"
|
||||
@@ -82,6 +92,7 @@ const messages = defineMessages({
|
||||
<SquarePlusIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
v-if="showDelete"
|
||||
v-tooltip="formatMessage(messages.deleteGroup)"
|
||||
:label="formatMessage(messages.deleteGroup)"
|
||||
type="quiet"
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
} from '@modrinth/ui'
|
||||
import { computed, inject, nextTick, onActivated, onDeactivated, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import ContextMenu from '@/components/ui/ContextMenu.vue'
|
||||
import ContextMenu from '@/components/ui/context-menu/index.vue'
|
||||
import GroupActionButtons from '@/components/ui/library/instance-group/group-action-buttons.vue'
|
||||
import InstanceCard from '@/components/ui/library/instance-group/instance-card.vue'
|
||||
import type {
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
InstanceGroup as InstanceGroupType,
|
||||
} from '@/components/ui/library/use-library'
|
||||
import { useLibrary } from '@/components/ui/library/use-library'
|
||||
import { useAppSettings } from '@/composables/use-app-settings.ts'
|
||||
import { FAVORITES_GROUP_ID, MAX_INSTANCE_GROUP_NAME_LENGTH } from '@/helpers/instance-groups'
|
||||
|
||||
const INSTANCE_GRID_OBSERVER_ACTIVATION_DELAY = 500
|
||||
@@ -48,6 +49,8 @@ const props = withDefaults(
|
||||
)
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const appSettings = useAppSettings()
|
||||
const compactMode = computed(() => appSettings.getFeatureFlag('compact_instance_cards'))
|
||||
const { addNotification } = injectNotificationManager()
|
||||
const {
|
||||
isSectionCollapsed,
|
||||
@@ -84,6 +87,9 @@ const isFavorites = computed(() => props.instanceGroup.id === FAVORITES_GROUP_ID
|
||||
const isCustomGroup = computed(
|
||||
() => displayState.value.group === 'Group' && !isUngrouped.value && !isFavorites.value,
|
||||
)
|
||||
const isReorderableGroup = computed(
|
||||
() => displayState.value.group === 'Group' && !isFavorites.value,
|
||||
)
|
||||
const groupContextMenuOpen = ref(false)
|
||||
const isGroupToggleBlocked = computed(
|
||||
() => isSearching.value || groupContextMenuOpen.value || Boolean(groupNameInput.value?.isEditing),
|
||||
@@ -383,7 +389,7 @@ onMounted(startInstanceGridResizeObserver)
|
||||
v-if="!hideHeader"
|
||||
class="group/header h-10 flex w-full items-center gap-2 border-0 border-b border-solid border-b-surface-5"
|
||||
:class="{
|
||||
'instance-group-reorder-handle': isCustomGroup && canDragReorder,
|
||||
'instance-group-reorder-handle': isReorderableGroup && canDragReorder,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
@@ -442,7 +448,7 @@ onMounted(startInstanceGridResizeObserver)
|
||||
</div>
|
||||
<div class="min-w-0 flex-1" />
|
||||
<GroupActionButtons
|
||||
v-if="isCustomGroup"
|
||||
v-if="isCustomGroup || isUngrouped"
|
||||
:can-move-down="canMoveGroupDown(instanceGroup.id)"
|
||||
:can-move-up="canMoveGroupUp(instanceGroup.id)"
|
||||
:deleting="deletingGroup"
|
||||
@@ -451,6 +457,8 @@ onMounted(startInstanceGridResizeObserver)
|
||||
:on-edit-group-name="() => groupNameInput?.startEditing()"
|
||||
:on-move-down="() => moveGroup(instanceGroup.id, 1)"
|
||||
:on-move-up="() => moveGroup(instanceGroup.id, -1)"
|
||||
:show-delete="!isUngrouped"
|
||||
:show-edit="!isUngrouped"
|
||||
/>
|
||||
</div>
|
||||
<Accordion
|
||||
@@ -470,7 +478,12 @@ onMounted(startInstanceGridResizeObserver)
|
||||
<div ref="instanceGridContent">
|
||||
<TransitionGroup
|
||||
tag="section"
|
||||
class="grid min-h-[45px] w-full grid-cols-[repeat(auto-fill,minmax(min(10rem,100%),1fr))] max-xl:grid-cols-[repeat(auto-fill,minmax(min(8rem,100%),1fr))] gap-3 overflow-y-auto scroll-smooth"
|
||||
class="grid min-h-[45px] w-full gap-3 overflow-y-auto scroll-smooth"
|
||||
:class="
|
||||
compactMode
|
||||
? 'grid-cols-[repeat(auto-fill,minmax(min(15rem,100%),1fr))]'
|
||||
: 'grid-cols-[repeat(auto-fill,minmax(min(10rem,100%),1fr))] max-xl:grid-cols-[repeat(auto-fill,minmax(min(8rem,100%),1fr))]'
|
||||
"
|
||||
move-class="transition-transform duration-200 ease-out motion-reduce:transition-none"
|
||||
enter-active-class="transition-[opacity,transform] duration-[150ms] ease-out motion-reduce:transition-none"
|
||||
enter-from-class="opacity-0"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Avatar, truncatedTooltip } from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useAppSettings } from '@/composables/use-app-settings.ts'
|
||||
import { getInstanceIconUrl } from '@/helpers/instance'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
@@ -16,6 +17,8 @@ const props = withDefaults(
|
||||
)
|
||||
|
||||
const iconSrc = computed(() => getInstanceIconUrl(props.instance.icon_path))
|
||||
const appSettings = useAppSettings()
|
||||
const compactMode = computed(() => appSettings.getFeatureFlag('compact_instance_cards'))
|
||||
|
||||
const nameRef = ref<HTMLElement | null>(null)
|
||||
const versionRef = ref<HTMLElement | null>(null)
|
||||
@@ -23,30 +26,40 @@ const versionRef = ref<HTMLElement | null>(null)
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="relative flex w-full min-w-0 select-none flex-col items-start justify-end gap-3 overflow-clip rounded-[20px] border border-solid bg-surface-3 p-3 text-left transition-all"
|
||||
class="relative flex w-full min-w-0 select-none overflow-clip border border-solid bg-surface-3 text-left transition-all"
|
||||
:class="{
|
||||
'flex-row items-center justify-start gap-2.5 rounded-xl p-2.5': compactMode,
|
||||
'flex-col items-start justify-end gap-3 rounded-[20px] p-3': !compactMode,
|
||||
'[border-color:color-mix(in_srgb,var(--color-text-primary)_40%,transparent)] brightness-110':
|
||||
selected,
|
||||
'border-surface-4': !selected,
|
||||
}"
|
||||
>
|
||||
<div
|
||||
class="relative flex aspect-square min-w-full shrink-0 items-center overflow-clip rounded-2xl"
|
||||
class="relative flex shrink-0 items-center overflow-clip"
|
||||
:class="compactMode ? 'size-10 rounded-lg' : 'aspect-square min-w-full rounded-2xl'"
|
||||
>
|
||||
<Avatar
|
||||
class="pointer-events-none !rounded-2xl outline-none"
|
||||
class="pointer-events-none outline-none"
|
||||
:class="compactMode ? '!rounded-lg' : '!rounded-2xl'"
|
||||
size="100%"
|
||||
:src="iconSrc"
|
||||
:tint-by="instance.id"
|
||||
alt=""
|
||||
no-shadow
|
||||
/>
|
||||
<slot name="loading" />
|
||||
<div class="absolute bottom-1.5 right-1.5 z-[1] flex size-12 items-center justify-center">
|
||||
<slot name="leading" />
|
||||
<slot name="loading" :compact="compactMode" />
|
||||
<div
|
||||
class="absolute z-[1] flex items-center justify-center"
|
||||
:class="compactMode ? 'inset-0' : 'bottom-1.5 right-1.5 size-12'"
|
||||
>
|
||||
<slot name="leading" :compact="compactMode" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex min-w-0 w-full flex-col items-start justify-center gap-1 px-0.5">
|
||||
<div
|
||||
class="flex min-w-0 w-full flex-col items-start justify-center gap-1 px-0.5"
|
||||
:class="{ 'pr-10': compactMode }"
|
||||
>
|
||||
<p
|
||||
ref="nameRef"
|
||||
v-tooltip="truncatedTooltip(nameRef, instance.name)"
|
||||
@@ -62,6 +75,6 @@ const versionRef = ref<HTMLElement | null>(null)
|
||||
{{ instance.loader }} {{ instance.game_version }}
|
||||
</p>
|
||||
</div>
|
||||
<slot name="overlay" />
|
||||
<slot name="overlay" :compact="compactMode" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -285,7 +285,7 @@ onMounted(() => {
|
||||
@mouseenter="checkProcess"
|
||||
@pointerdown="handlePointerDown"
|
||||
>
|
||||
<template #loading>
|
||||
<template #loading="{ compact }">
|
||||
<div
|
||||
v-if="loadingIndicatorVisible"
|
||||
class="pointer-events-none absolute inset-0 z-[1] flex items-center justify-center"
|
||||
@@ -293,13 +293,17 @@ onMounted(() => {
|
||||
<div class="absolute inset-0 bg-surface-1 opacity-30" />
|
||||
<SpinnerIcon
|
||||
v-tooltip="formatMessage(modLoading ? messages.loading : messages.installing)"
|
||||
class="relative size-[30%] animate-spin text-contrast"
|
||||
class="relative animate-spin text-contrast"
|
||||
:class="compact ? 'size-5' : 'size-[30%]'"
|
||||
tabindex="-1"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template #leading>
|
||||
<div class="relative flex size-12 shrink-0 items-center justify-center">
|
||||
<template #leading="{ compact }">
|
||||
<div
|
||||
class="relative flex shrink-0 items-center justify-center"
|
||||
:class="compact ? 'size-10' : 'size-12'"
|
||||
>
|
||||
<div class="absolute inset-0 flex items-center justify-center">
|
||||
<IconButton
|
||||
v-if="playing"
|
||||
@@ -307,7 +311,7 @@ onMounted(() => {
|
||||
:label="formatMessage(messages.stop)"
|
||||
type="colored"
|
||||
color="red"
|
||||
size="lg"
|
||||
:size="compact ? 'md' : 'lg'"
|
||||
@click="(e) => stop(e, 'InstanceCard')"
|
||||
@mouseenter="checkProcess"
|
||||
>
|
||||
@@ -325,7 +329,7 @@ onMounted(() => {
|
||||
:label="formatMessage(messages.repair)"
|
||||
type="colored"
|
||||
color="brand"
|
||||
size="lg"
|
||||
:size="compact ? 'md' : 'lg'"
|
||||
class="origin-bottom scale-75 opacity-0 transition-opacity group-hover/card:scale-100 group-hover/card:opacity-100"
|
||||
@click="(e) => repair(e)"
|
||||
>
|
||||
@@ -342,7 +346,7 @@ onMounted(() => {
|
||||
:label="formatMessage(messages.play)"
|
||||
type="colored"
|
||||
color="brand"
|
||||
size="lg"
|
||||
:size="compact ? 'md' : 'lg'"
|
||||
class="origin-bottom scale-75 opacity-0 transition-opacity group-hover/card:scale-100 group-hover/card:opacity-100"
|
||||
@click="(e) => play(e, 'InstanceCard')"
|
||||
@mouseenter="checkProcess"
|
||||
@@ -352,18 +356,21 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #overlay>
|
||||
<template #overlay="{ compact }">
|
||||
<button
|
||||
type="button"
|
||||
class="selection-button group/selection absolute right-2 top-1.5 z-[2] flex size-[50px] cursor-pointer items-start pt-4 justify-center border-0 bg-transparent p-0"
|
||||
class="selection-button group/selection absolute z-[2] flex size-[50px] cursor-pointer items-start pt-4 justify-center border-0 bg-transparent p-0"
|
||||
:class="compact ? '-right-1 -top-1' : 'right-2 top-1.5'"
|
||||
:aria-label="formatMessage(selected ? messages.deselect : messages.select)"
|
||||
:aria-pressed="selected"
|
||||
@click.stop="toggleSelection"
|
||||
>
|
||||
<span
|
||||
v-tooltip="formatMessage(selected ? messages.deselect : messages.select)"
|
||||
class="relative flex size-[24px] items-center justify-center rounded-full opacity-0 transition-opacity duration-200 ease-out group-hover/card:opacity-100 group-hover/selection:brightness-125"
|
||||
class="relative flex items-center justify-center rounded-full opacity-0 transition-opacity duration-200 ease-out group-hover/card:opacity-100 group-hover/selection:brightness-125"
|
||||
:class="{
|
||||
'size-[20px]': compact,
|
||||
'size-[24px]': !compact,
|
||||
'border-0 !opacity-100': selected,
|
||||
'border-2 border-solid border-primary bg-transparent': !selected,
|
||||
'[outline:3px_solid_var(--color-purple)] outline-offset-1':
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { Labrinth } from '@modrinth/api-client'
|
||||
import { formatLoader, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||
import { open as openDialog } from '@tauri-apps/plugin-dialog'
|
||||
import { useEventListener, useStorage } from '@vueuse/core'
|
||||
import dayjs from 'dayjs'
|
||||
import {
|
||||
computed,
|
||||
inject,
|
||||
type InjectionKey,
|
||||
nextTick,
|
||||
provide,
|
||||
type Ref,
|
||||
ref,
|
||||
@@ -13,10 +15,11 @@ import {
|
||||
watchEffect,
|
||||
} from 'vue'
|
||||
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { get_project_v3_many } from '@/helpers/cache.js'
|
||||
import { toError } from '@/helpers/errors'
|
||||
import { install_duplicate_instance } from '@/helpers/install'
|
||||
import { edit, remove } from '@/helpers/instance'
|
||||
import { edit, edit_icon, remove } from '@/helpers/instance'
|
||||
import {
|
||||
create_group as createInstanceGroup,
|
||||
delete_group as deleteInstanceGroup,
|
||||
@@ -28,7 +31,7 @@ import {
|
||||
set_group_memberships as setInstanceGroupMemberships,
|
||||
set_group_order as setInstanceGroupOrder,
|
||||
} from '@/helpers/instance-groups'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import type { GameInstance, InstanceIconConfig } from '@/helpers/types'
|
||||
|
||||
export const librarySortOptions = [
|
||||
'Name',
|
||||
@@ -102,6 +105,10 @@ type ConfirmDeleteModal = {
|
||||
show: () => void
|
||||
}
|
||||
|
||||
type IconEditorModal = {
|
||||
show: () => void
|
||||
}
|
||||
|
||||
type ContextMenuSelection = {
|
||||
option: string
|
||||
item: InstanceCard
|
||||
@@ -155,17 +162,25 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
|
||||
)
|
||||
const currentContextGroupId = ref<string | null>(null)
|
||||
const confirmDeleteModal = ref<ConfirmDeleteModal | null>(null)
|
||||
const iconEditorModal = ref<IconEditorModal | null>(null)
|
||||
const currentIconEditorInstanceId = ref<string | null>(null)
|
||||
const currentIconEditorInstance = computed(
|
||||
() =>
|
||||
instances.value.find((instance) => instance.id === currentIconEditorInstanceId.value) ?? null,
|
||||
)
|
||||
|
||||
const displayState = useStorage<{
|
||||
group: LibraryGroupBy
|
||||
sortBy: LibrarySort
|
||||
collapsedGroups: string[]
|
||||
ungroupedGroupPosition: number
|
||||
}>(
|
||||
'Instances-grid-display-state',
|
||||
{
|
||||
group: 'Group',
|
||||
sortBy: 'Last played',
|
||||
collapsedGroups: [],
|
||||
ungroupedGroupPosition: Number.MAX_SAFE_INTEGER,
|
||||
},
|
||||
localStorage,
|
||||
{ mergeDefaults: true },
|
||||
@@ -211,10 +226,15 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
})
|
||||
const groupInstancesModalGroup = computed(
|
||||
() =>
|
||||
libraryGroups.value.find((group) => group.id === groupInstancesModalGroupId.value) ?? null,
|
||||
)
|
||||
const groupInstancesModalGroup = computed(() => {
|
||||
if (groupInstancesModalGroupId.value === 'group:none') {
|
||||
return { id: 'group:none', name: 'None' }
|
||||
}
|
||||
|
||||
return (
|
||||
libraryGroups.value.find((group) => group.id === groupInstancesModalGroupId.value) ?? null
|
||||
)
|
||||
})
|
||||
const groupInstances = computed(() => {
|
||||
const query = groupInstancesSearch.value.trim().toLowerCase()
|
||||
|
||||
@@ -229,8 +249,23 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
|
||||
const customLibraryGroups = computed(() =>
|
||||
libraryGroups.value.filter((group) => group.id !== FAVORITES_GROUP_ID),
|
||||
)
|
||||
const customGroupOrder = computed(
|
||||
() => new Map(customLibraryGroups.value.map((group, index) => [group.id, index])),
|
||||
const orderedLibraryGroupIds = computed(() => {
|
||||
const groupIds = customLibraryGroups.value.map((group) => group.id)
|
||||
const storedUngroupedGroupPosition = displayState.value.ungroupedGroupPosition
|
||||
const ungroupedGroupPosition = Math.min(
|
||||
Math.max(
|
||||
Number.isFinite(storedUngroupedGroupPosition)
|
||||
? Math.trunc(storedUngroupedGroupPosition)
|
||||
: Number.MAX_SAFE_INTEGER,
|
||||
0,
|
||||
),
|
||||
groupIds.length,
|
||||
)
|
||||
groupIds.splice(ungroupedGroupPosition, 0, 'group:none')
|
||||
return groupIds
|
||||
})
|
||||
const libraryGroupOrder = computed(
|
||||
() => new Map(orderedLibraryGroupIds.value.map((groupId, index) => [groupId, index])),
|
||||
)
|
||||
|
||||
const refreshGroups = async () => {
|
||||
@@ -450,11 +485,9 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
|
||||
if (a.id === b.id) return 0
|
||||
if (a.id === FAVORITES_GROUP_ID) return -1
|
||||
if (b.id === FAVORITES_GROUP_ID) return 1
|
||||
if (a.id === 'group:none') return 1
|
||||
if (b.id === 'group:none') return -1
|
||||
|
||||
const aOrder = customGroupOrder.value.get(a.id) ?? Number.MAX_SAFE_INTEGER
|
||||
const bOrder = customGroupOrder.value.get(b.id) ?? Number.MAX_SAFE_INTEGER
|
||||
const aOrder = libraryGroupOrder.value.get(a.id) ?? Number.MAX_SAFE_INTEGER
|
||||
const bOrder = libraryGroupOrder.value.get(b.id) ?? Number.MAX_SAFE_INTEGER
|
||||
return aOrder - bOrder || a.key.localeCompare(b.key) || a.id.localeCompare(b.id)
|
||||
})
|
||||
}
|
||||
@@ -715,13 +748,17 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
|
||||
|
||||
const openGroupInstancesModal = (groupId: string) => {
|
||||
const group = libraryGroups.value.find((candidate) => candidate.id === groupId)
|
||||
if (!group) return
|
||||
if (!group && groupId !== 'group:none') return
|
||||
|
||||
groupInstancesModalGroupId.value = groupId
|
||||
groupInstancesSearch.value = ''
|
||||
selectedGroupInstanceIds.value = new Set(
|
||||
instances.value
|
||||
.filter((instance) => instance.group_ids.includes(groupId))
|
||||
.filter((instance) =>
|
||||
groupId === 'group:none'
|
||||
? instance.group_ids.length === 0
|
||||
: instance.group_ids.includes(groupId),
|
||||
)
|
||||
.map((instance) => instance.id),
|
||||
)
|
||||
isGroupInstancesModalOpen.value = true
|
||||
@@ -736,6 +773,7 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
|
||||
const selectedIds = new Set(selectedGroupInstanceIds.value)
|
||||
|
||||
if (selectedIds.has(instanceId)) {
|
||||
if (groupInstancesModalGroupId.value === 'group:none') return
|
||||
selectedIds.delete(instanceId)
|
||||
} else {
|
||||
selectedIds.add(instanceId)
|
||||
@@ -748,15 +786,20 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
|
||||
const groupId = groupInstancesModalGroupId.value
|
||||
if (!groupId || savingGroupInstances.value) return false
|
||||
|
||||
const changedInstances = instances.value.filter(
|
||||
(instance) =>
|
||||
instance.group_ids.includes(groupId) !== selectedGroupInstanceIds.value.has(instance.id),
|
||||
)
|
||||
const isUngrouped = groupId === 'group:none'
|
||||
const changedInstances = instances.value.filter((instance) => {
|
||||
const isSelected = selectedGroupInstanceIds.value.has(instance.id)
|
||||
return isUngrouped
|
||||
? isSelected && instance.group_ids.length > 0
|
||||
: instance.group_ids.includes(groupId) !== isSelected
|
||||
})
|
||||
const operations = changedInstances.map((instance) => {
|
||||
const shouldIncludeGroup = selectedGroupInstanceIds.value.has(instance.id)
|
||||
const nextGroupIds = shouldIncludeGroup
|
||||
? [...instance.group_ids, groupId]
|
||||
: instance.group_ids.filter((instanceGroupId) => instanceGroupId !== groupId)
|
||||
const nextGroupIds = isUngrouped
|
||||
? []
|
||||
: shouldIncludeGroup
|
||||
? [...instance.group_ids, groupId]
|
||||
: instance.group_ids.filter((instanceGroupId) => instanceGroupId !== groupId)
|
||||
|
||||
return {
|
||||
instance,
|
||||
@@ -978,14 +1021,16 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
|
||||
|
||||
const canMoveGroupUp = (groupId: string) =>
|
||||
!reorderingGroups.value &&
|
||||
customLibraryGroups.value.findIndex((group) => group.id === groupId) > 0
|
||||
orderedLibraryGroupIds.value.findIndex((orderedGroupId) => orderedGroupId === groupId) > 0
|
||||
|
||||
const canMoveGroupDown = (groupId: string) => {
|
||||
const groupIndex = customLibraryGroups.value.findIndex((group) => group.id === groupId)
|
||||
const groupIndex = orderedLibraryGroupIds.value.findIndex(
|
||||
(orderedGroupId) => orderedGroupId === groupId,
|
||||
)
|
||||
return (
|
||||
!reorderingGroups.value &&
|
||||
groupIndex >= 0 &&
|
||||
groupIndex < customLibraryGroups.value.length - 1
|
||||
groupIndex < orderedLibraryGroupIds.value.length - 1
|
||||
)
|
||||
}
|
||||
|
||||
@@ -993,35 +1038,48 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
|
||||
if (reorderingGroups.value) return false
|
||||
|
||||
const previousGroups = libraryGroups.value
|
||||
const previousUngroupedGroupPosition = displayState.value.ungroupedGroupPosition
|
||||
const customGroupsById = new Map(customLibraryGroups.value.map((group) => [group.id, group]))
|
||||
const reorderableGroupIds = new Set([...customGroupsById.keys(), 'group:none'])
|
||||
const orderedGroupIdSet = new Set(orderedGroupIds)
|
||||
|
||||
if (
|
||||
orderedGroupIdSet.size !== orderedGroupIds.length ||
|
||||
orderedGroupIds.some((groupId) => !customGroupsById.has(groupId))
|
||||
orderedGroupIds.some((groupId) => !reorderableGroupIds.has(groupId))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const orderedGroups = orderedGroupIds.map((groupId) => customGroupsById.get(groupId)!)
|
||||
let orderedGroupIndex = 0
|
||||
const reorderedCustomGroups = customLibraryGroups.value.map((group) =>
|
||||
orderedGroupIdSet.has(group.id) ? orderedGroups[orderedGroupIndex++] : group,
|
||||
const reorderedGroupIds = orderedLibraryGroupIds.value.map((groupId) =>
|
||||
orderedGroupIdSet.has(groupId) ? orderedGroupIds[orderedGroupIndex++] : groupId,
|
||||
)
|
||||
|
||||
if (reorderedCustomGroups.every((group, index) => group === customLibraryGroups.value[index])) {
|
||||
if (
|
||||
reorderedGroupIds.every((groupId, index) => groupId === orderedLibraryGroupIds.value[index])
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const reorderedCustomGroups = reorderedGroupIds
|
||||
.filter((groupId) => groupId !== 'group:none')
|
||||
.map((groupId) => customGroupsById.get(groupId)!)
|
||||
const customGroupOrderChanged = reorderedCustomGroups.some(
|
||||
(group, index) => group !== customLibraryGroups.value[index],
|
||||
)
|
||||
const favoriteGroups = previousGroups.filter((group) => group.id === FAVORITES_GROUP_ID)
|
||||
libraryGroups.value = [...favoriteGroups, ...reorderedCustomGroups]
|
||||
displayState.value.ungroupedGroupPosition = reorderedGroupIds.indexOf('group:none')
|
||||
reorderingGroups.value = true
|
||||
|
||||
try {
|
||||
await setInstanceGroupOrder(reorderedCustomGroups.map((group) => group.id))
|
||||
if (customGroupOrderChanged) {
|
||||
await setInstanceGroupOrder(reorderedCustomGroups.map((group) => group.id))
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
libraryGroups.value = previousGroups
|
||||
displayState.value.ungroupedGroupPosition = previousUngroupedGroupPosition
|
||||
handleError(toError(error))
|
||||
await refreshGroups()
|
||||
return false
|
||||
@@ -1031,7 +1089,7 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
|
||||
}
|
||||
|
||||
const moveGroup = async (groupId: string, direction: -1 | 1) => {
|
||||
const orderedGroupIds = customLibraryGroups.value.map((group) => group.id)
|
||||
const orderedGroupIds = [...orderedLibraryGroupIds.value]
|
||||
const groupIndex = orderedGroupIds.indexOf(groupId)
|
||||
const targetIndex = groupIndex + direction
|
||||
|
||||
@@ -1057,6 +1115,47 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
|
||||
await install_duplicate_instance(instanceId).catch((error) => handleError(toError(error)))
|
||||
}
|
||||
|
||||
const selectInstanceIcon = async (item: InstanceCard) => {
|
||||
const iconPath = await openDialog({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{
|
||||
name: 'Image',
|
||||
extensions: ['png', 'jpeg', 'svg', 'webp', 'gif', 'jpg'],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
if (!iconPath) return
|
||||
|
||||
try {
|
||||
await edit_icon(item.instance.id, iconPath)
|
||||
trackEvent('InstanceSetIcon')
|
||||
} catch (error) {
|
||||
handleError(toError(error))
|
||||
}
|
||||
}
|
||||
|
||||
const removeInstanceIcon = async (item: InstanceCard) => {
|
||||
try {
|
||||
await edit_icon(item.instance.id, null)
|
||||
trackEvent('InstanceRemoveIcon')
|
||||
} catch (error) {
|
||||
handleError(toError(error))
|
||||
}
|
||||
}
|
||||
|
||||
const openInstanceIconEditor = async (item: InstanceCard) => {
|
||||
currentIconEditorInstanceId.value = item.instance.id
|
||||
await nextTick()
|
||||
iconEditorModal.value?.show()
|
||||
trackEvent(item.instance.icon_config ? 'InstanceEditCreatedIcon' : 'InstanceCreateIcon')
|
||||
}
|
||||
|
||||
const handleInstanceIconSaved = (_iconPath: string, _config: InstanceIconConfig) => {
|
||||
trackEvent('InstanceSaveCreatedIcon')
|
||||
}
|
||||
|
||||
const handleInstanceContextMenu = (
|
||||
event: MouseEvent,
|
||||
item: InstanceCard,
|
||||
@@ -1083,6 +1182,14 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
|
||||
{ name: 'duplicate' },
|
||||
{ name: 'open' },
|
||||
{ name: 'copy' },
|
||||
{
|
||||
name: 'edit_icon',
|
||||
children: [
|
||||
{ name: item.instance.icon_path ? 'replace_icon' : 'select_icon' },
|
||||
{ name: item.instance.icon_config ? 'edit_created_icon' : 'create_icon' },
|
||||
...(item.instance.icon_path ? [{ name: 'remove_icon' }] : []),
|
||||
],
|
||||
},
|
||||
...(currentContextGroupId.value
|
||||
? [{ name: 'remove_from_group' }, { type: 'divider' }]
|
||||
: [{ type: 'divider' }]),
|
||||
@@ -1127,6 +1234,17 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
|
||||
case 'edit':
|
||||
await item.seeInstance()
|
||||
break
|
||||
case 'select_icon':
|
||||
case 'replace_icon':
|
||||
await selectInstanceIcon(item)
|
||||
break
|
||||
case 'create_icon':
|
||||
case 'edit_created_icon':
|
||||
await openInstanceIconEditor(item)
|
||||
break
|
||||
case 'remove_icon':
|
||||
await removeInstanceIcon(item)
|
||||
break
|
||||
case 'duplicate':
|
||||
if (item.instance.install_stage === 'installed') {
|
||||
await duplicateInstance(item.instance.id)
|
||||
@@ -1189,6 +1307,8 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
|
||||
canCreateGroup,
|
||||
instanceOptions,
|
||||
confirmDeleteModal,
|
||||
iconEditorModal,
|
||||
currentIconEditorInstance,
|
||||
currentDeleteInstances,
|
||||
isSectionCollapsed,
|
||||
setSectionCollapsed,
|
||||
@@ -1220,6 +1340,7 @@ function createLibraryState(instances: Ref<GameInstance[]>) {
|
||||
deleteInstance,
|
||||
handleInstanceContextMenu,
|
||||
handleInstanceOption,
|
||||
handleInstanceIconSaved,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user