refactor: separate out instance card content

This commit is contained in:
tdgao
2026-07-30 12:28:51 -07:00
parent b10b6b86c7
commit 0a31725c8f
4 changed files with 136 additions and 153 deletions
@@ -1,13 +1,10 @@
<script setup lang="ts">
import { PageRoundIcon } from '@modrinth/assets'
import { Avatar, TagItem } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { computed } from 'vue'
import { TagItem } from '@modrinth/ui'
import InstanceFileIcon from '@/assets/icons/instance-file.svg'
import InstanceCardContent from '@/components/ui/library/instance-group/instance-card-content.vue'
import type { GameInstance } from '@/helpers/types'
const props = withDefaults(
withDefaults(
defineProps<{
instance: GameInstance
count?: number
@@ -16,17 +13,6 @@ const props = withDefaults(
count: 1,
},
)
const instanceType = computed(() => {
if (
props.instance.link?.type === 'server_project' ||
props.instance.link?.type === 'server_project_modpack'
) {
return 'SRV'
}
return props.instance.link?.type === 'modrinth_modpack' ? 'MPK' : 'CST'
})
</script>
<template>
@@ -42,41 +28,13 @@ const instanceType = computed(() => {
<div
class="relative flex min-h-[76px] w-full items-center justify-center gap-2 overflow-clip rounded-[20px] border border-solid border-surface-4 bg-surface-3 p-4 text-left opacity-90 shadow-lg"
>
<Avatar
v-if="instance.icon_path"
class="pointer-events-none !border-none !bg-transparent !rounded-[26px] !rounded-br-[42px] !absolute -top-[26px] right-[20px] opacity-50 [mask-image:linear-gradient(135deg,transparent_16%,black_100%)]"
size="84px"
:src="convertFileSrc(instance.icon_path)"
:tint-by="instance.id"
alt=""
no-shadow
/>
<PageRoundIcon
aria-hidden="true"
class="pointer-events-none absolute -top-[52px] right-[0px] size-[124px] opacity-10 [mask-image:linear-gradient(135deg,transparent_16%,black_100%)]"
/>
<InstanceCardContent :instance="instance" />
<TagItem
v-if="count > 1"
class="!absolute right-3 top-3 z-[2] border-surface-5 bg-surface-2 font-semibold tabular-nums text-contrast"
>
{{ count }}
</TagItem>
<div class="relative z-[1] flex min-w-0 flex-1 items-center gap-2 pr-20">
<div
class="flex size-10 shrink-0 flex-col items-center gap-px overflow-clip rounded-[14px] px-[3px] py-0.5 text-primary"
>
<InstanceFileIcon class="h-[21px] w-[31px] shrink-0 text-primary [&_path]:fill-current" />
<span class="h-3.5 text-sm font-extrabold leading-[13px]">{{ instanceType }}</span>
</div>
<div class="flex min-w-0 flex-1 flex-col justify-center gap-1">
<p class="m-0 truncate text-base font-semibold leading-5 text-contrast">
{{ instance.name }}
</p>
<p class="m-0 truncate text-sm font-medium capitalize leading-[18px] text-primary">
{{ instance.loader }} {{ instance.game_version }}
</p>
</div>
</div>
</div>
</div>
</template>
@@ -15,9 +15,9 @@ import {
import { computed, nextTick, ref, watch } from 'vue'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import Instance from '@/components/ui/library/instance-group/instance.vue'
import InstanceCard from '@/components/ui/library/instance-group/instance-card.vue'
import type {
InstanceCard,
InstanceCard as InstanceCardExposed,
InstanceGroup as InstanceGroupType,
} from '@/components/ui/library/use-library'
import { useLibrary } from '@/components/ui/library/use-library'
@@ -50,7 +50,7 @@ const {
getInstanceGroupDropState,
} = useLibrary()
const instanceComponents = new Map<string, InstanceCard>()
const instanceComponents = new Map<string, InstanceCardExposed>()
const groupDropTarget = ref<HTMLElement>()
const groupAccordion = ref<InstanceType<typeof Accordion>>()
const groupOptions = ref<InstanceType<typeof ContextMenu>>()
@@ -123,7 +123,7 @@ function openInstanceContextMenu(event: MouseEvent, instanceId: string, instance
function setInstanceComponent(instanceId: string, component: unknown) {
if (component) {
instanceComponents.set(instanceId, component as InstanceCard)
instanceComponents.set(instanceId, component as InstanceCardExposed)
} else {
instanceComponents.delete(instanceId)
}
@@ -360,7 +360,7 @@ watch(
class="grid min-h-[45px] mt-2.5 w-full grid-cols-[repeat(auto-fill,minmax(20rem,22rem))] gap-3 overflow-y-auto scroll-smooth"
>
<div v-for="instance in instanceGroup.instances" :key="instance.id" class="min-w-0 w-full">
<Instance
<InstanceCard
:ref="(component: unknown) => setInstanceComponent(instance.id, component)"
:instance="instance"
:instance-group-id="instanceGroup.id"
@@ -0,0 +1,58 @@
<script setup lang="ts">
import { PageRoundIcon } from '@modrinth/assets'
import { Avatar } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { computed } from 'vue'
import InstanceFileIcon from '@/assets/icons/instance-file.svg'
import type { GameInstance } from '@/helpers/types'
const props = defineProps<{
instance: GameInstance
}>()
const instanceType = computed(() => {
if (
props.instance.link?.type === 'server_project' ||
props.instance.link?.type === 'server_project_modpack'
) {
return 'SRV'
}
return props.instance.link?.type === 'modrinth_modpack' ? 'MPK' : 'CST'
})
</script>
<template>
<Avatar
v-if="instance.icon_path"
class="pointer-events-none !border-none !bg-transparent !rounded-[26px] !rounded-br-[42px] !absolute -top-[26px] right-[20px] opacity-50 [mask-image:linear-gradient(135deg,transparent_16%,black_100%)]"
size="84px"
:src="convertFileSrc(instance.icon_path)"
:tint-by="instance.id"
alt=""
no-shadow
/>
<PageRoundIcon
aria-hidden="true"
class="pointer-events-none absolute -top-[52px] right-[0px] size-[124px] opacity-10 [mask-image:linear-gradient(135deg,transparent_16%,black_100%)]"
/>
<div class="relative z-[1] flex min-w-0 flex-1 items-center gap-2 pr-20">
<slot name="leading" :instance-type="instanceType">
<div
class="flex size-10 shrink-0 flex-col items-center gap-px overflow-clip rounded-[14px] px-[3px] py-0.5 text-primary"
>
<InstanceFileIcon class="h-[21px] w-[31px] shrink-0 text-primary [&_path]:fill-current" />
<span class="h-3.5 text-sm font-extrabold leading-[13px]">{{ instanceType }}</span>
</div>
</slot>
<div class="flex min-w-0 flex-1 flex-col justify-center gap-1">
<p class="m-0 truncate text-base font-semibold leading-5 text-contrast">
{{ instance.name }}
</p>
<p class="m-0 truncate text-sm font-medium capitalize leading-[18px] text-primary">
{{ instance.loader }} {{ instance.game_version }}
</p>
</div>
</div>
</template>
@@ -1,20 +1,13 @@
<script setup lang="ts">
import { useDraggable } from '@dnd-kit/vue'
import {
CheckIcon,
DownloadIcon,
PageRoundIcon,
PlayIcon,
SpinnerIcon,
StopCircleIcon,
} from '@modrinth/assets'
import { Avatar, ButtonStyled, injectNotificationManager } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { CheckIcon, DownloadIcon, PlayIcon, SpinnerIcon, StopCircleIcon } from '@modrinth/assets'
import { ButtonStyled, injectNotificationManager } from '@modrinth/ui'
import { useMagicKeys } from '@vueuse/core'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import InstanceFileIcon from '@/assets/icons/instance-file.svg'
import InstanceCardContent from '@/components/ui/library/instance-group/instance-card-content.vue'
import { getLibraryInstanceSelectionKey, useLibrary } from '@/components/ui/library/use-library'
import { trackEvent } from '@/helpers/analytics'
import { process_listener } from '@/helpers/events'
@@ -50,7 +43,7 @@ const emit = defineEmits<{
(e: 'toggle-selection', shiftKey: boolean): void
}>()
const instanceCard = ref<HTMLElement | null>(null)
const instanceCardElement = ref<HTMLElement | null>(null)
const playing = ref(false)
const loading = ref(false)
const currentEvent = ref<ProcessEvent | null>(null)
@@ -74,7 +67,7 @@ const holdingShift = computed(() => keys.shift.value)
const isPartOfActiveDrag = computed(() => activeDraggedInstanceKeys.value.has(selectionKey.value))
const { isDragging } = useDraggable({
id: computed(() => `instance:${props.instanceGroupId}:${props.instance.id}`),
element: instanceCard,
element: instanceCardElement,
disabled: computed(() => displayState.value.group !== 'Group'),
data: computed(() => ({
instanceId: props.instance.id,
@@ -82,17 +75,6 @@ const { isDragging } = useDraggable({
})),
})
const instanceType = computed(() => {
if (
props.instance.link?.type === 'server_project' ||
props.instance.link?.type === 'server_project_modpack'
) {
return 'SRV'
}
return props.instance.link?.type === 'modrinth_modpack' ? 'MPK' : 'CST'
})
const router = useRouter()
const seeInstance = async () => {
@@ -220,10 +202,10 @@ onUnmounted(() => unlisten())
<template>
<div
ref="instanceCard"
ref="instanceCardElement"
class="group/card relative flex min-h-[76px] w-full cursor-pointer items-center justify-center gap-2 -outline-offset-2 overflow-clip focus-visible:!outline-2 rounded-[20px] border border-solid bg-surface-3 p-4 text-left transition-all hover:brightness-110 select-none"
:class="{
'[border-color:color-mix(in_srgb,var(--color-text-primary)_20%,transparent)] brightness-110':
'[border-color:color-mix(in_srgb,var(--color-text-primary)_15%,transparent)] brightness-110':
selected,
'border-surface-4': !selected,
'!scale-100': isDragging,
@@ -246,19 +228,6 @@ onUnmounted(() => unlisten())
@keydown="handleCardKeydown"
@mouseenter="checkProcess"
>
<Avatar
v-if="instance.icon_path"
class="pointer-events-none !border-none !bg-transparent !rounded-[26px] !rounded-br-[42px] !absolute -top-[26px] right-[20px] opacity-50 [mask-image:linear-gradient(135deg,transparent_16%,black_100%)]"
size="84px"
:src="convertFileSrc(instance.icon_path)"
:tint-by="instance.id"
alt=""
no-shadow
/>
<PageRoundIcon
aria-hidden="true"
class="pointer-events-none absolute -top-[52px] right-[0px] size-[124px] opacity-10 [mask-image:linear-gradient(135deg,transparent_16%,black_100%)]"
/>
<button
type="button"
class="selection-button group/selection absolute right-0 top-0 z-[2] flex size-[50px] h-full cursor-pointer items-start pt-4 justify-center border-0 bg-transparent p-0"
@@ -280,73 +249,71 @@ onUnmounted(() => unlisten())
<CheckIcon v-if="selected" class="relative size-4 invert [stroke-width:3] top-px" />
</span>
</button>
<div class="relative z-[1] flex min-w-0 flex-1 items-center gap-2 pr-20">
<div class="relative flex size-10 shrink-0 items-center justify-center">
<div
v-if="!playing && !modLoading && !installing"
class="flex w-10 flex-col items-center gap-px overflow-clip rounded-[14px] px-[3px] py-0.5 text-primary transition-opacity"
:class="{
'group-hover/card:scale-75 group-hover/card:opacity-0':
!instance.quarantined && !isLibraryInstanceSelectionActive,
}"
>
<InstanceFileIcon class="h-[21px] w-[31px] shrink-0 text-primary [&_path]:fill-current" />
<span class="h-3.5 text-sm font-extrabold leading-[13px]">{{ instanceType }}</span>
</div>
<div class="absolute inset-0 flex items-center justify-center">
<ButtonStyled v-if="playing" color="red" circular>
<button
v-tooltip="'Stop'"
class="card-shadow"
@click="(e) => stop(e, 'InstanceCard')"
@mouseenter="checkProcess"
>
<StopCircleIcon />
</button>
</ButtonStyled>
<SpinnerIcon
v-else-if="modLoading || installing"
v-tooltip="modLoading ? 'Instance is loading...' : 'Installing...'"
class="size-8 animate-spin"
tabindex="-1"
/>
<ButtonStyled
v-else-if="!isLibraryInstanceSelectionActive && !installed && !instance.quarantined"
color="brand"
circular
<InstanceCardContent :instance="instance">
<template #leading="{ instanceType }">
<div class="relative flex size-10 shrink-0 items-center justify-center">
<div
v-if="!playing && !modLoading && !installing"
class="flex w-10 flex-col items-center gap-px overflow-clip rounded-[14px] px-[3px] py-0.5 text-primary transition-opacity"
:class="{
'group-hover/card:scale-75 group-hover/card:opacity-0':
!instance.quarantined && !isLibraryInstanceSelectionActive,
}"
>
<button
v-tooltip="'Repair'"
class="card-shadow origin-bottom scale-75 opacity-0 transition-opacity group-hover/card:scale-100 group-hover/card:opacity-100"
@click="(e) => repair(e)"
<InstanceFileIcon
class="h-[21px] w-[31px] shrink-0 text-primary [&_path]:fill-current"
/>
<span class="h-3.5 text-sm font-extrabold leading-[13px]">
{{ instanceType }}
</span>
</div>
<div class="absolute inset-0 flex items-center justify-center">
<ButtonStyled v-if="playing" color="red" circular>
<button
v-tooltip="'Stop'"
class="card-shadow"
@click="(e) => stop(e, 'InstanceCard')"
@mouseenter="checkProcess"
>
<StopCircleIcon />
</button>
</ButtonStyled>
<SpinnerIcon
v-else-if="modLoading || installing"
v-tooltip="modLoading ? 'Instance is loading...' : 'Installing...'"
class="size-8 animate-spin"
tabindex="-1"
/>
<ButtonStyled
v-else-if="!isLibraryInstanceSelectionActive && !installed && !instance.quarantined"
color="brand"
circular
>
<DownloadIcon />
</button>
</ButtonStyled>
<ButtonStyled
v-else-if="!isLibraryInstanceSelectionActive && !instance.quarantined"
color="brand"
circular
>
<button
v-tooltip="'Play'"
class="card-shadow 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"
<button
v-tooltip="'Repair'"
class="card-shadow origin-bottom scale-75 opacity-0 transition-opacity group-hover/card:scale-100 group-hover/card:opacity-100"
@click="(e) => repair(e)"
>
<DownloadIcon />
</button>
</ButtonStyled>
<ButtonStyled
v-else-if="!isLibraryInstanceSelectionActive && !instance.quarantined"
color="brand"
circular
>
<PlayIcon class="translate-x-px" />
</button>
</ButtonStyled>
<button
v-tooltip="'Play'"
class="card-shadow 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"
>
<PlayIcon class="translate-x-px" />
</button>
</ButtonStyled>
</div>
</div>
</div>
<div class="flex min-w-0 flex-1 flex-col justify-center gap-1">
<p class="m-0 truncate text-base font-semibold leading-5 text-contrast">
{{ instance.name }}
</p>
<p class="m-0 truncate text-sm font-medium capitalize leading-[18px] text-primary">
{{ instance.loader }} {{ instance.game_version }}
</p>
</div>
</div>
</template>
</InstanceCardContent>
</div>
</template>