feat: update versions table (#6502)

* feat: add new versions table, move environments column to be feature flag toggled, and add header/cell class props to Table.vue

* feat: implement much better table column widths

* feat: update version filter control to use multiselect

* feat: implement clicking table row to go to version

* feat: mobile pass on versions table

* feat: use multiselect for platform, and fix game version show all versions when dont exist

* pnpm prepr

* feat: replace dashboard projects table with new table

* feat: update projects so its out of card

* fix: version actions buttons column width

* feat: implement overflow on loaders cell

* update platform and version width

* feat: update project icon

* feat: change edit links to use floating action bar

* pnpm prepr
This commit is contained in:
Truman Gao
2026-06-29 15:01:07 +00:00
committed by GitHub
parent fc3e384f54
commit b0c6a06909
39 changed files with 1251 additions and 1143 deletions
+48 -7
View File
@@ -8,7 +8,8 @@
</div>
<div class="overflow-x-auto overflow-y-hidden">
<table
class="w-full table-fixed border-separate border-spacing-0 border-surface-4"
class="w-full border-separate border-spacing-0 border-surface-4"
:class="tableLayout === 'auto' ? 'table-auto' : 'table-fixed'"
:style="tableMinWidth ? { minWidth: tableMinWidth } : undefined"
>
<colgroup>
@@ -36,6 +37,7 @@
:class="[
`text-${column.align ?? 'left'}`,
column.enableSorting ? 'cursor-pointer select-none' : '',
column.headerClass,
]"
:style="column.width ? { width: column.width } : undefined"
@click="column.enableSorting ? handleSort(column.key) : undefined"
@@ -80,7 +82,8 @@
<tr
v-for="(row, rowIndex) in renderedRows"
:key="getRowRenderKey(row, getAbsoluteRowIndex(rowIndex))"
:class="getRowClass(getAbsoluteRowIndex(rowIndex))"
:class="getRowClass(row, getAbsoluteRowIndex(rowIndex))"
@click="handleRowClick(row, getAbsoluteRowIndex(rowIndex), $event)"
>
<td
v-if="showSelection"
@@ -96,7 +99,7 @@
v-for="column in columns"
:key="column.key"
class="text-secondary h-14 overflow-hidden first:pl-4 last:pr-4 border-solid border-0 border-t border-surface-4"
:class="`text-${column.align ?? 'left'}`"
:class="[`text-${column.align ?? 'left'}`, column.cellClass]"
>
<slot
:name="`cell-${column.key}`"
@@ -132,7 +135,8 @@
<tr
v-for="(row, rowIndex) in renderedRows"
:key="getRowRenderKey(row, getAbsoluteRowIndex(rowIndex))"
:class="getRowClass(getAbsoluteRowIndex(rowIndex))"
:class="getRowClass(row, getAbsoluteRowIndex(rowIndex))"
@click="handleRowClick(row, getAbsoluteRowIndex(rowIndex), $event)"
>
<td
v-if="showSelection"
@@ -148,7 +152,7 @@
v-for="column in columns"
:key="column.key"
class="text-secondary h-14 overflow-hidden first:pl-4 last:pr-4 border-solid border-0 border-t border-surface-4"
:class="`text-${column.align ?? 'left'}`"
:class="[`text-${column.align ?? 'left'}`, column.cellClass]"
>
<slot
:name="`cell-${column.key}`"
@@ -188,6 +192,7 @@ import Checkbox from './Checkbox.vue'
export type TableColumnAlign = 'left' | 'center' | 'right'
export type SortDirection = 'asc' | 'desc'
export type TableLayout = 'fixed' | 'auto'
/**
* Defines a table column configuration.
@@ -204,6 +209,8 @@ export interface TableColumn<K extends string = string> {
* Accepts any valid CSS width (e.g., '200px', '20%', '10rem', 'auto', 'fit-content').
*/
width?: string
headerClass?: string
cellClass?: string
}
const props = withDefaults(
@@ -223,10 +230,14 @@ const props = withDefaults(
* Sets a minimum width for the table content, allowing horizontal overflow below that width.
*/
tableMinWidth?: string
tableLayout?: TableLayout
rowClass?: string | ((row: T, index: number) => string)
rowClickable?: boolean | ((row: T, index: number) => boolean)
}>(),
{
showSelection: false,
rowKey: 'id' as keyof T,
tableLayout: 'fixed',
virtualized: false,
virtualRowHeight: 56,
virtualBufferSize: 5,
@@ -267,6 +278,7 @@ const bottomSpacerHeight = computed(() => {
const emit = defineEmits<{
sort: [column: string, direction: SortDirection]
rowClick: [row: T, index: number, event: MouseEvent]
}>()
const selectableRows = computed(() => props.selectionData ?? props.data)
@@ -319,8 +331,37 @@ function getRowRenderKey(row: T, rowIndex: number): PropertyKey {
return rowIndex
}
function getRowClass(rowIndex: number): string {
return rowIndex % 2 === 0 ? 'bg-surface-2' : 'bg-surface-1.5'
function getRowClass(row: T, rowIndex: number): string[] {
const baseClass = rowIndex % 2 === 0 ? 'bg-surface-2' : 'bg-surface-1.5'
const customClass =
typeof props.rowClass === 'function' ? props.rowClass(row, rowIndex) : props.rowClass
return customClass ? [baseClass, customClass] : [baseClass]
}
function isRowClickable(row: T, rowIndex: number): boolean {
return typeof props.rowClickable === 'function'
? props.rowClickable(row, rowIndex)
: props.rowClickable === true
}
function isNoRowClickTarget(event: MouseEvent): boolean {
const target = event.target
const currentTarget = event.currentTarget
if (!(target instanceof Element) || !(currentTarget instanceof Element)) {
return false
}
const noRowClickTarget = target.closest('[data-no-row-click]')
return noRowClickTarget !== null && noRowClickTarget !== currentTarget
}
function handleRowClick(row: T, rowIndex: number, event: MouseEvent) {
if (!isRowClickable(row, rowIndex) || isNoRowClickTarget(event)) {
return
}
emit('rowClick', row, rowIndex, event)
}
function isSelected(row: T): boolean {
@@ -1,5 +1,5 @@
<template>
<div class="flex flex-col gap-3 mb-3">
<div class="mb-3 flex flex-col gap-3">
<div class="flex flex-wrap justify-between gap-2">
<VersionFilterControl
ref="versionFilters"
@@ -24,7 +24,7 @@
<div
v-if="openModal && filteredVersions.length > pageSize"
class="flex flex-wrap justify-between items-center gap-2"
class="flex flex-wrap items-center justify-between gap-2"
>
<span>
Showing {{ (currentPage - 1) * pageSize + 1 }} to
@@ -40,219 +40,62 @@
/>
</div>
</div>
<div
<Table
v-if="versions.length > 0"
class="flex flex-col gap-4 rounded-2xl bg-bg-raised px-6 pb-8 pt-4 supports-[grid-template-columns:subgrid]:grid supports-[grid-template-columns:subgrid]:grid-cols-[1fr_min-content] sm:px-8 supports-[grid-template-columns:subgrid]:sm:grid-cols-[min-content_auto_auto_auto_min-content]"
:class="[
hasMultipleEnvironments
? 'supports-[grid-template-columns:subgrid]:xl:grid-cols-[min-content_auto_auto_auto_auto_auto_auto_min-content] has-environment'
: 'supports-[grid-template-columns:subgrid]:xl:grid-cols-[min-content_auto_auto_auto_auto_auto_min-content] no-environment',
]"
class="hidden sm:block"
:columns="versionColumns"
:data="currentVersionRows"
row-key="id"
:row-class="getVersionRowClass"
:row-clickable="!!versionLink"
table-layout="auto"
@row-click="openVersionRow"
>
<div class="versions-grid-row">
<div class="w-9 max-sm:hidden"></div>
<div class="text-sm font-bold text-contrast max-sm:hidden">Name</div>
<div
class="text-sm font-bold text-contrast max-sm:hidden sm:max-xl:collapse sm:max-xl:hidden"
>
Game version
</div>
<div
class="text-sm font-bold text-contrast max-sm:hidden sm:max-xl:collapse sm:max-xl:hidden"
>
Platforms
</div>
<div
v-if="hasMultipleEnvironments"
class="text-sm font-bold text-contrast max-sm:hidden sm:max-xl:collapse sm:max-xl:hidden"
>
Environment
</div>
<div
class="text-sm font-bold text-contrast max-sm:hidden sm:max-xl:collapse sm:max-xl:hidden"
>
Published
</div>
<div
class="text-sm font-bold text-contrast max-sm:hidden sm:max-xl:collapse sm:max-xl:hidden"
>
Downloads
</div>
<div class="text-sm font-bold text-contrast max-sm:hidden xl:collapse xl:hidden">
Compatibility
</div>
<div class="text-sm font-bold text-contrast max-sm:hidden xl:collapse xl:hidden">Stats</div>
<div class="w-9 max-sm:hidden"></div>
</div>
<template v-for="(version, index) in currentVersions" :key="index">
<!-- Row divider -->
<div
class="versions-grid-row h-px w-full bg-surface-5"
:class="{
'max-sm:!hidden': index === 0,
}"
></div>
<div class="versions-grid-row group relative">
<AutoLink
v-if="!!versionLink"
class="absolute inset-[calc(-1rem-2px)_-2rem] before:absolute before:inset-0 before:transition-all before:content-[''] hover:before:backdrop-brightness-110"
:to="versionLink?.(version)"
<template #cell-channel="{ row: version }">
<div class="flex items-center justify-center">
<VersionChannelIndicator
v-tooltip="`Toggle filter for ${version.version_type}`"
:channel="version.version_type"
class="cursor-pointer"
data-no-row-click
@click.stop="versionFilters?.toggleFilter('channel', version.version_type)"
/>
<div class="flex flex-col justify-center gap-2 sm:contents">
<div class="flex flex-row items-center gap-2 sm:contents">
<div class="self-center">
<div class="relative z-[1] cursor-pointer">
<VersionChannelIndicator
v-tooltip="`Toggle filter for ${version.version_type}`"
:channel="version.version_type"
@click="versionFilters?.toggleFilter('channel', version.version_type)"
/>
</div>
</div>
<div
class="pointer-events-none relative z-[1] flex flex-col gap-1 justify-center overflow-hidden min-w-32"
:class="{
'group-hover:underline': !!versionLink,
}"
title="`${version.version_number} - ${version.name}`"
>
<div class="flex items-center gap-2">
<div class="font-bold text-contrast text-ellipsis overflow-hidden">
{{ version.version_number }}
</div>
<div
v-if="version.files_missing_attribution"
v-tooltip="formatMessage(messages.withheldTooltip)"
class="z-[1]"
:style="{
'--_bg-color': 'var(--color-orange-bg)',
'--_color': 'var(--color-orange)',
}"
>
<TagItem> <CircleAlertIcon /> {{ formatMessage(messages.withheld) }}</TagItem>
</div>
</div>
<div class="text-xs font-medium text-ellipsis overflow-hidden">
{{ version.name }}
</div>
</div>
</div>
<div class="flex flex-col justify-center gap-2 sm:contents">
<div class="flex flex-row flex-wrap items-center gap-1 xl:contents">
<div class="flex items-center">
<div class="flex flex-wrap gap-1">
<TagItem
v-for="gameVersion in formatVersionsForDisplay(
version.game_versions,
gameVersions,
).slice(0, maxGameVersionTags)"
:key="`version-tag-${gameVersion}`"
v-tooltip="`Toggle filter for ${gameVersion}`"
class="z-[1]"
:action="
() => versionFilters?.toggleFilters('gameVersion', version.game_versions)
"
>
{{ gameVersion }}
</TagItem>
<Menu
v-if="
formatVersionsForDisplay(version.game_versions, gameVersions).length >
maxGameVersionTags
"
:delay="{ hide: 50, show: 0 }"
no-auto-focus
class="z-[1] cursor-default"
>
<TagItem tabindex="0">
+{{
formatVersionsForDisplay(version.game_versions, gameVersions).length -
maxGameVersionTags
}}
</TagItem>
<template #popper>
<div class="flex gap-1 flex-wrap max-w-[20rem]">
<TagItem
v-for="gameVersion in formatVersionsForDisplay(
version.game_versions,
gameVersions,
).slice(maxGameVersionTags)"
:key="`overflow-version-tag-${gameVersion}`"
:action="
() =>
versionFilters?.toggleFilters('gameVersion', version.game_versions)
"
>
{{ gameVersion }}
</TagItem>
</div>
</template>
</Menu>
</div>
</div>
<div class="flex items-center">
<div class="flex flex-wrap gap-1">
<template v-if="version.noModLoader">
<TagItem class="z-[1] border !border-solid border-surface-5">
No mod loader
</TagItem>
</template>
<template v-else>
<TagItem
v-for="platform in version.loaders"
:key="`platform-tag-${platform}`"
v-tooltip="`Toggle filter for ${platform}`"
class="z-[1]"
:style="`--_color: var(--color-platform-${platform})`"
:action="() => versionFilters?.toggleFilter('platform', platform)"
>
<component :is="getLoaderIcon(platform)" v-if="getLoaderIcon(platform)" />
<FormattedTag :tag="platform" enforce-type="loader" />
</TagItem>
</template>
</div>
</div>
<div v-if="hasMultipleEnvironments" class="flex items-center">
<div class="flex flex-wrap gap-1">
<TagItem
v-for="(tag, tagIdx) in getEnvironmentTags(version.environment)"
:key="`env-tag-${tagIdx}`"
class="z-[1] text-center"
>
<component :is="tag.icon" />
{{ formatMessage(tag.label).replace('and', '&') }}
</TagItem>
</div>
</div>
</div>
<div
class="flex flex-col justify-center gap-1 max-sm:flex-row max-sm:justify-start max-sm:gap-3 xl:contents"
>
<div
v-tooltip="formatDateTime(version.date_published)"
class="z-[1] flex cursor-help items-center gap-1 text-nowrap font-medium xl:self-center"
>
<CalendarIcon class="xl:hidden" />
{{ formatRelativeTime(new Date(version.date_published)) }}
</div>
<div
class="pointer-events-none z-[1] flex items-center gap-1 font-medium xl:self-center"
>
<DownloadIcon class="xl:hidden" />
{{ formatCompactNumber(version.downloads) }}
</div>
</div>
</div>
</div>
<div
class="flex items-start justify-end gap-1 sm:items-center z-[1] max-[400px]:flex-col max-[400px]:justify-start"
</div>
</template>
<template #cell-name="{ row: version }">
<div class="flex min-w-0 flex-col gap-2">
<AutoLink
v-tooltip="`${version.version_number} - ${version.name}`"
:to="versionLink?.(version)"
class="flex min-w-0 flex-col gap-1 w-fit"
:link-class="versionLink ? 'focus-visible:underline' : ''"
:title="`${version.version_number} - ${version.name}`"
>
<slot name="actions" :version="version"></slot>
</div>
<div v-if="showFiles" class="tag-list pointer-events-none relative z-[1] col-span-full">
<div class="flex min-w-0 items-center gap-2">
<div
class="overflow-hidden text-ellipsis font-medium text-contrast"
:class="versionLink ? 'version-row-name' : ''"
>
{{ version.version_number }}
</div>
<div
v-if="version.files_missing_attribution"
v-tooltip="formatMessage(messages.withheldTooltip)"
:style="{
'--_bg-color': 'var(--color-orange-bg)',
'--_color': 'var(--color-orange)',
}"
>
<TagItem> <CircleAlertIcon /> {{ formatMessage(messages.withheld) }}</TagItem>
</div>
</div>
</AutoLink>
<div v-if="showFiles" class="tag-list">
<div
v-for="(file, fileIdx) in version.files"
:key="`platform-tag-${fileIdx}`"
:key="`file-tag-${fileIdx}`"
:class="`flex items-center gap-1 text-wrap rounded-full bg-button-bg px-2 py-0.5 text-xs font-medium ${file.primary || fileIdx === 0 ? 'bg-brand-highlight text-contrast' : 'text-primary'}`"
>
<StarIcon v-if="file.primary || fileIdx === 0" class="shrink-0" />
@@ -261,8 +104,306 @@
</div>
</div>
</template>
<template #cell-gameVersions="{ row: version }">
<div class="flex flex-wrap gap-1 w-fit">
<TagItem
v-for="gameVersion in getDisplayGameVersions(version).slice(0, MAX_GAME_VERSION_TAGS)"
:key="`version-tag-${gameVersion}`"
v-tooltip="`Toggle filter for ${gameVersion}`"
data-no-row-click
:action="() => versionFilters?.toggleFilters('gameVersion', version.game_versions)"
>
{{ gameVersion }}
</TagItem>
<Menu
v-if="getDisplayGameVersions(version).length > MAX_GAME_VERSION_TAGS"
data-no-row-click
:delay="{ hide: 50, show: 0 }"
no-auto-focus
class="cursor-default"
>
<TagItem tabindex="0">
+{{ getDisplayGameVersions(version).length - MAX_GAME_VERSION_TAGS }}
</TagItem>
<template #popper>
<div class="flex max-w-[20rem] flex-wrap gap-1">
<TagItem
v-for="gameVersion in getDisplayGameVersions(version).slice(MAX_GAME_VERSION_TAGS)"
:key="`overflow-version-tag-${gameVersion}`"
:action="() => versionFilters?.toggleFilters('gameVersion', version.game_versions)"
>
{{ gameVersion }}
</TagItem>
</div>
</template>
</Menu>
</div>
</template>
<template #cell-platforms="{ row: version }">
<div class="flex flex-wrap gap-1 w-fit">
<template v-if="version.noModLoader">
<TagItem class="border !border-solid border-surface-5"> No mod loader </TagItem>
</template>
<template v-else>
<TagItem
v-for="platform in version.loaders.slice(0, MAX_PLATFORM_TAGS)"
:key="`platform-tag-${platform}`"
v-tooltip="`Toggle filter for ${platform}`"
data-no-row-click
:style="`--_color: var(--color-platform-${platform})`"
:action="() => versionFilters?.toggleFilter('platform', platform)"
>
<component :is="getLoaderIcon(platform)" v-if="getLoaderIcon(platform)" />
<FormattedTag :tag="platform" enforce-type="loader" />
</TagItem>
<Menu
v-if="version.loaders.length > MAX_PLATFORM_TAGS"
data-no-row-click
:delay="{ hide: 50, show: 0 }"
no-auto-focus
class="cursor-default"
>
<TagItem tabindex="0"> +{{ version.loaders.length - MAX_PLATFORM_TAGS }} </TagItem>
<template #popper>
<div class="flex max-w-[20rem] flex-wrap gap-1">
<TagItem
v-for="platform in version.loaders.slice(MAX_PLATFORM_TAGS)"
:key="`overflow-platform-tag-${platform}`"
:style="`--_color: var(--color-platform-${platform})`"
:action="() => versionFilters?.toggleFilter('platform', platform)"
>
<component :is="getLoaderIcon(platform)" v-if="getLoaderIcon(platform)" />
<FormattedTag :tag="platform" enforce-type="loader" />
</TagItem>
</div>
</template>
</Menu>
</template>
</div>
</template>
<template v-if="showEnvironmentColumn" #cell-environment="{ row: version }">
<div class="flex flex-wrap gap-1">
<TagItem
v-for="(tag, tagIdx) in getEnvironmentTags(version.environment)"
:key="`env-tag-${tagIdx}`"
data-no-row-click
class="text-center"
>
<component :is="tag.icon" />
{{ formatMessage(tag.label).replace('and', '&') }}
</TagItem>
</div>
</template>
<template #cell-published="{ row: version }">
<div
v-tooltip="formatDateTime(version.date_published)"
class="flex items-center gap-1 text-nowrap font-medium w-max cursor-default"
data-no-row-click
>
{{ formatRelativeTime(new Date(version.date_published)) }}
</div>
</template>
<template #cell-downloads="{ row: version }">
<div
v-tooltip="`${version.downloads} downloads`"
class="flex items-center gap-1 font-medium w-max text-nowrap cursor-default"
data-no-row-click
>
{{ formatCompactNumber(version.downloads) }}
</div>
</template>
<template #cell-actions="{ row: version }">
<div
class="flex h-full w-max items-center justify-end gap-0.5 whitespace-nowrap cursor-default"
data-no-row-click
>
<slot name="actions" :version="version"></slot>
</div>
</template>
</Table>
<!-- MOBILE VERSIONS TABLE/LIST -->
<div
v-if="versions.length > 0"
class="flex flex-col gap-4 rounded-2xl bg-bg-raised p-5 sm:hidden"
>
<template v-for="(version, index) in currentVersions" :key="version.id ?? index">
<div
class="h-px w-[calc(100%+2.5rem)] bg-surface-5 -ml-5"
:class="{
hidden: index === 0,
}"
></div>
<SmartClickable class="group">
<template v-if="versionLink" #clickable>
<AutoLink
:to="versionLink(version)"
class="rounded-xl outline-none no-click-animation custom-focus-indicator"
:title="`${version.version_number} - ${version.name}`"
></AutoLink>
</template>
<div
class="flex flex-col justify-center gap-1.5 rounded-xl transition-colors smart-clickable:outline-on-focus"
:class="{
'cursor-pointer': !!versionLink,
}"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5">
<div class="self-center">
<VersionChannelIndicator
v-tooltip="`Toggle filter for ${version.version_type}`"
:channel="version.version_type"
class="cursor-pointer smart-clickable:allow-pointer-events"
size="sm"
@click="versionFilters?.toggleFilter('channel', version.version_type)"
/>
</div>
<div class="overflow-hidden text-ellipsis font-medium text-base text-contrast">
{{ version.version_number }}
</div>
<div
v-if="version.files_missing_attribution"
v-tooltip="formatMessage(messages.withheldTooltip)"
:style="{
'--_bg-color': 'var(--color-orange-bg)',
'--_color': 'var(--color-orange)',
}"
>
<TagItem> <CircleAlertIcon /> {{ formatMessage(messages.withheld) }}</TagItem>
</div>
</div>
<div
class="flex items-start justify-end gap-1 max-[400px]:flex-col max-[400px]:justify-start smart-clickable:allow-pointer-events"
>
<slot name="actions" :version="version"></slot>
</div>
</div>
<div class="flex flex-col justify-center gap-3">
<div class="flex flex-row flex-wrap items-center gap-1.5">
<TagItem
v-for="gameVersion in getDisplayGameVersions(version).slice(
0,
MAX_GAME_VERSION_TAGS,
)"
:key="`version-tag-${gameVersion}`"
v-tooltip="`Toggle filter for ${gameVersion}`"
class="smart-clickable:allow-pointer-events"
:action="() => versionFilters?.toggleFilters('gameVersion', version.game_versions)"
>
{{ gameVersion }}
</TagItem>
<Menu
v-if="getDisplayGameVersions(version).length > MAX_GAME_VERSION_TAGS"
:delay="{ hide: 50, show: 0 }"
no-auto-focus
class="cursor-default smart-clickable:allow-pointer-events"
>
<TagItem tabindex="0">
+{{ getDisplayGameVersions(version).length - MAX_GAME_VERSION_TAGS }}
</TagItem>
<template #popper>
<div class="flex max-w-[20rem] flex-wrap gap-1">
<TagItem
v-for="gameVersion in getDisplayGameVersions(version).slice(
MAX_GAME_VERSION_TAGS,
)"
:key="`overflow-version-tag-${gameVersion}`"
:action="
() => versionFilters?.toggleFilters('gameVersion', version.game_versions)
"
>
{{ gameVersion }}
</TagItem>
</div>
</template>
</Menu>
<template v-if="version.noModLoader">
<TagItem class="border !border-solid border-surface-5"> No mod loader </TagItem>
</template>
<template v-else>
<TagItem
v-for="platform in version.loaders.slice(0, MAX_PLATFORM_TAGS)"
:key="`platform-tag-${platform}`"
v-tooltip="`Toggle filter for ${platform}`"
class="smart-clickable:allow-pointer-events"
:style="`--_color: var(--color-platform-${platform})`"
:action="() => versionFilters?.toggleFilter('platform', platform)"
>
<component :is="getLoaderIcon(platform)" v-if="getLoaderIcon(platform)" />
<FormattedTag :tag="platform" enforce-type="loader" />
</TagItem>
<Menu
v-if="version.loaders.length > MAX_PLATFORM_TAGS"
:delay="{ hide: 50, show: 0 }"
no-auto-focus
class="cursor-default smart-clickable:allow-pointer-events"
>
<TagItem tabindex="0">
+{{ version.loaders.length - MAX_PLATFORM_TAGS }}
</TagItem>
<template #popper>
<div class="flex max-w-[20rem] flex-wrap gap-1">
<TagItem
v-for="platform in version.loaders.slice(MAX_PLATFORM_TAGS)"
:key="`overflow-platform-tag-${platform}`"
:style="`--_color: var(--color-platform-${platform})`"
:action="() => versionFilters?.toggleFilter('platform', platform)"
>
<component :is="getLoaderIcon(platform)" v-if="getLoaderIcon(platform)" />
<FormattedTag :tag="platform" enforce-type="loader" />
</TagItem>
</div>
</template>
</Menu>
</template>
<template v-if="showEnvironmentColumn">
<TagItem
v-for="(tag, tagIdx) in getEnvironmentTags(version.environment)"
:key="`env-tag-${tagIdx}`"
class="text-center"
>
<component :is="tag.icon" />
{{ formatMessage(tag.label).replace('and', '&') }}
</TagItem>
</template>
</div>
<div class="flex flex-row justify-start gap-3">
<div class="flex cursor-help items-center gap-1 text-nowrap font-medium">
<CalendarIcon />
{{ formatRelativeTime(new Date(version.date_published)) }}
</div>
<div class="flex items-center gap-1 font-medium">
<DownloadIcon />
{{ formatCompactNumber(version.downloads) }}
</div>
</div>
</div>
<div v-if="showFiles" class="tag-list">
<div
v-for="(file, fileIdx) in version.files"
:key="`file-tag-${fileIdx}`"
:class="`flex items-center gap-1 text-wrap rounded-full bg-button-bg px-2 py-0.5 text-xs font-medium ${file.primary || fileIdx === 0 ? 'bg-brand-highlight text-contrast' : 'text-primary'}`"
>
<StarIcon v-if="file.primary || fileIdx === 0" class="shrink-0" />
{{ file.filename }} - {{ formatBytes(file.size) }}
</div>
</div>
</div>
</SmartClickable>
</template>
</div>
<div class="flex mt-3">
<div class="mt-3 flex">
<Pagination
:page="currentPage"
class="ml-auto"
@@ -286,6 +427,9 @@ import {
ButtonStyled,
FormattedTag,
Pagination,
SmartClickable,
Table,
type TableColumn,
TagItem,
useCompactNumber,
useFormatBytes,
@@ -303,7 +447,7 @@ import { defineMessages, useVIntl } from '../../composables/i18n'
import { getEnvironmentTags } from './settings/environment/environments'
const { formatMessage } = useVIntl()
const formatRelativeTime = useRelativeTime()
const formatRelativeTime = useRelativeTime({ style: 'narrow' })
const { formatCompactNumber } = useCompactNumber()
const formatDateTime = useFormatDateTime({
timeStyle: 'short',
@@ -311,6 +455,9 @@ const formatDateTime = useFormatDateTime({
})
const formatBytes = useFormatBytes()
const MAX_GAME_VERSION_TAGS = 5
const MAX_PLATFORM_TAGS = 3
type VersionWithDisplayUrlEnding = Version & {
displayUrlEnding: string
environment?: Labrinth.Projects.v3.Environment
@@ -319,8 +466,20 @@ type VersionWithDisplayUrlEnding = Version & {
type DisplayVersion = VersionWithDisplayUrlEnding & {
noModLoader: boolean
files_missing_attribution?: boolean
}
type VersionTableColumn =
| 'channel'
| 'name'
| 'gameVersions'
| 'platforms'
| 'environment'
| 'published'
| 'downloads'
| 'actions'
type VersionTableRow = DisplayVersion & Record<string, unknown>
const props = withDefaults(
defineProps<{
baseId?: string
@@ -331,6 +490,7 @@ const props = withDefaults(
}
versions: VersionWithDisplayUrlEnding[]
showFiles?: boolean
showEnvironmentColumn?: boolean
currentMember?: boolean
loaders: Labrinth.Tags.v2.Loader[]
gameVersions: GameVersionTag[]
@@ -341,11 +501,72 @@ const props = withDefaults(
{
baseId: undefined,
showFiles: false,
showEnvironmentColumn: false,
currentMember: false,
versionLink: undefined,
},
)
const visibleCellClass = '!overflow-visible py-3 align-middle pr-2.5'
const versionColumns = computed<TableColumn<VersionTableColumn>[]>(() => {
const columns: TableColumn<VersionTableColumn>[] = [
{
key: 'channel',
width: '4.5rem',
headerClass: 'text-secondary',
cellClass: visibleCellClass,
},
{
key: 'name',
label: 'Name',
cellClass: '!overflow-visible py-3 pr-4 min-w-[7rem]',
},
{
key: 'gameVersions',
label: 'Game version',
cellClass: '!overflow-visible py-3 align-middle pr-2.5 w-fit max-w-[10rem]',
},
{
key: 'platforms',
label: 'Platforms',
cellClass: '!overflow-visible py-3 align-middle pr-2.5 w-fit max-w-[10rem]',
},
]
if (props.showEnvironmentColumn) {
columns.push({
key: 'environment',
label: 'Environment',
cellClass: visibleCellClass,
})
}
columns.push(
{
key: 'published',
label: 'Published',
cellClass: '!overflow-visible align-middle pr-2.5 w-max',
width: '12%',
},
{
key: 'downloads',
label: 'Downloads',
cellClass: '!overflow-visible align-middle',
width: '12%',
},
{
key: 'actions',
align: 'right',
headerClass: 'text-secondary',
width: '1%',
cellClass: '!overflow-visible align-middle',
},
)
return columns
})
function getModpackLoaders(version: VersionWithDisplayUrlEnding): string[] {
const loaders = Array.isArray(version.loaders) ? version.loaders : []
@@ -374,6 +595,10 @@ function hasNoModLoader(loaders: string[]): boolean {
)
}
function getDisplayGameVersions(version: DisplayVersion): string[] {
return formatVersionsForDisplay(version.game_versions, props.gameVersions)
}
const normalizedVersions = computed<DisplayVersion[]>(() =>
props.versions.map((version) => {
const loaders = getModpackLoaders(version)
@@ -389,8 +614,6 @@ const normalizedVersions = computed<DisplayVersion[]>(() =>
}),
)
const maxGameVersionTags = 6
const currentPage: Ref<number> = ref(1)
const pageSize: Ref<number> = ref(20)
const versionFilters: Ref<InstanceType<typeof VersionFilterControl> | null> = ref(null)
@@ -403,11 +626,6 @@ const selectedPlatforms: Ref<string[]> = computed(
)
const selectedChannels: Ref<string[]> = computed(() => versionFilters.value?.selectedChannels ?? [])
const hasMultipleEnvironments = computed(() => {
const environments = new Set(currentVersions.value.map((v) => v.environment).filter(Boolean))
return environments.size > 1
})
const filteredVersions = computed(() => {
return normalizedVersions.value.filter(
(version) =>
@@ -431,6 +649,9 @@ const currentVersions = computed(() =>
currentPage.value * pageSize.value,
),
)
const currentVersionRows = computed<VersionTableRow[]>(
() => currentVersions.value as VersionTableRow[],
)
const route = useRoute()
const router = useRouter()
@@ -452,6 +673,18 @@ function switchPage(page: number) {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
function getVersionRowClass(): string {
return props.versionLink
? 'group version-row-link cursor-pointer transition-[filter] [&:hover:not(:has([data-no-row-click]:hover))]:brightness-[115%]'
: 'group'
}
function openVersionRow(version: VersionTableRow) {
const link = props.versionLink?.(version)
if (!link) return
router.push(link)
}
function updateQuery(newQueries: Record<string, string | string[] | undefined | null>) {
if (newQueries.page) {
currentPage.value = Number(newQueries.page)
@@ -478,16 +711,9 @@ const messages = defineMessages({
},
})
</script>
<style scoped>
.versions-grid-row {
@apply grid grid-cols-[1fr_min-content] gap-4 supports-[grid-template-columns:subgrid]:col-span-full supports-[grid-template-columns:subgrid]:!grid-cols-subgrid sm:grid-cols-[min-content_1fr_1fr_1fr_min-content];
}
.has-environment .versions-grid-row {
@apply xl:grid-cols-[min-content_1fr_1fr_1fr_1fr_1fr_1fr_min-content];
}
.no-environment .versions-grid-row {
@apply xl:grid-cols-[min-content_1fr_1fr_1fr_1fr_1fr_min-content];
:deep(.version-row-link:hover:not(:has([data-no-row-click]:hover)) .version-row-name) {
text-decoration-line: underline;
}
</style>
@@ -1,43 +1,84 @@
<template>
<div class="flex flex-col gap-3">
<div class="flex flex-wrap items-center gap-2">
<ManySelect
v-model="selectedPlatforms"
:options="filterOptions.platform"
:dropdown-id="`${baseId}-platform`"
@change="updateFilters"
<MultiSelect
v-if="filterOptions.platform.length > 1"
:model-value="selectedPlatforms"
:options="platformOptions"
fit-content
:dropdown-min-width="180"
trigger-class="!min-h-9 !px-3 !py-0"
@update:model-value="updateSelectedPlatforms"
>
<FilterIcon class="h-5 w-5 text-secondary" />
Platform
<template #option="{ option }">
<FormattedTag :tag="option" enforce-type="loader" />
<template #input-content="{ isOpen, openDirection }">
<div class="flex items-center gap-2">
<FilterIcon class="h-5 w-5 text-secondary" />
<span class="font-semibold text-primary">Platforms</span>
<ChevronLeftIcon
class="h-5 w-5 text-secondary transition-transform duration-150"
:class="
isOpen ? (openDirection === 'down' ? 'rotate-90' : '-rotate-90') : '-rotate-90'
"
/>
</div>
</template>
</ManySelect>
<ManySelect
v-model="selectedGameVersions"
:options="filterOptions.gameVersion"
:dropdown-id="`${baseId}-game-version`"
search
@change="updateFilters"
</MultiSelect>
<MultiSelect
v-if="availableGameVersions.length > 1"
:model-value="selectedGameVersions"
:options="gameVersionOptions"
searchable
search-placeholder="Search..."
fit-content
:dropdown-min-width="240"
trigger-class="!min-h-9 !px-3 !py-0"
@update:model-value="updateSelectedGameVersions"
>
<FilterIcon class="h-5 w-5 text-secondary" />
Game versions
<template #footer>
<Checkbox v-model="showSnapshots" class="mx-1" :label="`Show all versions`" />
<template #input-content="{ isOpen, openDirection }">
<div class="flex items-center gap-2">
<FilterIcon class="h-5 w-5 text-secondary" />
<span class="font-semibold text-primary">Game versions</span>
<ChevronLeftIcon
class="h-5 w-5 text-secondary transition-transform duration-150"
:class="
isOpen ? (openDirection === 'down' ? 'rotate-90' : '-rotate-90') : '-rotate-90'
"
/>
</div>
</template>
</ManySelect>
<ManySelect
v-model="selectedChannels"
:options="filterOptions.channel"
:dropdown-id="`${baseId}-channel`"
@change="updateFilters"
<template v-if="hasAnyNonReleaseGameVersions" #bottom>
<div class="border-0 border-t border-solid border-t-surface-5 px-3 py-3">
<Checkbox
:model-value="showSnapshots"
class="mx-1"
:label="`Show all versions`"
@update:model-value="updateShowSnapshots"
/>
</div>
</template>
</MultiSelect>
<MultiSelect
v-if="filterOptions.channel.length > 1"
:model-value="selectedChannels"
:options="channelOptions"
fit-content
:dropdown-min-width="180"
trigger-class="!min-h-9 !px-3 !py-0"
@update:model-value="updateSelectedChannels"
>
<FilterIcon class="h-5 w-5 text-secondary" />
Channels
<template #option="{ option }">
{{ option === 'release' ? 'Release' : option === 'beta' ? 'Beta' : 'Alpha' }}
<template #input-content="{ isOpen, openDirection }">
<div class="flex items-center gap-2">
<FilterIcon class="h-5 w-5 text-secondary" />
<span class="font-semibold text-primary">Channels</span>
<ChevronLeftIcon
class="h-5 w-5 text-secondary transition-transform duration-150"
:class="
isOpen ? (openDirection === 'down' ? 'rotate-90' : '-rotate-90') : '-rotate-90'
"
/>
</div>
</template>
</ManySelect>
</MultiSelect>
</div>
<div class="flex flex-wrap items-center gap-1 empty:hidden">
<TagItem
@@ -79,10 +120,12 @@
</template>
<script setup lang="ts">
import { FilterIcon, XCircleIcon, XIcon } from '@modrinth/assets'
import { Checkbox, FormattedTag, ManySelect, TagItem } from '@modrinth/ui'
import { ChevronLeftIcon, FilterIcon, XCircleIcon, XIcon } from '@modrinth/assets'
import type { MultiSelectOption } from '@modrinth/ui'
import { Checkbox, formatLoader, FormattedTag, MultiSelect, TagItem, useVIntl } from '@modrinth/ui'
import type { GameVersionTag, Version } from '@modrinth/utils'
import { computed, ref } from 'vue'
import type { LocationQueryValue } from 'vue-router'
import { useRoute } from 'vue-router'
const props = defineProps<{
@@ -93,6 +136,8 @@ const props = defineProps<{
const emit = defineEmits(['update:query'])
const { formatMessage } = useVIntl()
const allChannels = ref(['release', 'beta', 'alpha'])
const route = useRoute()
@@ -102,6 +147,34 @@ const showSnapshots = ref(false)
type FilterType = 'channel' | 'gameVersion' | 'platform'
type Filter = string
const gameVersionTags = computed(() => new Map(props.gameVersions.map((x) => [x.version, x])))
const availableGameVersions = computed(() => {
const gameVersionSet = new Set<Filter>()
for (const version of props.versions) {
for (const gameVersion of Array.isArray(version.game_versions) ? version.game_versions : []) {
gameVersionSet.add(gameVersion)
}
}
const knownGameVersions = props.gameVersions.filter((x) => gameVersionSet.has(x.version))
const knownGameVersionSet = new Set(knownGameVersions.map((x) => x.version))
const unknownGameVersions = Array.from(gameVersionSet).filter(
(version) => !knownGameVersionSet.has(version),
)
return [...knownGameVersions.map((x) => x.version), ...unknownGameVersions]
})
const hasAnyReleaseGameVersions = computed(() =>
availableGameVersions.value.some((version) => isReleaseGameVersion(version)),
)
const hasAnyNonReleaseGameVersions = computed(() =>
availableGameVersions.value.some((version) => !isReleaseGameVersion(version)),
)
const filterOptions = computed(() => {
const filters: Record<FilterType, Filter[]> = {
channel: [],
@@ -110,16 +183,12 @@ const filterOptions = computed(() => {
}
const platformSet = new Set<Filter>()
const gameVersionSet = new Set<Filter>()
const channelSet = new Set<Filter>()
for (const version of props.versions) {
for (const loader of Array.isArray(version.loaders) ? version.loaders : []) {
platformSet.add(loader)
}
for (const gameVersion of Array.isArray(version.game_versions) ? version.game_versions : []) {
gameVersionSet.add(gameVersion)
}
channelSet.add(version.version_type)
}
@@ -127,12 +196,12 @@ const filterOptions = computed(() => {
filters.channel = Array.from(channelSet) as Filter[]
filters.channel.sort((a, b) => allChannels.value.indexOf(a) - allChannels.value.indexOf(b))
}
if (gameVersionSet.size > 0) {
const gameVersions = props.gameVersions.filter((x) => gameVersionSet.has(x.version))
filters.gameVersion = gameVersions
.filter((x) => (showSnapshots.value ? true : x.version_type === 'release'))
.map((x) => x.version)
if (availableGameVersions.value.length > 0) {
filters.gameVersion = availableGameVersions.value.filter((version) =>
showSnapshots.value || !hasAnyReleaseGameVersions.value
? true
: isReleaseGameVersion(version),
)
}
if (platformSet.size > 0) {
filters.platform = Array.from(platformSet) as Filter[]
@@ -141,6 +210,27 @@ const filterOptions = computed(() => {
return filters
})
const gameVersionOptions = computed<MultiSelectOption<string>[]>(() =>
filterOptions.value.gameVersion.map((version) => ({
value: version,
label: version,
})),
)
const channelOptions = computed<MultiSelectOption<string>[]>(() =>
filterOptions.value.channel.map((channel) => ({
value: channel,
label: getChannelLabel(channel),
})),
)
const platformOptions = computed<MultiSelectOption<string>[]>(() =>
filterOptions.value.platform.map((platform) => ({
value: platform,
label: formatLoader(formatMessage, platform),
})),
)
const selectedChannels = ref<string[]>([])
const selectedGameVersions = ref<string[]>([])
const selectedPlatforms = ref<string[]>([])
@@ -149,6 +239,10 @@ selectedChannels.value = route.query.c ? getArrayOrString(route.query.c) : []
selectedGameVersions.value = route.query.g ? getArrayOrString(route.query.g) : []
selectedPlatforms.value = route.query.l ? getArrayOrString(route.query.l) : []
if (selectedGameVersions.value.some((version) => !isReleaseGameVersion(version))) {
showSnapshots.value = true
}
async function toggleFilters(type: FilterType, filters: Filter[]) {
for (const filter of filters) {
await toggleFilter(type, filter, true)
@@ -176,6 +270,38 @@ async function toggleFilter(type: FilterType, filter: Filter, bulk = false) {
}
}
function updateSelectedGameVersions(versions: string[]) {
selectedGameVersions.value = versions
updateFilters()
}
function updateSelectedChannels(channels: string[]) {
selectedChannels.value = channels
updateFilters()
}
function updateSelectedPlatforms(platforms: string[]) {
selectedPlatforms.value = platforms
updateFilters()
}
function updateShowSnapshots(value: boolean, _event?: MouseEvent) {
showSnapshots.value = value
if (value || !hasAnyReleaseGameVersions.value) {
return
}
const selectedReleaseGameVersions = selectedGameVersions.value.filter((version) =>
isReleaseGameVersion(version),
)
if (selectedReleaseGameVersions.length !== selectedGameVersions.value.length) {
selectedGameVersions.value = selectedReleaseGameVersions
updateFilters()
}
}
async function clearFilters() {
selectedChannels.value = []
selectedGameVersions.value = []
@@ -201,11 +327,22 @@ defineExpose({
selectedPlatforms,
})
function getArrayOrString(x: string | string[]): string[] {
function getArrayOrString(x: LocationQueryValue | LocationQueryValue[]): string[] {
if (x === null) {
return []
}
if (typeof x === 'string') {
return [x]
} else {
return x
}
return x.filter((value): value is string => value !== null)
}
function getChannelLabel(channel: string) {
return channel === 'release' ? 'Release' : channel === 'beta' ? 'Beta' : 'Alpha'
}
function isReleaseGameVersion(version: string) {
return gameVersionTags.value.get(version)?.version_type === 'release'
}
</script>
@@ -100,6 +100,32 @@ export const HorizontalOverflow: StoryObj = {
}),
}
export const CustomClasses: StoryObj = {
args: {},
render: () => ({
components: { Table },
setup() {
const columns = [
{ key: 'name', label: 'Name', cellClass: '!overflow-visible py-3' },
{ key: 'email', label: 'Email' },
{
key: 'status',
label: 'Status',
headerClass: 'text-center',
cellClass: 'text-center',
},
{ key: 'role', label: 'Role' },
]
const data = sampleUsers
const rowClass = (_row: User, index: number) => (index === 0 ? 'font-semibold' : '')
return { columns, data, rowClass }
},
template: /* html */ `
<Table :columns="columns" :data="data" :row-class="rowClass" />
`,
}),
}
export const WithSelection: StoryObj = {
args: {},
render: () => ({
@@ -0,0 +1,206 @@
import type { Labrinth } from '@modrinth/api-client'
import { DownloadIcon, MoreVerticalIcon } from '@modrinth/assets'
import type { GameVersionTag, Version } from '@modrinth/utils'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ButtonStyled from '../../components/base/ButtonStyled.vue'
import ProjectPageVersions from '../../components/project/ProjectPageVersions.vue'
type StoryVersion = Version & {
displayUrlEnding: string
environment?: Labrinth.Projects.v3.Environment
mrpack_loaders?: string[]
files_missing_attribution?: string[]
}
const gameVersions: GameVersionTag[] = [
{ version: '1.21.4', version_type: 'release', date: '2024-12-03', major: true },
{ version: '1.21.3', version_type: 'release', date: '2024-10-23', major: false },
{ version: '1.21.1', version_type: 'release', date: '2024-08-08', major: true },
{ version: '1.20.6', version_type: 'release', date: '2024-04-29', major: false },
{ version: '1.20.4', version_type: 'release', date: '2023-12-07', major: true },
{ version: '1.20.1', version_type: 'release', date: '2023-06-12', major: true },
{ version: '1.19.4', version_type: 'release', date: '2023-03-14', major: true },
]
const loaders: Labrinth.Tags.v2.Loader[] = [
{ icon: '', name: 'fabric', supported_project_types: ['mod', 'modpack'] },
{ icon: '', name: 'forge', supported_project_types: ['mod', 'modpack'] },
{ icon: '', name: 'neoforge', supported_project_types: ['mod', 'modpack'] },
{ icon: '', name: 'quilt', supported_project_types: ['mod', 'modpack'] },
]
const versions: StoryVersion[] = [
{
id: 'version-1',
project_id: 'project-1',
author_id: 'author-1',
name: 'Performance improvements and bug fixes',
version_number: 'mc1.21.4-0.6.13',
displayUrlEnding: 'mc1.21.4-0.6.13',
changelog: '',
dependencies: [],
game_versions: ['1.21.4', '1.21.3', '1.21.1'],
version_type: 'release',
loaders: ['fabric', 'quilt', 'forge', 'neoforge'],
featured: true,
status: 'listed',
date_published: new Date(Date.now() - 1000 * 60 * 60 * 24 * 2).toISOString(),
downloads: 1258400,
environment: 'client_only',
files: [
{
hashes: { sha512: 'sha512-1', sha1: 'sha1-1' },
url: 'https://cdn.modrinth.com/data/story/version-1.jar',
filename: 'sodium-fabric-0.6.13.jar',
primary: true,
size: 1248200,
},
],
},
{
id: 'version-2',
project_id: 'project-1',
author_id: 'author-1',
name: 'NeoForge compatibility',
version_number: 'mc1.20.6-0.5.11',
displayUrlEnding: 'mc1.20.6-0.5.11',
changelog: '',
dependencies: [],
game_versions: ['1.20.6', '1.20.4', '1.20.1', '1.19.4'],
version_type: 'beta',
loaders: ['neoforge'],
featured: false,
status: 'listed',
date_published: new Date(Date.now() - 1000 * 60 * 60 * 24 * 16).toISOString(),
downloads: 84200,
environment: 'client_and_server',
files_missing_attribution: ['bundled-library.jar'],
files: [
{
hashes: { sha512: 'sha512-2', sha1: 'sha1-2' },
url: 'https://cdn.modrinth.com/data/story/version-2.jar',
filename: 'sodium-neoforge-0.5.11.jar',
primary: true,
size: 1424200,
},
{
hashes: { sha512: 'sha512-3', sha1: 'sha1-3' },
url: 'https://cdn.modrinth.com/data/story/version-2-sources.jar',
filename: 'sodium-neoforge-0.5.11-sources.jar',
primary: false,
size: 624200,
file_type: 'sources-jar',
},
],
},
{
id: 'version-3',
project_id: 'project-1',
author_id: 'author-1',
name: 'Server pack with no mod loader',
version_number: 'server-pack-1.0.0',
displayUrlEnding: 'server-pack-1.0.0',
changelog: '',
dependencies: [],
game_versions: ['1.21.4'],
version_type: 'alpha',
loaders: ['minecraft'],
mrpack_loaders: [],
featured: false,
status: 'listed',
date_published: new Date(Date.now() - 1000 * 60 * 60 * 24 * 45).toISOString(),
downloads: 1200,
environment: 'server_only',
files: [
{
hashes: { sha512: 'sha512-4', sha1: 'sha1-4' },
url: 'https://cdn.modrinth.com/data/story/version-3.mrpack',
filename: 'server-pack-1.0.0.mrpack',
primary: true,
size: 2048200,
},
],
},
]
const meta = {
title: 'Project/ProjectPageVersions',
component: ProjectPageVersions,
decorators: [
(story) => ({
components: { story },
template: '<div class="p-4"><story /></div>',
}),
],
args: {
project: {
project_type: 'mod',
slug: 'sodium',
id: 'project-1',
},
versions,
loaders,
gameVersions,
baseId: 'project-page-versions-story',
showFiles: false,
showEnvironmentColumn: false,
versionLink: (version: Version) => `https://modrinth.com/mod/sodium/version/${version.id}`,
},
render: (args) => ({
components: { ButtonStyled, DownloadIcon, MoreVerticalIcon, ProjectPageVersions },
setup() {
return { args }
},
template: /* html */ `
<ProjectPageVersions v-bind="args">
<template #actions="{ version }">
<ButtonStyled circular type="transparent">
<a
v-tooltip="'Download'"
:href="version.files[0]?.url"
:download="version.files[0]?.filename"
aria-label="Download"
>
<DownloadIcon aria-hidden="true" />
</a>
</ButtonStyled>
<ButtonStyled circular type="transparent">
<button v-tooltip="'More options'" aria-label="More options">
<MoreVerticalIcon aria-hidden="true" />
</button>
</ButtonStyled>
</template>
</ProjectPageVersions>
`,
}),
} satisfies Meta<typeof ProjectPageVersions>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {}
export const WithFiles: Story = {
args: {
showFiles: true,
},
}
export const WithEnvironmentColumn: Story = {
args: {
showEnvironmentColumn: true,
},
}
export const MobileWidth: Story = {
parameters: {
viewport: {
defaultViewport: 'mobile1',
},
},
args: {
showFiles: true,
showEnvironmentColumn: true,
},
}