Files
AstralRinth/packages/ui/src/components/instances/ContentCardItem.vue
T
Calum H. 78aca7e5c0 feat: shared components for worlds + p2p instances (#5135)
* feat: base content card component

* fix: tooltips + colors

* feat: fix orgs

* feat: add ContentModpackCard

* fix: extract types

* feat: selection v-model

* add show icon in selected for combobox with stories

* feat: add project combobox

* clean up project combobox

* feat: start install to play modal

* fix: events

* feat: figma alignments

* feat: migrate toggle to tailwind

* fix: row borders

* feat: disabled state

* feat: virtual list impl for card table based on window scroll

* fix: lint

* feat: virtualization + smaller contentcard items

* feat: fix gap + border issues on last elm

* fix: use TeleportOverflowMenu

* fix: hasUpdate type

* fix: fallback to svg if src is invalid on avatar component

* fix: storybook

* feat: start on updater modal

* feat: finish content updater modal

* feat: i18n pass

* remove install to play modal from ui package

* pnpm prepr

* feat: reusable table component

* feat: add column width prop for table and fix stories

* feat: add table overflow menu story example

* feat: add surface-1.5 and use in table

* chore: export table in index

* fix: allow more loose typing on columns

* feat: update table component to derive key from column instead of data

* feat: surface 1.5 for oled + refactor story for contentcardtable + yeet sorting funcs

* fix: lint

* feat: add no padding story for new modal

---------

Signed-off-by: Calum H. <contact@cal.engineer>
Co-authored-by: tdgao <mr.trumgao@gmail.com>
2026-01-28 20:09:24 +00:00

199 lines
5.7 KiB
Vue

<script setup lang="ts">
import { DownloadIcon, MoreVerticalIcon, OrganizationIcon, TrashIcon } from '@modrinth/assets'
import { type ComponentPublicInstance, computed, getCurrentInstance, ref } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import { useVIntl } from '../../composables/i18n'
import { commonMessages } from '../../utils/common-messages'
import { truncatedTooltip } from '../../utils/truncate'
import AutoLink from '../base/AutoLink.vue'
import Avatar from '../base/Avatar.vue'
import ButtonStyled from '../base/ButtonStyled.vue'
import Checkbox from '../base/Checkbox.vue'
import type { Option as OverflowMenuOption } from '../base/OverflowMenu.vue'
import Toggle from '../base/Toggle.vue'
import TeleportOverflowMenu from '../servers/files/explorer/TeleportOverflowMenu.vue'
import type { ContentCardProject, ContentCardVersion, ContentOwner } from './types'
const { formatMessage } = useVIntl()
interface Props {
project: ContentCardProject
projectLink?: string | RouteLocationRaw
version?: ContentCardVersion
owner?: ContentOwner
enabled?: boolean
hasUpdate?: boolean
overflowOptions?: OverflowMenuOption[]
disabled?: boolean
showCheckbox?: boolean
}
withDefaults(defineProps<Props>(), {
projectLink: undefined,
version: undefined,
owner: undefined,
enabled: undefined,
hasUpdate: false,
overflowOptions: undefined,
disabled: false,
showCheckbox: false,
})
const selected = defineModel<boolean>('selected')
const emit = defineEmits<{
'update:enabled': [value: boolean]
delete: []
update: []
}>()
const instance = getCurrentInstance()
const hasDeleteListener = computed(() => typeof instance?.vnode.props?.onDelete === 'function')
const hasUpdateListener = computed(() => typeof instance?.vnode.props?.onUpdate === 'function')
const titleRef = ref<ComponentPublicInstance | null>(null)
const MAX_FILENAME_LENGTH = 42
function truncateMiddle(str: string, maxLength: number): string {
if (str.length <= maxLength) return str
const ellipsis = '...'
const charsToShow = maxLength - ellipsis.length
const frontChars = Math.ceil(charsToShow / 2)
const backChars = Math.floor(charsToShow / 2)
return str.slice(0, frontChars) + ellipsis + str.slice(-backChars)
}
</script>
<template>
<div
class="grid h-[74px] items-center gap-4 px-4"
:class="[
{ 'opacity-50': disabled },
showCheckbox
? 'grid-cols-[auto_1fr_1fr] md:grid-cols-[auto_1fr_335px_1fr]'
: 'grid-cols-[1fr_1fr] md:grid-cols-[1fr_335px_1fr]',
]"
>
<Checkbox
v-if="showCheckbox"
:model-value="selected ?? false"
:disabled="disabled"
class="shrink-0"
@update:model-value="selected = $event"
/>
<div class="flex min-w-0 items-center gap-3">
<Avatar
:src="project.icon_url"
:alt="project.title"
size="3rem"
no-shadow
class="shrink-0 rounded-2xl border border-surface-5"
/>
<div class="flex min-w-0 flex-col gap-0.5">
<AutoLink
ref="titleRef"
v-tooltip="truncatedTooltip(titleRef?.$el, project.title)"
:target="
typeof projectLink === 'string' && projectLink.startsWith('http') ? '_blank' : undefined
"
:to="projectLink"
class="truncate font-semibold leading-6 text-contrast !decoration-contrast"
:class="{ 'hover:underline': projectLink }"
>
{{ project.title }}
</AutoLink>
<AutoLink
v-if="owner"
:target="
typeof owner.link === 'string' && owner.link.startsWith('http') ? '_blank' : undefined
"
:to="owner.link"
class="flex items-center gap-1 !decoration-secondary"
:class="{ 'hover:underline': owner.link }"
>
<Avatar
:src="owner.avatar_url"
:alt="owner.name"
size="1.5rem"
:circle="owner.type === 'user'"
no-shadow
class="shrink-0"
/>
<OrganizationIcon v-if="owner.type === 'organization'" class="size-4 text-secondary" />
<span class="text-sm leading-5 text-secondary">{{ owner.name }}</span>
</AutoLink>
</div>
</div>
<div class="hidden flex-col justify-center gap-0.5 md:flex">
<template v-if="version">
<span class="font-medium leading-6 text-contrast">{{ version.version_number }}</span>
<span
v-tooltip="version.file_name.length > MAX_FILENAME_LENGTH ? version.file_name : undefined"
class="leading-6 text-secondary"
>
{{ truncateMiddle(version.file_name, MAX_FILENAME_LENGTH) }}
</span>
</template>
</div>
<div class="flex items-center justify-end gap-2">
<slot name="additionalButtonsLeft" />
<!-- Fixed width container to reserve space for update button -->
<div v-if="hasUpdateListener" class="flex w-8 items-center justify-center">
<ButtonStyled
v-if="hasUpdate"
circular
type="transparent"
color="green"
color-fill="text"
hover-color-fill="background"
>
<button
v-tooltip="formatMessage(commonMessages.updateAvailableLabel)"
:disabled="disabled"
@click="emit('update')"
>
<DownloadIcon class="size-5" />
</button>
</ButtonStyled>
</div>
<Toggle
v-if="enabled !== undefined"
:model-value="enabled"
:disabled="disabled"
small
@update:model-value="(val) => emit('update:enabled', val as boolean)"
/>
<ButtonStyled v-if="hasDeleteListener" circular type="transparent">
<button
v-tooltip="formatMessage(commonMessages.deleteLabel)"
:disabled="disabled"
@click="emit('delete')"
>
<TrashIcon class="size-5 text-secondary" />
</button>
</ButtonStyled>
<slot name="additionalButtonsRight" />
<ButtonStyled circular type="transparent">
<TeleportOverflowMenu
v-if="overflowOptions?.length"
:options="overflowOptions"
:disabled="disabled"
>
<MoreVerticalIcon class="size-5" />
</TeleportOverflowMenu>
</ButtonStyled>
</div>
</div>
</template>