Files
modrinth/packages/ui/src/components/instances/ContentModpackCard.vue
T
Calum H.andtdgao 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

184 lines
5.6 KiB
Vue

<script setup lang="ts">
import {
ClockIcon,
DownloadIcon,
HeartIcon,
MoreVerticalIcon,
OrganizationIcon,
UnlinkIcon,
} from '@modrinth/assets'
import { computed, getCurrentInstance } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import { useRelativeTime } from '../../composables/how-ago'
import { defineMessages, useVIntl } from '../../composables/i18n'
import { commonMessages } from '../../utils/common-messages'
import AutoLink from '../base/AutoLink.vue'
import Avatar from '../base/Avatar.vue'
import BulletDivider from '../base/BulletDivider.vue'
import ButtonStyled from '../base/ButtonStyled.vue'
import OverflowMenu, { type Option as OverflowMenuOption } from '../base/OverflowMenu.vue'
import TagItem from '../base/TagItem.vue'
import type {
ContentModpackCardCategory,
ContentModpackCardProject,
ContentModpackCardVersion,
ContentOwner,
} from './types'
const { formatMessage } = useVIntl()
const messages = defineMessages({
unlinkModpack: {
id: 'instances.modpack-card.unlink',
defaultMessage: 'Unlink modpack',
},
})
interface Props {
project: ContentModpackCardProject
projectLink?: string | RouteLocationRaw
version?: ContentModpackCardVersion
owner?: ContentOwner
categories?: ContentModpackCardCategory[]
disabled?: boolean
overflowOptions?: OverflowMenuOption[]
}
withDefaults(defineProps<Props>(), {
projectLink: undefined,
version: undefined,
owner: undefined,
categories: undefined,
disabled: false,
overflowOptions: undefined,
})
const emit = defineEmits<{
update: []
content: []
unlink: []
}>()
const instance = getCurrentInstance()
const hasUpdateListener = computed(() => typeof instance?.vnode.props?.onUpdate === 'function')
const hasContentListener = computed(() => typeof instance?.vnode.props?.onContent === 'function')
const hasUnlinkListener = computed(() => typeof instance?.vnode.props?.onUnlink === 'function')
const formatTimeAgo = useRelativeTime()
const formatCompact = (n: number | undefined) => {
if (n === undefined) return ''
return new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 2 }).format(n)
}
</script>
<template>
<div
class="flex flex-col gap-4 rounded-[20px] bg-bg-raised p-6 shadow-md"
:class="{ 'opacity-50': disabled }"
>
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div class="flex flex-col gap-4 sm:flex-row sm:items-center">
<Avatar
:src="project.icon_url"
:alt="project.title"
size="5rem"
no-shadow
raised
class="shrink-0"
/>
<div class="flex flex-col gap-1.5">
<AutoLink
:to="projectLink"
class="text-2xl font-semibold leading-8 text-contrast hover:underline"
>
{{ project.title }}
</AutoLink>
<div class="flex flex-wrap items-center gap-2 text-secondary">
<template v-if="owner">
<AutoLink :to="owner.link" class="flex items-center gap-1.5 hover:underline">
<Avatar
:src="owner.avatar_url"
:alt="owner.name"
size="2rem"
:circle="owner.type === 'user'"
no-shadow
/>
<OrganizationIcon v-if="owner.type === 'organization'" class="size-4" />
<span class="font-medium">{{ owner.name }}</span>
</AutoLink>
</template>
<template v-if="owner && version">
<BulletDivider />
</template>
<template v-if="version">
<span class="font-medium">v{{ version.version_number }}</span>
</template>
<template v-if="version?.date_published">
<BulletDivider />
<div class="flex items-center gap-2">
<ClockIcon class="size-5" />
<span>{{ formatTimeAgo(new Date(version.date_published)) }}</span>
</div>
</template>
</div>
</div>
</div>
<div class="flex shrink-0 items-center gap-2">
<ButtonStyled v-if="hasUpdateListener" type="transparent" color="green" color-fill="text">
<button class="flex items-center gap-2" @click="emit('update')">
<DownloadIcon class="!text-green size-5" />
<span class="font-semibold">{{ formatMessage(commonMessages.updateButton) }}</span>
</button>
</ButtonStyled>
<ButtonStyled v-if="hasContentListener">
<button class="!shadow-none" @click="emit('content')">
{{ formatMessage(commonMessages.contentLabel) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="hasUnlinkListener" circular type="outlined">
<button
v-tooltip="formatMessage(messages.unlinkModpack)"
class="!border-surface-4 !border-[1px]"
@click="emit('unlink')"
>
<UnlinkIcon class="size-5" />
</button>
</ButtonStyled>
<ButtonStyled v-if="overflowOptions?.length" circular type="transparent">
<OverflowMenu :options="overflowOptions">
<MoreVerticalIcon class="size-5" />
</OverflowMenu>
</ButtonStyled>
</div>
</div>
<span v-if="project.description" class="text-secondary">
{{ project.description }}
</span>
<div class="flex flex-wrap items-center gap-3">
<div v-if="project.downloads !== undefined" class="flex items-center gap-2 text-secondary">
<DownloadIcon class="size-5" />
<span class="font-medium">{{ formatCompact(project.downloads) }}</span>
</div>
<div v-if="project.followers !== undefined" class="flex items-center gap-2 text-secondary">
<HeartIcon class="size-5" />
<span class="font-medium">{{ formatCompact(project.followers) }}</span>
</div>
<div v-if="categories?.length" class="flex flex-wrap gap-2">
<TagItem v-for="cat in categories" :key="cat.name" :action="cat.action">
{{ cat.name }}
</TagItem>
</div>
</div>
</div>
</template>