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>
This commit is contained in:
Calum H.
2026-01-28 20:09:24 +00:00
committed by GitHub
parent 728f8db7b9
commit 78aca7e5c0
52 changed files with 4097 additions and 939 deletions

View File

@@ -223,10 +223,7 @@
:where(input) {
box-sizing: border-box;
max-height: 40px;
&:not(.stylized-toggle) {
max-width: 100%;
}
max-width: 100%;
}
:where(.adjacent-input, &.adjacent-input) {
@@ -271,10 +268,6 @@
&:not(&.small) {
flex-direction: column;
align-items: flex-start;
.stylized-toggle {
flex-basis: 0;
}
}
}
}
@@ -650,64 +643,6 @@ tr.button-transparent {
}
}
.switch {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
-webkit-tap-highlight-color: transparent;
cursor: pointer;
&:focus {
//outline: 0; Bad for accessibility
}
}
.stylized-toggle {
@extend .button-base;
box-sizing: content-box;
min-height: 32px;
height: 32px;
width: 52px;
max-width: 52px;
border-radius: var(--size-rounded-max);
display: inline-block;
position: relative;
margin: 0;
transition: all 0.2s ease;
background: var(--color-button-bg);
&:after {
content: '';
position: absolute;
top: 7px;
left: 7px;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--color-toggle-handle);
transition: all 0.2s cubic-bezier(0.5, 0.1, 0.75, 1.35);
outline: 2px solid transparent;
@media (prefers-reduced-motion) {
transition: none;
}
}
&:checked {
background-color: var(--color-brand);
&:after {
transform: translatex(20px);
background: var(--color-brand-inverted);
}
}
&:hover &:focus {
background: var(--color-button-bg);
}
}
.textarea-wrapper {
display: flex;
flex-direction: column;

View File

@@ -60,8 +60,6 @@ html {
--color-button-bg-active: #c3c6cb;
--color-button-text-active: var(--color-button-text-hover);
--color-toggle-handle: var(--color-icon);
--color-dropdown-bg: var(--color-button-bg);
--color-dropdown-text: var(--color-button-text);
@@ -177,8 +175,6 @@ html {
--color-button-bg-active: #616570;
--color-button-text-active: var(--color-button-text-hover);
--color-toggle-handle: var(--color-button-text);
--color-dropdown-bg: var(--color-button-bg);
--color-dropdown-text: var(--color-button-text);

View File

@@ -32,12 +32,7 @@
<label class="w-full text-lg font-bold text-contrast" for="modpack-hard-reset">
Erase all data
</label>
<input
id="modpack-hard-reset"
v-model="hardReset"
class="switch stylized-toggle shrink-0"
type="checkbox"
/>
<Toggle id="modpack-hard-reset" v-model="hardReset" class="shrink-0" />
</div>
<div>
If enabled, existing mods, worlds, and configurations, will be deleted before installing
@@ -69,7 +64,7 @@
<script setup lang="ts">
import { DownloadIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, Combobox, injectNotificationManager, NewModal } from '@modrinth/ui'
import { ButtonStyled, Combobox, injectNotificationManager, NewModal, Toggle } from '@modrinth/ui'
import { ModrinthServersFetchError } from '@modrinth/utils'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
@@ -158,9 +153,3 @@ const hide = () => modal.value?.hide()
defineExpose({ show, hide })
</script>
<style scoped>
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
}
</style>

View File

@@ -53,12 +53,7 @@
<label class="w-full text-lg font-bold text-contrast" for="hard-reset">
Erase all data
</label>
<input
id="hard-reset"
v-model="hardReset"
class="switch stylized-toggle shrink-0"
type="checkbox"
/>
<Toggle id="hard-reset" v-model="hardReset" class="shrink-0" />
</div>
<div>
Removes all data on your server, including your worlds, mods, and configuration
@@ -128,6 +123,7 @@ import {
ButtonStyled,
injectNotificationManager,
NewModal,
Toggle,
} from '@modrinth/ui'
import { ModrinthServersFetchError } from '@modrinth/utils'
import { onMounted, onUnmounted } from 'vue'
@@ -255,9 +251,3 @@ const hide = () => mrpackModal.value?.hide()
defineExpose({ show, hide })
</script>
<style scoped>
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
}
</style>

View File

@@ -143,12 +143,7 @@
<label class="w-full text-lg font-bold text-contrast" for="hard-reset">
Erase all data
</label>
<input
id="hard-reset"
v-model="hardReset"
class="switch stylized-toggle shrink-0"
type="checkbox"
/>
<Toggle id="hard-reset" v-model="hardReset" class="shrink-0" />
</div>
<div>
Removes all data on your server, including your worlds, mods, and configuration files,
@@ -542,9 +537,3 @@ const hide = () => versionSelectModal.value?.hide()
defineExpose({ show, hide })
</script>
<style scoped>
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
}
</style>

View File

@@ -274,10 +274,6 @@ watch(
</script>
<style scoped>
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
}
.button-base:active {
scale: none !important;
}

View File

@@ -351,12 +351,10 @@
monetization weights to this user on the project.
</span>
</label>
<input
<Toggle
:id="`member-${allOrgMembers[index].user.username}-override-perms`"
v-model="allOrgMembers[index].override"
class="switch stylized-toggle"
type="checkbox"
:disabled="(currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER"
:disabled="(props.currentMember?.permissions & EDIT_MEMBER) !== EDIT_MEMBER"
/>
</div>
<div class="adjacent-input">
@@ -535,6 +533,7 @@ import {
ConfirmModal,
injectNotificationManager,
injectProjectPageContext,
Toggle,
} from '@modrinth/ui'
import { Multiselect } from 'vue-multiselect'

View File

@@ -26,6 +26,7 @@ import {
SearchFilterControl,
SearchSidebarFilter,
type SortType,
Toggle,
useSearch,
} from '@modrinth/ui'
import { capitalizeString, cycleValue, type Mod as InstallableMod } from '@modrinth/utils'
@@ -466,13 +467,7 @@ useSeoMeta({
</div>
<div class="flex flex-row items-center justify-between gap-2 px-6">
<label for="erase-data-on-install"> Erase all data on install </label>
<input
id="erase-data-on-install"
v-model="eraseDataOnInstall"
label="Erase all data on install"
class="switch stylized-toggle flex-none"
type="checkbox"
/>
<Toggle id="erase-data-on-install" v-model="eraseDataOnInstall" class="flex-none" />
</div>
<div class="px-6 py-4 text-sm">
If enabled, existing mods, worlds, and configurations, will be deleted before installing
@@ -921,8 +916,4 @@ useSeoMeta({
mask-image: linear-gradient(to bottom, black, transparent);
opacity: 0.25;
}
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
}
</style>

View File

@@ -1,67 +1,76 @@
<script setup lang="ts">
import { SearchIcon } from '@modrinth/assets'
import { Toggle } from '@modrinth/ui'
import Fuse from 'fuse.js'
import { computed, ref, shallowReactive } from 'vue'
import {
DEFAULT_FEATURE_FLAGS,
type FeatureFlag,
saveFeatureFlags,
useFeatureFlags,
} from '~/composables/featureFlags.ts'
const flags = shallowReactive(useFeatureFlags().value)
const searchQuery = ref('')
const allFlags = computed(() => Object.keys(flags) as FeatureFlag[])
const fuse = computed(
() =>
new Fuse(allFlags.value, {
threshold: 0.4,
}),
)
const filteredFlags = computed(() => {
if (!searchQuery.value.trim()) {
return allFlags.value
}
return fuse.value.search(searchQuery.value).map((result) => result.item)
})
</script>
<template>
<div class="page">
<h1>Feature flags</h1>
<div class="flags">
<div class="mx-auto my-4 box-border w-[calc(100%-2rem)] max-w-[800px]">
<h1 class="mb-4 text-2xl font-bold text-contrast">Feature flags</h1>
<div class="relative mb-2">
<SearchIcon
class="pointer-events-none absolute left-3 top-1/2 size-5 -translate-y-1/2 text-secondary"
/>
<input
v-model="searchQuery"
type="search"
placeholder="Search flags..."
class="w-full rounded-xl bg-bg-raised py-2 pl-10 pr-4"
/>
</div>
<div class="flex flex-col gap-2">
<div
v-for="flag in Object.keys(flags) as FeatureFlag[]"
v-for="flag in filteredFlags"
:key="`flag-${flag}`"
class="adjacent-input small card"
class="flex flex-row flex-wrap items-center gap-2 rounded-2xl bg-bg-raised p-4"
>
<label :for="`toggle-${flag}`">
<span class="label__title">
<label :for="`toggle-${flag}`" class="flex-1">
<span class="block font-semibold capitalize">
{{ flag.replaceAll('_', ' ') }}
</span>
<span class="label__description">
<p>
Default:
<span
:style="`color:var(--color-${
DEFAULT_FEATURE_FLAGS[flag] === false ? 'red' : 'green'
})`"
>{{ DEFAULT_FEATURE_FLAGS[flag] }}</span
>
</p>
</span>
<p class="m-0 text-secondary">
Default:
<span :class="DEFAULT_FEATURE_FLAGS[flag] === false ? 'text-red' : 'text-green'">
{{ DEFAULT_FEATURE_FLAGS[flag] }}
</span>
</p>
</label>
<input
<Toggle
:id="`toggle-${flag}`"
v-model="flags[flag]"
class="switch stylized-toggle"
type="checkbox"
@change="() => saveFeatureFlags()"
@update:model-value="() => saveFeatureFlags()"
/>
</div>
<p v-if="filteredFlags.length === 0" class="text-center text-secondary">
No flags found matching "{{ searchQuery }}"
</p>
</div>
</div>
</template>
<style lang="scss" scoped>
.page {
width: calc(100% - 2 * var(--spacing-card-md));
max-width: 800px;
margin-inline: auto;
box-sizing: border-box;
margin-block: var(--spacing-card-md);
}
.flags {
}
.label__title {
text-transform: capitalize;
}
.label__description p {
margin: 0;
}
</style>

View File

@@ -242,13 +242,11 @@
>
</div>
<input
<Toggle
:id="`toggle-${getStableModKey(mod)}`"
:checked="!mod.disabled"
:model-value="!mod.disabled"
:disabled="mod.changing"
class="switch stylized-toggle"
type="checkbox"
@change="toggleMod(mod)"
@update:model-value="toggleMod(mod)"
/>
</div>
</div>
@@ -353,7 +351,13 @@ import {
TrashIcon,
WrenchIcon,
} from '@modrinth/assets'
import { Avatar, ButtonStyled, injectModrinthClient, injectNotificationManager } from '@modrinth/ui'
import {
Avatar,
ButtonStyled,
injectModrinthClient,
injectNotificationManager,
Toggle,
} from '@modrinth/ui'
import type { Mod } from '@modrinth/utils'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
@@ -699,8 +703,4 @@ const filteredMods = computed(() => {
height: 1px;
visibility: hidden;
}
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
}
</style>

View File

@@ -21,11 +21,10 @@
</div>
<span>{{ prefConfig.description }}</span>
</label>
<input
<Toggle
:id="`pref-${key}`"
v-model="newUserPreferences[key]"
class="switch stylized-toggle flex-none"
type="checkbox"
class="flex-none"
:disabled="prefConfig.implemented === false"
/>
</div>
@@ -42,7 +41,7 @@
</template>
<script setup lang="ts">
import { injectNotificationManager } from '@modrinth/ui'
import { injectNotificationManager, Toggle } from '@modrinth/ui'
import { useStorage } from '@vueuse/core'
import SaveBanner from '~/components/ui/servers/SaveBanner.vue'
@@ -117,9 +116,3 @@ const resetPreferences = () => {
newUserPreferences.value = { ...userPreferences.value }
}
</script>
<style scoped>
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
}
</style>

View File

@@ -62,11 +62,9 @@
/>
</div>
<div v-else-if="typeof property === 'boolean'" class="flex justify-end">
<input
<Toggle
:id="`server-property-${index}`"
v-model="liveProperties[index]"
class="switch stylized-toggle"
type="checkbox"
:aria-labelledby="`property-label-${index}`"
/>
</div>
@@ -134,7 +132,7 @@
<script setup lang="ts">
import { EyeIcon, SearchIcon } from '@modrinth/assets'
import { Combobox, injectModrinthClient, injectNotificationManager } from '@modrinth/ui'
import { Combobox, injectModrinthClient, injectNotificationManager, Toggle } from '@modrinth/ui'
import Fuse from 'fuse.js'
import { computed, inject, ref, watch } from 'vue'
@@ -331,9 +329,3 @@ const isComplexProperty = (property: any): boolean => {
)
}
</script>
<style scoped>
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
}
</style>

View File

@@ -69,12 +69,7 @@
</span>
</div>
<div class="flex items-center gap-2">
<input
id="show-all-versions"
v-model="showAllVersions"
class="switch stylized-toggle flex-none"
type="checkbox"
/>
<Toggle id="show-all-versions" v-model="showAllVersions" class="flex-none" />
<label for="show-all-versions" class="text-sm">Show all Java versions</label>
</div>
<Combobox
@@ -113,7 +108,7 @@
<script setup lang="ts">
import { IssuesIcon, UpdatedIcon } from '@modrinth/assets'
import { ButtonStyled, Combobox, injectNotificationManager } from '@modrinth/ui'
import { ButtonStyled, Combobox, injectNotificationManager, Toggle } from '@modrinth/ui'
import SaveBanner from '~/components/ui/servers/SaveBanner.vue'
import type { ModrinthServer } from '~/composables/servers/modrinth-servers.ts'
@@ -232,9 +227,3 @@ function resetToDefault() {
invocation.value = originalInvocation.value ?? ''
}
</script>
<style scoped>
.stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important;
}
</style>

View File

@@ -119,85 +119,78 @@
<section class="universal-card">
<h2 class="text-2xl">{{ formatMessage(toggleFeatures.title) }}</h2>
<p class="mb-4">{{ formatMessage(toggleFeatures.description) }}</p>
<div class="adjacent-input small">
<label for="advanced-rendering">
<span class="label__title">
{{ formatMessage(toggleFeatures.advancedRenderingTitle) }}
</span>
<span class="label__description">
{{ formatMessage(toggleFeatures.advancedRenderingDescription) }}
</span>
</label>
<input
id="advanced-rendering"
v-model="cosmetics.advancedRendering"
class="switch stylized-toggle"
type="checkbox"
/>
</div>
<div class="adjacent-input small">
<label for="external-links-new-tab">
<span class="label__title">
{{ formatMessage(toggleFeatures.externalLinksNewTabTitle) }}
</span>
<span class="label__description">
{{ formatMessage(toggleFeatures.externalLinksNewTabDescription) }}
</span>
</label>
<input
id="external-links-new-tab"
v-model="cosmetics.externalLinksNewTab"
class="switch stylized-toggle"
type="checkbox"
/>
</div>
<div v-if="false" class="adjacent-input small">
<label for="modrinth-app-promos">
<span class="label__title">
{{ formatMessage(toggleFeatures.hideModrinthAppPromosTitle) }}
</span>
<span class="label__description">
{{ formatMessage(toggleFeatures.hideModrinthAppPromosDescription) }}
</span>
</label>
<input
id="modrinth-app-promos"
v-model="cosmetics.hideModrinthAppPromos"
class="switch stylized-toggle"
type="checkbox"
/>
</div>
<div class="adjacent-input small">
<label for="search-layout-toggle">
<span class="label__title">
{{ formatMessage(toggleFeatures.rightAlignedFiltersSidebarTitle) }}
</span>
<span class="label__description">
{{ formatMessage(toggleFeatures.rightAlignedFiltersSidebarDescription) }}
</span>
</label>
<input
id="search-layout-toggle"
v-model="cosmetics.rightSearchLayout"
class="switch stylized-toggle"
type="checkbox"
/>
</div>
<div class="adjacent-input small">
<label for="project-layout-toggle">
<span class="label__title">
{{ formatMessage(toggleFeatures.leftAlignedContentSidebarTitle) }}
</span>
<span class="label__description">
{{ formatMessage(toggleFeatures.leftAlignedContentSidebarDescription) }}
</span>
</label>
<input
id="project-layout-toggle"
v-model="cosmetics.leftContentLayout"
class="switch stylized-toggle"
type="checkbox"
/>
<div class="flex flex-col gap-4">
<div class="flex flex-row flex-wrap items-center justify-between gap-2">
<label for="advanced-rendering" class="flex-1">
<span class="block font-semibold text-contrast">
{{ formatMessage(toggleFeatures.advancedRenderingTitle) }}
</span>
<span class="text-secondary">
{{ formatMessage(toggleFeatures.advancedRenderingDescription) }}
</span>
</label>
<Toggle id="advanced-rendering" v-model="cosmetics.advancedRendering" class="shrink-0" />
</div>
<div class="flex flex-row flex-wrap items-center justify-between gap-2">
<label for="external-links-new-tab" class="flex-1">
<span class="block font-semibold text-contrast">
{{ formatMessage(toggleFeatures.externalLinksNewTabTitle) }}
</span>
<span class="text-secondary">
{{ formatMessage(toggleFeatures.externalLinksNewTabDescription) }}
</span>
</label>
<Toggle
id="external-links-new-tab"
v-model="cosmetics.externalLinksNewTab"
class="shrink-0"
/>
</div>
<div v-if="false" class="flex flex-row flex-wrap items-center justify-between gap-2">
<label for="modrinth-app-promos" class="flex-1">
<span class="block font-semibold text-contrast">
{{ formatMessage(toggleFeatures.hideModrinthAppPromosTitle) }}
</span>
<span class="text-secondary">
{{ formatMessage(toggleFeatures.hideModrinthAppPromosDescription) }}
</span>
</label>
<Toggle
id="modrinth-app-promos"
v-model="cosmetics.hideModrinthAppPromos"
class="shrink-0"
/>
</div>
<div class="flex flex-row flex-wrap items-center justify-between gap-2">
<label for="search-layout-toggle" class="flex-1">
<span class="block font-semibold text-contrast">
{{ formatMessage(toggleFeatures.rightAlignedFiltersSidebarTitle) }}
</span>
<span class="text-secondary">
{{ formatMessage(toggleFeatures.rightAlignedFiltersSidebarDescription) }}
</span>
</label>
<Toggle
id="search-layout-toggle"
v-model="cosmetics.rightSearchLayout"
class="shrink-0"
/>
</div>
<div class="flex flex-row flex-wrap items-center justify-between gap-2">
<label for="project-layout-toggle" class="">
<span class="block font-semibold text-contrast">
{{ formatMessage(toggleFeatures.leftAlignedContentSidebarTitle) }}
</span>
<span class="text-secondary">
{{ formatMessage(toggleFeatures.leftAlignedContentSidebarDescription) }}
</span>
</label>
<Toggle
id="project-layout-toggle"
v-model="cosmetics.leftContentLayout"
class="shrink-0"
/>
</div>
</div>
</section>
</div>
@@ -212,6 +205,7 @@ import {
IntlFormatted,
normalizeChildren,
ThemeSelector,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { formatProjectType } from '@modrinth/utils'

View File

@@ -3,8 +3,6 @@
import type { FunctionalComponent, SVGAttributes } from 'vue'
export type IconComponent = FunctionalComponent<SVGAttributes>
import _AffiliateIcon from './icons/affiliate.svg?component'
import _AlignLeftIcon from './icons/align-left.svg?component'
import _ArchiveIcon from './icons/archive.svg?component'
@@ -327,6 +325,8 @@ import _XCircleIcon from './icons/x-circle.svg?component'
import _ZoomInIcon from './icons/zoom-in.svg?component'
import _ZoomOutIcon from './icons/zoom-out.svg?component'
export type IconComponent = FunctionalComponent<SVGAttributes>
export const AffiliateIcon = _AffiliateIcon
export const AlignLeftIcon = _AlignLeftIcon
export const ArchiveIcon = _ArchiveIcon

View File

@@ -63,10 +63,10 @@ import _WindowsIcon from './external/windows.svg?component'
import _YouTubeIcon from './external/youtube.svg?component'
import _YouTubeGaming from './external/youtubegaming.svg?component'
import _YouTubeShortsIcon from './external/youtubeshorts.svg?component'
import _EmptyIllustration from './illustrations/empty.svg?component'
// Tag icon helpers - import maps from generated-icons
import type { IconComponent } from './generated-icons'
import { categoryIconMap, loaderIconMap } from './generated-icons'
import _EmptyIllustration from './illustrations/empty.svg?component'
export const ModrinthIcon = _ModrinthIcon
export const BrowserWindowSuccessIllustration = _BrowserWindowSuccessIllustration

View File

@@ -70,10 +70,7 @@
:where(input) {
box-sizing: border-box;
max-height: 40px;
&:not(.stylized-toggle) {
max-width: 100%;
}
max-width: 100%;
}
:where(.adjacent-input, &.adjacent-input) {
@@ -118,10 +115,6 @@
&:not(&.small) {
flex-direction: column;
align-items: start;
.stylized-toggle {
flex-basis: 0;
}
}
}
}
@@ -766,60 +759,6 @@ a,
}
}
.switch {
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
-webkit-tap-highlight-color: transparent;
cursor: pointer;
}
.stylized-toggle {
@extend .button-base;
box-sizing: content-box;
min-height: 32px;
height: 32px;
min-width: 52px;
max-width: 52px;
border-radius: var(--radius-max);
display: inline-block;
position: relative;
margin: 0;
transition: all 0.2s ease;
background: var(--color-button-bg);
&:after {
content: '';
position: absolute;
top: 7px;
left: 7px;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--color-gray);
transition: all 0.2s cubic-bezier(0.5, 0.1, 0.75, 1.35);
outline: 2px solid transparent;
@media (prefers-reduced-motion) {
transition: none;
}
}
&:checked {
background-color: var(--color-brand);
&:after {
transform: translatex(20px);
background: var(--color-accent-contrast);
}
}
&:hover &:focus {
background: var(--color-button-bg);
}
}
// TOOLTIPS
.v-popper--theme-dropdown,

View File

@@ -1,5 +1,6 @@
.light-properties {
--surface-1: #ebebeb;
--surface-1-5: #ededed;
--surface-2: #f5f5f5;
--surface-3: #f8f8f8;
--surface-4: #ffffff;
@@ -221,6 +222,7 @@ html {
.dark,
:root[data-theme='dark'] {
--surface-1: #16181c;
--surface-1-5: #1a1c20;
--surface-2: #1d1f23;
--surface-3: #27292e;
--surface-4: #34363c;
@@ -392,6 +394,7 @@ html {
.oled-mode {
@extend .dark-mode;
--surface-1: #000000;
--surface-1-5: #050506;
--surface-2: #09090a;
--surface-3: #101013;
--surface-4: #1b1b20;

View File

@@ -6,6 +6,7 @@ const config: Config = {
colors: {
surface: {
1: 'var(--surface-1)',
1.5: 'var(--surface-1-5)',
2: 'var(--surface-2)',
3: 'var(--surface-3)',
4: 'var(--surface-4)',
@@ -224,7 +225,7 @@ const config: Config = {
hr: 'var(--color-hr)',
table: {
border: 'var(--color-table-border)',
alternateRow: ' var(--color-table-alternate-row)',
alternateRow: 'var(--color-table-alternate-row)',
},
},
backgroundImage: {

View File

@@ -1,17 +1,13 @@
import type { StorybookConfig } from '@storybook/vue3-vite'
const config: StorybookConfig = {
framework: '@storybook/vue3-vite',
core: {
builder: '@storybook/builder-vite',
framework: {
name: '@storybook/vue3-vite',
options: {
docgen: false,
},
},
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: [
'@storybook/addon-themes',
'@storybook/addon-vitest',
'@storybook/addon-a11y',
'@storybook/addon-docs',
'@storybook/addon-onboarding',
],
stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: ['@storybook/addon-themes', '@storybook/addon-a11y'],
}
export default config

View File

@@ -2,6 +2,7 @@ import '@modrinth/assets/omorphia.scss'
import 'floating-vue/dist/style.css'
import '../src/styles/tailwind.css'
import { GenericModrinthClient } from '@modrinth/api-client'
import { withThemeByClassName } from '@storybook/addon-themes'
import type { Preview } from '@storybook/vue3-vite'
import { setup } from '@storybook/vue3-vite'
@@ -17,7 +18,10 @@ import {
} from '../src/composables/i18n'
import {
AbstractWebNotificationManager,
I18N_INJECTION_KEY,
type I18nContext,
type NotificationPanelLocation,
provideModrinthClient,
provideNotificationManager,
type WebNotification,
} from '../src/providers'
@@ -77,6 +81,17 @@ class StorybookNotificationManager extends AbstractWebNotificationManager {
setup((app) => {
app.use(i18n)
// Provide the custom I18nContext for components using injectI18n()
const i18nContext: I18nContext = {
locale: i18n.global.locale,
t: (key, values) => i18n.global.t(key, values ?? {}) as string,
setLocale: (newLocale) => {
i18n.global.locale.value = newLocale
},
}
app.provide(I18N_INJECTION_KEY, i18nContext)
app.use(FloatingVue, {
themes: {
'ribbit-popout': {
@@ -100,10 +115,15 @@ setup((app) => {
}
})
// Wrapper component that provides notification manager context
const NotificationManagerProvider = defineComponent({
const StorybookProvider = defineComponent({
setup(_, { slots }) {
provideNotificationManager(new StorybookNotificationManager())
const modrinthClient = new GenericModrinthClient({
userAgent: 'modrinth-storybook/1.0.0',
})
provideModrinthClient(modrinthClient)
return () => slots.default?.()
},
})
@@ -126,14 +146,13 @@ const preview: Preview = {
},
defaultTheme: 'dark',
}),
// Wrap stories with notification manager provider
(story) => ({
components: { story, NotificationManagerProvider, NotificationPanel },
components: { story, StorybookProvider, NotificationPanel },
template: /*html*/ `
<NotificationManagerProvider>
<StorybookProvider>
<NotificationPanel />
<story />
</NotificationManagerProvider>
</StorybookProvider>
`,
}),
],

View File

@@ -27,26 +27,19 @@
"@formatjs/cli": "^6.2.12",
"@modrinth/tooling-config": "workspace:*",
"@storybook/addon-a11y": "^10.1.10",
"@storybook/addon-docs": "^10.1.10",
"@storybook/addon-onboarding": "^10.1.10",
"@storybook/addon-themes": "^10.1.10",
"@storybook/addon-vitest": "^10.1.10",
"@storybook/builder-vite": "^10.1.10",
"@storybook/vue3-vite": "^10.1.10",
"@stripe/stripe-js": "^7.3.1",
"@tailwindcss/vite": "^4.1.18",
"@vitejs/plugin-vue": "^5.2.1",
"@vitest/browser-playwright": "^4.0.16",
"@vitest/coverage-v8": "^4.0.16",
"eslint-plugin-storybook": "^10.1.10",
"playwright": "^1.57.0",
"storybook": "^10.1.10",
"stripe": "^18.1.1",
"tailwindcss": "^3.4.4",
"typescript": "^5.4.5",
"vite": "^5.4.6",
"vite-svg-loader": "^5.1.0",
"vitest": "^4.0.16",
"vue": "^3.5.13",
"vue-component-type-helpers": "^3.1.8",
"vue-router": "^4.6.0"

View File

@@ -1,6 +1,6 @@
<template>
<img
v-if="src"
v-if="src && !failed"
ref="img"
class="`experimental-styles-within avatar shrink-0"
:style="`--_size: ${cssSize}`"
@@ -14,6 +14,7 @@
:alt="alt"
:loading="loading"
@load="updatePixelated"
@error="onError"
/>
<svg
v-else
@@ -45,10 +46,11 @@
</template>
<script setup>
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
const pixelated = ref(false)
const img = ref(null)
const failed = ref(false)
const props = defineProps({
src: {
@@ -95,6 +97,18 @@ const LEGACY_PRESETS = {
const cssSize = computed(() => LEGACY_PRESETS[props.size] ?? props.size)
watch(
() => props.src,
() => {
failed.value = false
},
)
function onError(e) {
console.log('Avatar image failed to load:', props.src, e)
failed.value = true
}
function updatePixelated() {
if (img.value && img.value.naturalWidth && img.value.naturalWidth < 32) {
pixelated.value = true

View File

@@ -22,6 +22,11 @@
>
<div class="flex items-center gap-2">
<slot name="prefix"></slot>
<component
:is="selectedOption?.icon"
v-if="showIconInSelected && selectedOption?.icon"
class="h-5 w-5"
/>
<span class="text-primary font-semibold leading-tight">
<slot name="selected">{{ triggerText }}</slot>
</span>
@@ -164,6 +169,7 @@ const props = withDefaults(
searchPlaceholder?: string
listbox?: boolean
showChevron?: boolean
showIconInSelected?: boolean
maxHeight?: number
displayValue?: string
extraPosition?: 'top' | 'bottom'
@@ -179,6 +185,7 @@ const props = withDefaults(
searchPlaceholder: 'Search...',
listbox: true,
showChevron: true,
showIconInSelected: false,
maxHeight: DEFAULT_MAX_HEIGHT,
extraPosition: 'bottom',
noOptionsMessage: 'No results found',

View File

@@ -0,0 +1,172 @@
<template>
<div class="overflow-hidden rounded-2xl border border-solid border-surface-3">
<table class="w-full border-separate border-spacing-0">
<thead>
<tr class="bg-surface-3">
<th v-if="showSelection" class="w-10 pl-4">
<Checkbox
:model-value="allSelected"
:indeterminate="someSelected"
class="shrink-0 py-4"
@update:model-value="toggleSelectAll"
/>
</th>
<th
v-for="column in columns"
:key="column.key"
class="h-14 first:pl-4 last:pr-4"
:class="[
`text-${column.align ?? 'left'}`,
column.enableSorting ? 'cursor-pointer select-none' : '',
]"
:style="column.width ? { width: column.width } : undefined"
@click="column.enableSorting ? handleSort(column.key) : undefined"
>
<slot :name="`header-${column.key}`" :column="column">
<span
v-if="column.label || column.enableSorting"
class="inline-flex items-center gap-1 font-semibold"
:class="`${sortColumn === column.key ? 'text-contrast' : ''}`"
>
{{ column.label ?? '' }}
<template v-if="column.enableSorting">
<ChevronUpIcon
v-if="sortColumn === column.key && sortDirection === 'asc'"
class="size-4"
/>
<ChevronDownIcon
v-else-if="sortColumn === column.key && sortDirection === 'desc'"
class="size-4"
/>
</template>
</span>
</slot>
</th>
</tr>
</thead>
<tbody>
<tr
v-for="(row, rowIndex) in data"
:key="rowIndex"
:class="rowIndex % 2 === 0 ? 'bg-surface-2' : 'bg-surface-1.5'"
>
<td v-if="showSelection" class="w-10">
<Checkbox
:model-value="isSelected(row)"
class="shrink-0 p-4"
@update:model-value="toggleSelection(row)"
/>
</td>
<td
v-for="column in columns"
:key="column.key"
class="text-secondary h-14 first:pl-4 last:pr-4"
:class="`text-${column.align ?? 'left'}`"
:style="column.width ? { width: column.width } : undefined"
>
<slot
:name="`cell-${column.key}`"
:row="row"
:value="row[column.key]"
:column="column"
:index="rowIndex"
>
{{ row[column.key] ?? '' }}
</slot>
</td>
</tr>
</tbody>
</table>
</div>
</template>
<script
setup
lang="ts"
generic="K extends string = string, T extends Record<string, unknown> = Record<K, unknown>"
>
import { ChevronDownIcon, ChevronUpIcon } from '@modrinth/assets'
import { computed } from 'vue'
import Checkbox from './Checkbox.vue'
export type TableColumnAlign = 'left' | 'center' | 'right'
export type SortDirection = 'asc' | 'desc'
/**
* Defines a table column configuration.
* @template K - The column key is used to get cell data of row
*/
export interface TableColumn<K extends string = string> {
key: K
label?: string
align?: TableColumnAlign
enableSorting?: boolean
/**
* CSS width value for the column.
* Accepts any valid CSS width (e.g., '200px', '20%', '10rem', 'auto', 'fit-content').
*/
width?: string
}
const props = withDefaults(
defineProps<{
columns: TableColumn<K>[]
data: T[] /* Row data for table */
showSelection?: boolean
rowKey?: keyof T /* The key used to uniquely identify each row */
}>(),
{
showSelection: false,
rowKey: 'id' as keyof T,
},
)
const selectedIds = defineModel<unknown[]>('selectedIds', { default: () => [] })
const sortColumn = defineModel<string | undefined>('sortColumn')
const sortDirection = defineModel<SortDirection>('sortDirection', { default: 'asc' })
const emit = defineEmits<{
sort: [column: string, direction: SortDirection]
}>()
const allSelected = computed(
() => props.data.length > 0 && selectedIds.value.length === props.data.length,
)
const someSelected = computed(
() => selectedIds.value.length > 0 && selectedIds.value.length < props.data.length,
)
function getRowId(row: T): unknown {
return row[props.rowKey as keyof T]
}
function isSelected(row: T): boolean {
return selectedIds.value.includes(getRowId(row))
}
function toggleSelection(row: T) {
const id = getRowId(row)
if (isSelected(row)) {
selectedIds.value = selectedIds.value.filter((selectedId) => selectedId !== id)
} else {
selectedIds.value = [...selectedIds.value, id]
}
}
function toggleSelectAll(selectAll: boolean) {
if (selectAll) {
selectedIds.value = props.data.map((row) => getRowId(row))
} else {
selectedIds.value = []
}
}
function handleSort(columnKey: string) {
const newDirection: SortDirection =
sortColumn.value === columnKey && sortDirection.value === 'asc' ? 'desc' : 'asc'
sortColumn.value = columnKey
sortDirection.value = newDirection
emit('sort', columnKey, newDirection)
}
</script>

View File

@@ -1,19 +1,44 @@
<template>
<input
<button
:id="id"
type="checkbox"
class="switch stylized-toggle"
type="button"
role="switch"
:aria-checked="modelValue"
:disabled="disabled"
:checked="checked"
@change="checked = !checked"
/>
class="relative inline-flex shrink-0 rounded-full m-0 transition-all duration-200 cursor-pointer border-none"
:class="[
small ? 'h-5 !w-[38px]' : 'h-8 !w-[52px]',
modelValue ? 'bg-brand' : 'bg-button-bg',
disabled ? 'opacity-50 cursor-not-allowed' : 'btn-wrapper',
]"
@click="toggle"
>
<span
class="absolute rounded-full transition-all duration-200"
:class="[
small ? 'w-4 h-4 top-0.5 left-0.5' : 'w-[18px] h-[18px] top-[7px] left-[7px]',
modelValue
? small
? 'translate-x-[18px] bg-black/90'
: 'translate-x-5 bg-black/90'
: 'bg-gray',
]"
/>
</button>
</template>
<script setup lang="ts">
defineProps<{
const props = defineProps<{
id?: string
disabled?: boolean
small?: boolean
}>()
const checked = defineModel<boolean>()
const modelValue = defineModel<boolean>()
function toggle() {
if (!props.disabled) {
modelValue.value = !modelValue.value
}
}
</script>

View File

@@ -39,8 +39,7 @@ export { default as LoadingIndicator } from './LoadingIndicator.vue'
export { default as ManySelect } from './ManySelect.vue'
export { default as MarkdownEditor } from './MarkdownEditor.vue'
export type { MaybeCtxFn, StageButtonConfig, StageConfigInput } from './MultiStageModal.vue'
export { default as MultiStageModal } from './MultiStageModal.vue'
export { resolveCtxFn } from './MultiStageModal.vue'
export { default as MultiStageModal, resolveCtxFn } from './MultiStageModal.vue'
export { default as OptionGroup } from './OptionGroup.vue'
export type { Option as OverflowMenuOption } from './OverflowMenu.vue'
export { default as OverflowMenu } from './OverflowMenu.vue'
@@ -59,6 +58,8 @@ export { default as SettingsLabel } from './SettingsLabel.vue'
export { default as SimpleBadge } from './SimpleBadge.vue'
export { default as Slider } from './Slider.vue'
export { default as SmartClickable } from './SmartClickable.vue'
export type { TableColumn } from './Table.vue'
export { default as Table } from './Table.vue'
export { default as TagItem } from './TagItem.vue'
export { default as Timeline } from './Timeline.vue'
export { default as Toggle } from './Toggle.vue'

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { PlusIcon, XIcon } from '@modrinth/assets'
import type Stripe from 'stripe'
import { nextTick, ref, useTemplateRef } from 'vue'
import { defineMessages, useVIntl } from '../../composables/i18n'

View File

@@ -5,6 +5,7 @@ export * from './brand'
export * from './changelog'
export * from './chart'
export * from './content'
export * from './instances'
export * from './modal'
export * from './nav'
export * from './page'

View File

@@ -0,0 +1,198 @@
<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>

View File

@@ -0,0 +1,292 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useVIntl } from '../../composables/i18n'
import { commonMessages } from '../../utils/common-messages'
import Checkbox from '../base/Checkbox.vue'
import ContentCardItem from './ContentCardItem.vue'
import type { ContentCardTableItem } from './types'
const { formatMessage } = useVIntl()
const BUFFER_SIZE = 5
interface Props {
items: ContentCardTableItem[]
showSelection?: boolean
virtualized?: boolean
}
const props = withDefaults(defineProps<Props>(), {
showSelection: false,
virtualized: true,
})
const selectedIds = defineModel<string[]>('selectedIds', { default: () => [] })
const emit = defineEmits<{
'update:enabled': [id: string, value: boolean]
delete: [id: string]
update: [id: string]
}>()
// Virtualization state
const listContainer = ref<HTMLElement | null>(null)
const scrollContainer = ref<HTMLElement | Window | null>(null)
const scrollTop = ref(0)
const viewportHeight = ref(0)
const itemHeight = 74
const totalHeight = computed(() => props.items.length * itemHeight)
// Find the nearest scrollable ancestor
function findScrollableAncestor(element: HTMLElement | null): HTMLElement | Window {
if (!element) return window
let current: HTMLElement | null = element.parentElement
while (current) {
const style = getComputedStyle(current)
const overflowY = style.overflowY
const isScrollable =
(overflowY === 'auto' || overflowY === 'scroll') &&
current.scrollHeight > current.clientHeight
if (isScrollable) {
return current
}
current = current.parentElement
}
return window
}
function getScrollTop(container: HTMLElement | Window): number {
if (container instanceof Window) {
return window.scrollY
}
return container.scrollTop
}
function getViewportHeight(container: HTMLElement | Window): number {
if (container instanceof Window) {
return window.innerHeight
}
return container.clientHeight
}
function getContainerOffset(listEl: HTMLElement, container: HTMLElement | Window): number {
if (container instanceof Window) {
return listEl.getBoundingClientRect().top + window.scrollY
}
// For element containers, get the offset relative to the scroll container
const listRect = listEl.getBoundingClientRect()
const containerRect = container.getBoundingClientRect()
return listRect.top - containerRect.top + container.scrollTop
}
const visibleRange = computed(() => {
if (!props.virtualized) {
return { start: 0, end: props.items.length }
}
if (!listContainer.value || !scrollContainer.value) return { start: 0, end: 0 }
const containerOffset = getContainerOffset(listContainer.value, scrollContainer.value)
const relativeScrollTop = Math.max(0, scrollTop.value - containerOffset)
const start = Math.floor(relativeScrollTop / itemHeight)
const visibleCount = Math.ceil(viewportHeight.value / itemHeight)
return {
start: Math.max(0, start - BUFFER_SIZE),
end: Math.min(props.items.length, start + visibleCount + BUFFER_SIZE * 2),
}
})
const visibleTop = computed(() => (props.virtualized ? visibleRange.value.start * itemHeight : 0))
const visibleItems = computed(() =>
props.items.slice(visibleRange.value.start, visibleRange.value.end),
)
// Expose for perf monitoring
defineExpose({
visibleRange,
visibleItems,
})
function handleScroll() {
if (scrollContainer.value) {
scrollTop.value = getScrollTop(scrollContainer.value)
}
}
function handleResize() {
if (scrollContainer.value) {
viewportHeight.value = getViewportHeight(scrollContainer.value)
}
}
onMounted(() => {
scrollContainer.value = findScrollableAncestor(listContainer.value)
viewportHeight.value = getViewportHeight(scrollContainer.value)
scrollTop.value = getScrollTop(scrollContainer.value)
scrollContainer.value.addEventListener('scroll', handleScroll, { passive: true })
window.addEventListener('resize', handleResize, { passive: true })
})
onUnmounted(() => {
if (scrollContainer.value) {
scrollContainer.value.removeEventListener('scroll', handleScroll)
}
window.removeEventListener('resize', handleResize)
})
// Selection logic
const allSelected = computed(() => {
if (props.items.length === 0) return false
return props.items.every((item) => selectedIds.value.includes(item.id))
})
const someSelected = computed(() => {
return props.items.some((item) => selectedIds.value.includes(item.id)) && !allSelected.value
})
function toggleSelectAll() {
if (allSelected.value) {
selectedIds.value = []
} else {
selectedIds.value = props.items.map((item) => item.id)
}
}
function toggleItemSelection(itemId: string, selected: boolean) {
if (selected) {
if (!selectedIds.value.includes(itemId)) {
selectedIds.value = [...selectedIds.value, itemId]
}
} else {
selectedIds.value = selectedIds.value.filter((id) => id !== itemId)
}
}
function isItemSelected(itemId: string): boolean {
return selectedIds.value.includes(itemId)
}
</script>
<template>
<div class="overflow-hidden rounded-[20px] border border-solid border-surface-3">
<div
class="grid h-12 items-center gap-4 bg-surface-3 px-4"
:class="
showSelection
? '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="showSelection"
:model-value="allSelected"
:indeterminate="someSelected"
class="shrink-0"
@update:model-value="toggleSelectAll"
/>
<span class="font-semibold text-contrast">
{{ formatMessage(commonMessages.projectLabel) }}
</span>
<span class="hidden font-semibold text-secondary md:block">
{{ formatMessage(commonMessages.versionLabel) }}
</span>
<div class="text-right">
<span class="font-semibold text-secondary">{{
formatMessage(commonMessages.actionsLabel)
}}</span>
</div>
</div>
<div
v-if="items.length > 0 && virtualized"
ref="listContainer"
class="relative w-full rounded-b-[20px]"
:style="{ minHeight: `${totalHeight}px` }"
>
<div class="absolute w-full" :style="{ top: `${visibleTop}px` }">
<ContentCardItem
v-for="(item, idx) in visibleItems"
:key="item.id"
data-content-card-item
:project="item.project"
:project-link="item.projectLink"
:version="item.version"
:owner="item.owner"
:enabled="item.enabled"
:has-update="item.hasUpdate"
:overflow-options="item.overflowOptions"
:disabled="item.disabled"
:show-checkbox="showSelection"
:selected="isItemSelected(item.id)"
:class="[
(visibleRange.start + idx) % 2 === 1 ? 'bg-surface-1.5' : 'bg-surface-2',
'border-t border-solid border-[1px] border-surface-3',
visibleRange.start + idx === items.length - 1 ? 'rounded-b-[20px] !border-none' : '',
]"
@update:selected="(val) => toggleItemSelection(item.id, val ?? false)"
@update:enabled="(val) => emit('update:enabled', item.id, val)"
@delete="emit('delete', item.id)"
@update="emit('update', item.id)"
>
<template #additionalButtonsLeft>
<slot name="itemButtonsLeft" :item="item" :index="visibleRange.start + idx" />
</template>
<template #additionalButtonsRight>
<slot name="itemButtonsRight" :item="item" :index="visibleRange.start + idx" />
</template>
</ContentCardItem>
</div>
</div>
<div v-else-if="items.length > 0" ref="listContainer" class="rounded-b-[20px]">
<ContentCardItem
v-for="(item, index) in items"
:key="item.id"
data-content-card-item
:project="item.project"
:project-link="item.projectLink"
:version="item.version"
:owner="item.owner"
:enabled="item.enabled"
:has-update="item.hasUpdate"
:overflow-options="item.overflowOptions"
:disabled="item.disabled"
:show-checkbox="showSelection"
:selected="isItemSelected(item.id)"
:class="[
index % 2 === 1 ? 'bg-surface-1.5' : 'bg-surface-2',
'border-t border-solid border-surface-3',
index === items.length - 1 ? 'rounded-b-[20px] !border-none' : '',
]"
@update:selected="(val) => toggleItemSelection(item.id, val ?? false)"
@update:enabled="(val) => emit('update:enabled', item.id, val)"
@delete="emit('delete', item.id)"
@update="emit('update', item.id)"
>
<template #additionalButtonsLeft>
<slot name="itemButtonsLeft" :item="item" :index="index" />
</template>
<template #additionalButtonsRight>
<slot name="itemButtonsRight" :item="item" :index="index" />
</template>
</ContentCardItem>
</div>
<div v-else class="flex items-center justify-center rounded-b-[20px] py-12">
<slot name="empty">
<span class="text-secondary">{{ formatMessage(commonMessages.noItemsLabel) }}</span>
</slot>
</div>
</div>
</template>

View File

@@ -0,0 +1,183 @@
<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>

View File

@@ -0,0 +1,18 @@
export { default as ContentCardItem } from './ContentCardItem.vue'
export { default as ContentCardTable } from './ContentCardTable.vue'
/**
* @deprecated Use `ContentCardTable` with `ContentCardItem` instead.
* This alias is kept for backwards compatibility and will be removed in a future version.
*/
export { default as ContentCard } from './ContentCardItem.vue'
export { default as ContentModpackCard } from './ContentModpackCard.vue'
// export { default as ContentUpdaterModal } from './modals/ContentUpdaterModal.vue'
export type {
ContentCardProject,
ContentCardTableItem,
ContentCardVersion,
ContentModpackCardCategory,
ContentModpackCardProject,
ContentModpackCardVersion,
ContentOwner,
} from './types'

View File

@@ -0,0 +1,392 @@
<template>
<NewModal ref="modal" :max-width="'90vw'" :width="'90vw'" no-padding>
<template #title>
<Avatar v-if="projectIconUrl" :src="projectIconUrl" size="3rem" :tint-by="projectName" />
<span class="text-lg font-extrabold text-contrast">{{
header ?? formatMessage(messages.updateVersionHeader)
}}</span>
</template>
<div class="flex h-[550px] border-solid border-transparent border-[1px] border-b-surface-4">
<div class="w-[300px] flex flex-col relative">
<div class="p-4 pb-2">
<div class="iconified-input w-full border-solid border-[1px] border-surface-4 rounded-xl">
<SearchIcon class="transition-colors" />
<input
v-model="searchQuery"
type="text"
:placeholder="formatMessage(messages.searchVersionPlaceholder)"
class="!bg-transparent rounded-xl transition-colors"
/>
</div>
</div>
<div class="flex-1 overflow-y-auto px-4 pb-16">
<div class="flex flex-col gap-1.5">
<button
v-for="version in filteredVersions"
:key="version.id"
class="flex items-center h-10 px-4 py-2.5 rounded-xl border-none cursor-pointer transition-colors"
:class="[
selectedVersion?.id === version.id
? 'bg-brand-highlight'
: 'bg-transparent hover:bg-button-bg',
]"
@click="selectedVersion = version"
>
<div class="flex items-center justify-between w-full">
<span
v-tooltip="'v' + version.version_number"
class="font-semibold text-contrast truncate"
>
v{{ version.version_number }}
</span>
<span
class="px-2.5 py-0.5 rounded-full text-sm font-medium flex items-center flex-shrink-0 border border-solid"
:class="getBadgeClasses(version)"
>
{{ getBadgeLabel(version) }}
</span>
</div>
</button>
</div>
<div v-if="filteredVersions.length === 0" class="p-4 text-center text-secondary text-sm">
{{ formatMessage(messages.noVersionsFound) }}
</div>
</div>
<div class="absolute bottom-0 left-0 right-0 pointer-events-none">
<div class="h-14 bg-gradient-to-t from-bg-raised to-transparent" />
<div class="bg-bg-raised pb-5 flex justify-center pointer-events-auto">
<ButtonStyled type="transparent" :circular="true">
<button
class="flex items-center gap-1.5"
@click="hideIncompatibleState = !hideIncompatibleState"
>
<EyeIcon v-if="hideIncompatibleState" class="h-6 w-6" />
<EyeOffIcon v-else class="h-6 w-6" />
<span class="font-medium">{{
hideIncompatibleState
? formatMessage(messages.showIncompatible)
: formatMessage(messages.hideIncompatible)
}}</span>
</button>
</ButtonStyled>
</div>
</div>
</div>
<div class="w-px bg-divider" />
<div class="flex-1 flex flex-col min-w-0 relative">
<template v-if="selectedVersion">
<div class="bg-bg p-4">
<div class="flex flex-col gap-1.5">
<div class="flex items-center justify-between">
<div class="flex items-center gap-1.5">
<span class="font-semibold text-xl text-contrast">
v{{ selectedVersion.version_number }}
</span>
<span
class="px-2.5 py-0.5 rounded-full text-sm font-medium flex items-center flex-shrink-0 border border-solid"
:class="getBadgeClasses(selectedVersion)"
>
{{ getBadgeLabel(selectedVersion) }}
</span>
</div>
<span class="font-medium text-primary">
{{ formatLongDate(selectedVersion.date_published) }}
</span>
</div>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2 rounded-xl">
<FileTextIcon class="h-6 w-6 text-primary" />
<span class="font-medium text-primary">{{
formatMessage(commonMessages.changelogLabel)
}}</span>
</div>
<span class="w-1.5 h-1.5 rounded-full bg-divider" />
<span class="font-medium text-primary">
{{ formatLoaderGameVersion(selectedVersion) }}
</span>
</div>
</div>
</div>
<div class="h-px bg-divider" />
<div class="flex-1 bg-bg p-4 overflow-y-auto">
<div
v-if="selectedVersion.changelog"
class="markdown"
v-html="renderHighlightedString(selectedVersion.changelog)"
/>
<div v-else class="text-secondary italic">
{{ formatMessage(messages.noChangelog) }}
</div>
</div>
<div
class="absolute bottom-0 left-0 right-0 h-14 bg-gradient-to-t from-bg to-transparent pointer-events-none"
/>
</template>
<div v-else class="flex-1 flex items-center justify-center text-secondary bg-bg">
{{ formatMessage(messages.selectVersionPrompt) }}
</div>
</div>
</div>
<div
class="bg-highlight-orange h-9 text-orange p-2 border-solid border-x-0 border-[1px] flex flex-row gap-2"
>
<TriangleAlertIcon class="size-4" />
<span>{{
formatMessage(isApp ? messages.updateWarningApp : messages.updateWarningWeb)
}}</span>
</div>
<div class="w-full flex flex-row gap-2 justify-end p-4">
<ButtonStyled type="outlined">
<button class="!border-[1px] !border-surface-4" @click="handleCancel">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button
:disabled="!selectedVersion || selectedVersion.id === currentVersionId"
@click="handleUpdate"
>
<DownloadIcon />
{{
formatMessage(isDowngrade ? messages.downgradeToVersion : messages.updateToVersion, {
version: selectedVersion?.version_number ?? '...',
})
}}
</button>
</ButtonStyled>
</div>
</NewModal>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
DownloadIcon,
EyeIcon,
EyeOffIcon,
FileTextIcon,
SearchIcon,
TriangleAlertIcon,
XIcon,
} from '@modrinth/assets'
import { capitalizeString, renderHighlightedString } from '@modrinth/utils'
import { computed, ref } from 'vue'
import { defineMessages, useVIntl } from '../../../composables/i18n'
import { commonMessages } from '../../../utils/common-messages'
import Avatar from '../../base/Avatar.vue'
import ButtonStyled from '../../base/ButtonStyled.vue'
import NewModal from '../../modal/NewModal.vue'
const { formatMessage } = useVIntl()
const messages = defineMessages({
updateVersionHeader: {
id: 'instances.updater-modal.header',
defaultMessage: 'Update version',
},
searchVersionPlaceholder: {
id: 'instances.updater-modal.search-placeholder',
defaultMessage: 'Search version...',
},
noVersionsFound: {
id: 'instances.updater-modal.no-versions',
defaultMessage: 'No versions found',
},
showIncompatible: {
id: 'instances.updater-modal.show-incompatible',
defaultMessage: 'Show incompatible',
},
hideIncompatible: {
id: 'instances.updater-modal.hide-incompatible',
defaultMessage: 'Hide incompatible',
},
noChangelog: {
id: 'instances.updater-modal.no-changelog',
defaultMessage: 'No changelog provided for this version.',
},
selectVersionPrompt: {
id: 'instances.updater-modal.select-version',
defaultMessage: 'Select a version to view its changelog',
},
updateWarningApp: {
id: 'instances.updater-modal.warning.app',
defaultMessage:
"We can't guarantee updates are safe for your instance. Review the changelog for all intermediate versions and consider a backup.",
},
updateWarningWeb: {
id: 'instances.updater-modal.warning.web',
defaultMessage:
"We can't guarantee updates are safe for your worlds. Review the changelog for all intermediate versions and consider a backup.",
},
downgradeToVersion: {
id: 'instances.updater-modal.downgrade-to',
defaultMessage: 'Downgrade to v{version}',
},
updateToVersion: {
id: 'instances.updater-modal.update-to',
defaultMessage: 'Update to v{version}',
},
currentBadge: {
id: 'instances.updater-modal.badge.current',
defaultMessage: 'Current',
},
incompatibleBadge: {
id: 'instances.updater-modal.badge.incompatible',
defaultMessage: 'Incompatible',
},
})
const props = withDefaults(
defineProps<{
versions: Labrinth.Versions.v2.Version[]
currentGameVersion: string
currentLoader: string
currentVersionId: string
isApp: boolean
projectIconUrl?: string
projectName?: string
header?: string
}>(),
{
projectIconUrl: undefined,
projectName: undefined,
header: undefined,
},
)
const emit = defineEmits<{
update: [version: Labrinth.Versions.v2.Version]
cancel: []
}>()
const modal = ref<InstanceType<typeof NewModal>>()
const searchQuery = ref('')
const hideIncompatibleState = ref(true)
const selectedVersion = ref<Labrinth.Versions.v2.Version | null>(null)
function isVersionCompatible(version: Labrinth.Versions.v2.Version): boolean {
const hasGameVersion = version.game_versions.includes(props.currentGameVersion)
const hasLoader = version.loaders.some(
(loader) => loader.toLowerCase() === props.currentLoader.toLowerCase(),
)
return hasGameVersion && hasLoader
}
const currentVersion = computed(() => props.versions.find((v) => v.id === props.currentVersionId))
const isDowngrade = computed(() => {
if (!selectedVersion.value || !currentVersion.value) return false
return (
new Date(selectedVersion.value.date_published) < new Date(currentVersion.value.date_published)
)
})
const filteredVersions = computed(() => {
let versions = [...props.versions]
// Filter by search query
if (searchQuery.value) {
const query = searchQuery.value.toLowerCase()
versions = versions.filter(
(v) => v.name.toLowerCase().includes(query) || v.version_number.toLowerCase().includes(query),
)
}
// Filter by compatibility
if (hideIncompatibleState.value) {
versions = versions.filter(isVersionCompatible)
}
return versions
})
function getBadgeLabel(version: Labrinth.Versions.v2.Version): string {
if (version.id === props.currentVersionId) return formatMessage(messages.currentBadge)
if (!isVersionCompatible(version)) return formatMessage(messages.incompatibleBadge)
return capitalizeString(version.version_type)
}
function getBadgeClasses(version: Labrinth.Versions.v2.Version): string {
// Current badge
if (version.id === props.currentVersionId) {
return 'bg-surface-4 border-surface-5 text-primary'
}
// Incompatible badge (takes precedence over version type)
if (!isVersionCompatible(version)) {
return 'bg-highlight-orange border-brand-orange text-brand-orange'
}
// Version type badges
switch (version.version_type) {
case 'release':
return 'bg-highlight-green border-brand text-brand'
case 'beta':
return 'bg-highlight-blue border-brand-blue text-brand-blue'
case 'alpha':
return 'bg-highlight-purple border-brand-purple text-brand-purple'
default:
return 'bg-surface-4 border-surface-5 text-primary'
}
}
function formatLongDate(dateString: string): string {
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
function formatLoaderGameVersion(version: Labrinth.Versions.v2.Version): string {
const loader = capitalizeString(version.loaders[0] || '')
const gameVersion = version.game_versions[0] || ''
return `${loader} ${gameVersion}`
}
function handleUpdate() {
if (selectedVersion.value) {
emit('update', selectedVersion.value)
hide()
}
}
function handleCancel() {
emit('cancel')
hide()
}
function show(initialVersionId?: string) {
searchQuery.value = ''
hideIncompatibleState.value = true
// Pre-select a version
if (initialVersionId) {
selectedVersion.value = props.versions.find((v) => v.id === initialVersionId) ?? null
} else if (props.versions.length > 0) {
// Default to first version if none specified
selectedVersion.value = props.versions[0]
} else {
selectedVersion.value = null
}
modal.value?.show()
}
function hide() {
modal.value?.hide()
}
defineExpose({ show, hide })
</script>

View File

@@ -0,0 +1,47 @@
import type { Labrinth } from '@modrinth/api-client'
import type { RouteLocationRaw } from 'vue-router'
import type { Option as OverflowMenuOption } from '../base/OverflowMenu.vue'
export type ContentCardProject = Pick<
Labrinth.Projects.v2.Project,
'id' | 'slug' | 'title' | 'icon_url'
>
export type ContentCardVersion = Pick<Labrinth.Versions.v2.Version, 'id' | 'version_number'> & {
file_name: string
}
export interface ContentOwner {
id: string
name: string
avatar_url?: string
type: 'user' | 'organization'
link?: string | RouteLocationRaw
}
export interface ContentCardTableItem {
id: string
project: ContentCardProject
projectLink?: string | RouteLocationRaw
version?: ContentCardVersion
owner?: ContentOwner
enabled?: boolean
disabled?: boolean
hasUpdate?: boolean
overflowOptions?: OverflowMenuOption[]
}
export type ContentModpackCardProject = Pick<
Labrinth.Projects.v2.Project,
'id' | 'slug' | 'title' | 'icon_url' | 'description' | 'downloads' | 'followers'
>
export type ContentModpackCardVersion = Pick<
Labrinth.Versions.v2.Version,
'id' | 'version_number' | 'date_published'
>
export type ContentModpackCardCategory = Labrinth.Tags.v2.Category & {
action?: (event: MouseEvent) => void
}

View File

@@ -78,7 +78,7 @@ const props = defineProps({
},
proceedIcon: {
type: Object,
default: TrashIcon,
default: () => TrashIcon,
},
proceedLabel: {
type: String,

View File

@@ -76,10 +76,10 @@
<div
ref="scrollContainer"
:class="[
'overflow-y-auto p-6 !pb-1 sm:pb-6',
{ 'pt-12': props.mergeHeader && closable },
props.noPadding ? '' : 'overflow-y-auto p-6 !pb-1 sm:pb-6',
{ 'pt-12': props.mergeHeader && closable && !props.noPadding },
]"
:style="{ maxHeight: maxContentHeight }"
:style="props.noPadding ? {} : { maxHeight: maxContentHeight }"
@scroll="checkScrollState"
>
<slot> You just lost the game.</slot>
@@ -100,11 +100,17 @@
</Transition>
</div>
<div v-else :class="['overflow-y-auto p-6', { 'pt-12': props.mergeHeader && closable }]">
<div
v-else
:class="[
props.noPadding ? '' : 'overflow-y-auto p-6',
{ 'pt-12': props.mergeHeader && closable && !props.noPadding },
]"
>
<slot> You just lost the game.</slot>
</div>
<div v-if="$slots.actions" class="p-6 pt-0">
<div v-if="$slots.actions" class="p-4">
<slot name="actions" />
</div>
</div>
@@ -137,6 +143,8 @@ const props = withDefaults(
mergeHeader?: boolean
scrollable?: boolean
maxContentHeight?: string
/** Removes padding from the content area. Useful for edge-to-edge layouts. */
noPadding?: boolean
/** Max width for the modal (e.g., '460px', '600px'). Defaults to '60rem'. */
maxWidth?: string
/** Width for the modal body (e.g., '460px', '600px'). */
@@ -160,6 +168,7 @@ const props = withDefaults(
// TODO: migrate all modals to use scrollable and remove this prop
scrollable: false,
maxContentHeight: '70vh',
noPadding: false,
maxWidth: undefined,
width: undefined,
disableClose: false,

View File

@@ -0,0 +1,183 @@
<template>
<Combobox
v-model="projectId"
:placeholder="placeholder"
:options="options"
:searchable="true"
:search-placeholder="searchPlaceholder"
:no-options-message="searchLoading ? loadingMessage : noResultsMessage"
:disable-search-filter="true"
:disabled="disabled"
show-icon-in-selected
@search-input="(query) => handleSearch(query)"
/>
</template>
<script lang="ts" setup>
import { useDebounceFn } from '@vueuse/core'
import { defineAsyncComponent, h, ref, watch } from 'vue'
import { injectModrinthClient, injectNotificationManager } from '../../providers'
import type { ComboboxOption } from '../base/Combobox.vue'
import Combobox from '../base/Combobox.vue'
export type ProjectType =
| 'mod'
| 'modpack'
| 'resourcepack'
| 'shader'
| 'datapack'
| 'plugin'
| 'server'
interface SearchHit {
project_id: string
title: string
icon_url?: string
project_type: string
slug: string
}
const props = withDefaults(
defineProps<{
/** Filter by project types */
projectTypes?: ProjectType[]
/** Placeholder text for the combobox */
placeholder?: string
/** Placeholder text for the search input */
searchPlaceholder?: string
/** Message shown when loading */
loadingMessage?: string
/** Message shown when no results found */
noResultsMessage?: string
/** Whether the combobox is disabled */
disabled?: boolean
/** Maximum number of results to show */
limit?: number
}>(),
{
placeholder: 'Select project',
searchPlaceholder: 'Search by name or paste ID...',
loadingMessage: 'Loading...',
noResultsMessage: 'No results found',
disabled: false,
limit: 20,
},
)
const { addNotification } = injectNotificationManager()
const projectId = defineModel<string>()
const searchLoading = ref(false)
const options = ref<ComboboxOption<string>[]>([])
const selectedProject = ref<SearchHit | null>(null)
const searchResultsCache = ref<Map<string, SearchHit>>(new Map())
const { labrinth } = injectModrinthClient()
// Watch for external changes to projectId to update selectedProject
watch(
projectId,
async (newId) => {
if (!newId) {
selectedProject.value = null
return
}
if (searchResultsCache.value.has(newId)) {
selectedProject.value = searchResultsCache.value.get(newId) || null
return
}
try {
const project = await labrinth.projects_v2.get(newId)
if (project) {
const hit: SearchHit = {
project_id: project.id,
title: project.title,
icon_url: project.icon_url ?? undefined,
project_type: project.project_type,
slug: project.slug,
}
searchResultsCache.value.set(project.id, hit)
selectedProject.value = hit
}
} catch {
selectedProject.value = null
}
},
{ immediate: true },
)
const search = async (query: string) => {
query = query.trim()
if (!query) {
searchLoading.value = false
options.value = []
return
}
try {
const projectTypeFacets = props.projectTypes?.map((type) => `project_type:${type}`)
const results = await labrinth.projects_v2.search({
query: query,
limit: props.limit,
facets: projectTypeFacets ? [projectTypeFacets] : undefined,
})
const resultsByProjectId = await labrinth.projects_v2.search({
query: '',
limit: props.limit,
facets: [[`project_id:${query.replace(/[^a-zA-Z0-9]/g, '')}`]],
})
const allHits = [...resultsByProjectId.hits, ...results.hits]
const seenIds = new Set<string>()
const uniqueHits: SearchHit[] = []
for (const hit of allHits) {
if (!seenIds.has(hit.project_id)) {
seenIds.add(hit.project_id)
uniqueHits.push(hit)
// Cache the hit for later lookup
searchResultsCache.value.set(hit.project_id, hit)
}
}
options.value = uniqueHits.map((hit) => ({
label: hit.title,
value: hit.project_id,
icon: defineAsyncComponent(() =>
Promise.resolve({
setup: () => () =>
h('img', {
src: hit.icon_url,
alt: hit.title,
class: 'h-5 w-5 rounded',
}),
}),
),
}))
} catch (error: unknown) {
const err = error as { data?: { description?: string } }
addNotification({
title: 'An error occurred',
text: err.data ? err.data.description : String(error),
type: 'error',
})
}
searchLoading.value = false
}
const throttledSearch = useDebounceFn(search, 250)
const handleSearch = async (query: string) => {
searchLoading.value = true
await throttledSearch(query)
}
defineExpose({
selectedProject,
})
</script>

View File

@@ -95,7 +95,7 @@ interface Option {
}
type Divider = {
divider: true
divider?: boolean
shown?: boolean
}

View File

@@ -146,6 +146,9 @@
"button.unfollow": {
"defaultMessage": "Unfollow"
},
"button.update": {
"defaultMessage": "Update"
},
"button.upload-image": {
"defaultMessage": "Upload image"
},
@@ -299,9 +302,57 @@
"instance.worlds.game_mode.unknown": {
"defaultMessage": "Unknown game mode"
},
"instances.modpack-card.unlink": {
"defaultMessage": "Unlink modpack"
},
"instances.updater-modal.badge.current": {
"defaultMessage": "Current"
},
"instances.updater-modal.badge.incompatible": {
"defaultMessage": "Incompatible"
},
"instances.updater-modal.downgrade-to": {
"defaultMessage": "Downgrade to v{version}"
},
"instances.updater-modal.header": {
"defaultMessage": "Update version"
},
"instances.updater-modal.hide-incompatible": {
"defaultMessage": "Hide incompatible"
},
"instances.updater-modal.no-changelog": {
"defaultMessage": "No changelog provided for this version."
},
"instances.updater-modal.no-versions": {
"defaultMessage": "No versions found"
},
"instances.updater-modal.search-placeholder": {
"defaultMessage": "Search version..."
},
"instances.updater-modal.select-version": {
"defaultMessage": "Select a version to view its changelog"
},
"instances.updater-modal.show-incompatible": {
"defaultMessage": "Show incompatible"
},
"instances.updater-modal.update-to": {
"defaultMessage": "Update to v{version}"
},
"instances.updater-modal.warning.app": {
"defaultMessage": "We can't guarantee updates are safe for your instance. Review the changelog for all intermediate versions and consider a backup."
},
"instances.updater-modal.warning.web": {
"defaultMessage": "We can't guarantee updates are safe for your worlds. Review the changelog for all intermediate versions and consider a backup."
},
"label.actions": {
"defaultMessage": "Actions"
},
"label.available": {
"defaultMessage": "{amount} available."
},
"label.changelog": {
"defaultMessage": "Changelog"
},
"label.changes-saved": {
"defaultMessage": "Changes saved"
},
@@ -311,6 +362,9 @@
"label.confirm-password": {
"defaultMessage": "Confirm password"
},
"label.content": {
"defaultMessage": "Content"
},
"label.created-ago": {
"defaultMessage": "Created {ago}"
},
@@ -350,6 +404,9 @@
"label.no": {
"defaultMessage": "No"
},
"label.no-items": {
"defaultMessage": "No items"
},
"label.notifications": {
"defaultMessage": "Notifications"
},
@@ -362,6 +419,9 @@
"label.played": {
"defaultMessage": "Played {time}"
},
"label.project": {
"defaultMessage": "Project"
},
"label.public": {
"defaultMessage": "Public"
},
@@ -404,9 +464,15 @@
"label.unlisted": {
"defaultMessage": "Unlisted"
},
"label.update-available": {
"defaultMessage": "Update available"
},
"label.username": {
"defaultMessage": "Username"
},
"label.version": {
"defaultMessage": "Version"
},
"label.visibility": {
"defaultMessage": "Visibility"
},

View File

@@ -1,3 +1,11 @@
import {
DownloadIcon,
HeartIcon,
SettingsIcon,
ShareIcon,
TrashIcon,
UserIcon,
} from '@modrinth/assets'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import Combobox from '../../components/base/Combobox.vue'
@@ -44,3 +52,61 @@ export const Disabled: Story = {
disabled: true,
},
}
export const IconSlot: Story = {
args: {
options: [
{ value: 'download', label: 'Download', icon: DownloadIcon },
{ value: 'share', label: 'Share', icon: ShareIcon },
{ value: 'favorite', label: 'Add to favorites', icon: HeartIcon },
{ type: 'divider' },
{ value: 'settings', label: 'Settings', icon: SettingsIcon },
{ value: 'profile', label: 'Profile', icon: UserIcon },
{ type: 'divider' },
{ value: 'delete', label: 'Delete', icon: TrashIcon, disabled: true },
],
placeholder: 'Select an action',
listbox: false,
},
}
export const IconSlotSearchable: Story = {
args: {
options: [
{ value: 'download', label: 'Download', icon: DownloadIcon },
{ value: 'share', label: 'Share', icon: ShareIcon },
{ value: 'favorite', label: 'Add to favorites', icon: HeartIcon },
{ value: 'settings', label: 'Settings', icon: SettingsIcon },
{ value: 'profile', label: 'Profile', icon: UserIcon },
{ value: 'delete', label: 'Delete', icon: TrashIcon },
],
placeholder: 'Select an action',
searchable: true,
searchPlaceholder: 'Search actions...',
},
}
export const WithSelectedOption: Story = {
args: {
options: [
{ value: '1', label: 'Option 1' },
{ value: '2', label: 'Option 2' },
{ value: '3', label: 'Option 3' },
],
modelValue: '2',
},
}
export const WithSelectedOptionAndIcon: Story = {
args: {
options: [
{ value: 'download', label: 'Download', icon: DownloadIcon },
{ value: 'share', label: 'Share', icon: ShareIcon },
{ value: 'favorite', label: 'Add to favorites', icon: HeartIcon },
{ value: 'settings', label: 'Settings', icon: SettingsIcon },
{ value: 'profile', label: 'Profile', icon: UserIcon },
],
modelValue: 'favorite',
showIconInSelected: true,
},
}

View File

@@ -0,0 +1,455 @@
import { EditIcon, MoreVerticalIcon, TrashIcon } from '@modrinth/assets'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import Badge from '../../components/base/Badge.vue'
import ButtonStyled from '../../components/base/ButtonStyled.vue'
import OverflowMenu from '../../components/base/OverflowMenu.vue'
import Table from '../../components/base/Table.vue'
interface User {
id: string
name: string
email: string
status: 'active' | 'inactive' | 'pending'
role: string
}
const sampleUsers: User[] = [
{ id: '1', name: 'John Doe', email: 'john@example.com', status: 'active', role: 'Admin' },
{ id: '2', name: 'Jane Smith', email: 'jane@example.com', status: 'inactive', role: 'User' },
{ id: '3', name: 'Bob Johnson', email: 'bob@example.com', status: 'pending', role: 'Editor' },
{ id: '4', name: 'Alice Brown', email: 'alice@example.com', status: 'active', role: 'User' },
{
id: '5',
name: 'Charlie Wilson',
email: 'charlie@example.com',
status: 'active',
role: 'Admin',
},
]
const meta = {
title: 'Base/Table',
// @ts-ignore - Generic component
component: Table,
} satisfies Meta<typeof Table>
export default meta
export const Default: StoryObj = {
args: {},
render: () => ({
components: { Table },
setup() {
const columns = [
{ key: 'name', label: 'Name' },
{ key: 'email', label: 'Email' },
{ key: 'status', label: 'Status' },
{ key: 'role', label: 'Role' },
]
const data = sampleUsers
return { columns, data }
},
template: /* html */ `
<Table :columns="columns" :data="data" />
`,
}),
}
export const WithSelection: StoryObj = {
args: {},
render: () => ({
components: { Table },
setup() {
const columns = [
{ key: 'name', label: 'Name' },
{ key: 'email', label: 'Email' },
{ key: 'status', label: 'Status' },
{ key: 'role', label: 'Role' },
]
const data = sampleUsers
const selectedIds = ref<string[]>([])
return { columns, data, selectedIds }
},
template: /* html */ `
<div class="space-y-4">
<Table
:columns="columns"
:data="data"
show-selection
row-key="id"
v-model:selected-ids="selectedIds"
/>
<p class="text-secondary">Selected IDs: {{ selectedIds.join(', ') || 'None' }}</p>
</div>
`,
}),
}
export const WithSorting: StoryObj = {
args: {},
render: () => ({
components: { Table },
setup() {
const columns = [
{ key: 'name', label: 'Name', enableSorting: true },
{ key: 'email', label: 'Email', enableSorting: true },
{ key: 'status', label: 'Status' },
{ key: 'role', label: 'Role', enableSorting: true },
]
const data = sampleUsers
const sortColumn = ref<string | undefined>('name')
const sortDirection = ref<'asc' | 'desc'>('asc')
function handleSort(column: string, direction: 'asc' | 'desc') {
console.log(`Sorting by ${column} ${direction}`)
}
return { columns, data, sortColumn, sortDirection, handleSort }
},
template: /* html */ `
<div class="space-y-4">
<Table
:columns="columns"
:data="data"
v-model:sort-column="sortColumn"
v-model:sort-direction="sortDirection"
@sort="handleSort"
/>
<p class="text-secondary">Sort: {{ sortColumn }} ({{ sortDirection }})</p>
</div>
`,
}),
}
export const WithColumnAlignment: StoryObj = {
args: {},
render: () => ({
components: { Table },
setup() {
const columns = [
{ key: 'name', label: 'Name', align: 'left' as const },
{ key: 'email', label: 'Email', align: 'center' as const },
{ key: 'status', label: 'Status', align: 'center' as const },
{ key: 'role', label: 'Role', align: 'right' as const },
]
const data = sampleUsers
return { columns, data }
},
template: /* html */ `
<Table :columns="columns" :data="data" />
`,
}),
}
export const WithCustomCellSlots: StoryObj = {
args: {},
render: () => ({
components: { Table, Badge },
setup() {
const columns = [
{ key: 'name', label: 'Name' },
{ key: 'email', label: 'Email' },
{ key: 'status', label: 'Status', align: 'center' as const, width: '20%' },
{ key: 'role', label: 'Role', width: '10%' },
]
const data = sampleUsers
const statusColor = (status: string) => {
switch (status) {
case 'active':
return 'green'
case 'inactive':
return 'red'
case 'pending':
return 'orange'
default:
return 'gray'
}
}
return { columns, data, statusColor }
},
template: /* html */ `
<Table :columns="columns" :data="data">
<template #cell-name="{ value, row }">
<div class="font-semibold">{{ value }}</div>
</template>
<template #cell-email="{ value }">
<a :href="'mailto:' + value" class="text-brand hover:underline">{{ value }}</a>
</template>
<template #cell-status="{ value }">
<div class="flex justify-center">
<Badge :color="statusColor(value)">{{ value }}</Badge>
</div>
</template>
</Table>
`,
}),
}
export const WithCustomHeaderSlots: StoryObj = {
args: {},
render: () => ({
components: { Table },
setup() {
const columns = [
{ key: 'name', label: 'Name' },
{ key: 'email', label: 'Email' },
{ key: 'status', label: 'Status' },
{ key: 'role', label: 'Role' },
]
const data = sampleUsers
return { columns, data }
},
template: /* html */ `
<Table :columns="columns" :data="data">
<template #header-name="{ column }">
<span class="text-brand font-bold uppercase">{{ column.label }} ✨</span>
</template>
<template #header-status="{ column }">
<span class="flex items-center gap-1">
<span class="w-2 h-2 rounded-full bg-green"></span>
{{ column.label }}
</span>
</template>
</Table>
`,
}),
}
export const WithActionsColumn: StoryObj = {
args: {},
render: () => ({
components: { Table, ButtonStyled, EditIcon, TrashIcon },
setup() {
const columns = [
{ key: 'name', label: 'Name' },
{ key: 'email', label: 'Email' },
{ key: 'role', label: 'Role' },
{ key: 'actions', label: 'Actions', align: 'right' as const },
]
const data = sampleUsers
function handleEdit(row: User) {
alert(`Edit user: ${row.name}`)
}
function handleDelete(row: User) {
alert(`Delete user: ${row.name}`)
}
return { columns, data, handleEdit, handleDelete }
},
template: /* html */ `
<Table :columns="columns" :data="data">
<template #cell-actions="{ row }">
<div class="flex items-center justify-end gap-2">
<ButtonStyled color="brand" type="transparent" @click="handleEdit(row)">
<button class="flex items-center gap-1">
<EditIcon class="size-4" />
Edit
</button>
</ButtonStyled>
<ButtonStyled color="red" type="transparent" @click="handleDelete(row)">
<button class="flex items-center gap-1">
<TrashIcon class="size-4" />
Delete
</button>
</ButtonStyled>
</div>
</template>
</Table>
`,
}),
}
export const FullFeatured: StoryObj = {
args: {},
render: () => ({
components: { Table, Badge, ButtonStyled, EditIcon, TrashIcon },
setup() {
const columns = [
{ key: 'name', label: 'Name', enableSorting: true },
{ key: 'email', label: 'Email', enableSorting: true },
{ key: 'status', label: 'Status', align: 'center' as const, width: '100px' },
{ key: 'role', label: 'Role', enableSorting: true },
{ key: 'actions', label: 'Actions', align: 'right' as const, width: '200px' },
]
const data = sampleUsers
const selectedIds = ref<string[]>([])
const sortColumn = ref<string | undefined>('name')
const sortDirection = ref<'asc' | 'desc'>('asc')
const statusColor = (status: string) => {
switch (status) {
case 'active':
return 'green'
case 'inactive':
return 'red'
case 'pending':
return 'orange'
default:
return 'gray'
}
}
function handleSort(column: string, direction: 'asc' | 'desc') {
console.log(`Sorting by ${column} ${direction}`)
}
function handleEdit(row: User) {
alert(`Edit user: ${row.name}`)
}
function handleDelete(row: User) {
alert(`Delete user: ${row.name}`)
}
return {
columns,
data,
selectedIds,
sortColumn,
sortDirection,
statusColor,
handleSort,
handleEdit,
handleDelete,
}
},
template: /* html */ `
<div class="space-y-4">
<Table
:columns="columns"
:data="data"
show-selection
row-key="id"
v-model:selected-ids="selectedIds"
v-model:sort-column="sortColumn"
v-model:sort-direction="sortDirection"
@sort="handleSort"
>
<template #cell-name="{ value }">
<span class="font-semibold">{{ value }}</span>
</template>
<template #cell-email="{ value }">
<a :href="'mailto:' + value" class="text-brand hover:underline">{{ value }}</a>
</template>
<template #cell-status="{ value }">
<div class="flex justify-center">
<Badge :color="statusColor(value)">{{ value }}</Badge>
</div>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center justify-end gap-2">
<ButtonStyled color="brand" type="transparent" @click="handleEdit(row)">
<button class="flex items-center gap-1">
<EditIcon class="size-4" />
Edit
</button>
</ButtonStyled>
<ButtonStyled color="red" type="transparent" @click="handleDelete(row)">
<button class="flex items-center gap-1">
<TrashIcon class="size-4" />
Delete
</button>
</ButtonStyled>
</div>
</template>
</Table>
<div class="flex gap-4 text-secondary text-sm">
<span>Selected: {{ selectedIds.length }} items</span>
<span>Sort: {{ sortColumn }} ({{ sortDirection }})</span>
</div>
</div>
`,
}),
}
export const WithOverflowMenu: StoryObj = {
args: {},
render: () => ({
components: { Table, Badge, ButtonStyled, OverflowMenu, MoreVerticalIcon, EditIcon, TrashIcon },
setup() {
const columns = [
{ key: 'name', label: 'Name' },
{ key: 'email', label: 'Email' },
{ key: 'status', label: 'Status', align: 'center' as const, width: '20%' },
{ key: 'role', label: 'Role' },
{ key: 'actions', label: '', width: '48px' },
]
const data = sampleUsers
const statusColor = (status: string) => {
switch (status) {
case 'active':
return 'green'
case 'inactive':
return 'red'
case 'pending':
return 'orange'
default:
return 'gray'
}
}
const getMenuOptions = (row: User) => [
{
id: 'edit',
action: () => alert(`Edit user: ${row.name}`),
},
{
id: 'duplicate',
action: () => alert(`Duplicate user: ${row.name}`),
},
{ divider: true },
{
id: 'delete',
color: 'red' as const,
hoverFilled: true,
action: () => alert(`Delete user: ${row.name}`),
},
]
return { columns, data, statusColor, getMenuOptions }
},
template: /* html */ `
<Table :columns="columns" :data="data">
<template #cell-name="{ value }">
<span class="font-semibold">{{ value }}</span>
</template>
<template #cell-status="{ value }">
<div class="flex justify-center">
<Badge :color="statusColor(value)">{{ value }}</Badge>
</div>
</template>
<template #cell-actions="{ row }">
<div class="flex justify-end">
<ButtonStyled circular type="transparent">
<OverflowMenu
:options="getMenuOptions(row)"
aria-label="More options"
>
<MoreVerticalIcon aria-hidden="true" />
<template #edit>
<EditIcon class="size-4" aria-hidden="true" />
Edit
</template>
<template #duplicate>
<EditIcon class="size-4" aria-hidden="true" />
Duplicate
</template>
<template #delete>
<TrashIcon class="size-4" aria-hidden="true" />
Delete
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</template>
</Table>
`,
}),
}

View File

@@ -13,6 +13,7 @@ type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
modelValue: false,
small: false,
},
}
@@ -29,11 +30,26 @@ export const Disabled: Story = {
},
}
export const Small: Story = {
args: {
modelValue: false,
small: true,
},
}
export const SmallChecked: Story = {
args: {
modelValue: true,
small: true,
},
}
export const AllStates: Story = {
render: () => ({
components: { Toggle },
template: /*html*/ `
<div style="display: flex; flex-direction: column; gap: 1rem;">
<span style="font-weight: 600;">Default Size</span>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<Toggle :model-value="false" /> Off
</div>
@@ -43,6 +59,16 @@ export const AllStates: Story = {
<div style="display: flex; align-items: center; gap: 0.5rem;">
<Toggle :model-value="false" :disabled="true" /> Disabled
</div>
<span style="font-weight: 600; margin-top: 1rem;">Small Size</span>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<Toggle :model-value="false" :small="true" /> Off
</div>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<Toggle :model-value="true" :small="true" /> On
</div>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<Toggle :model-value="false" :small="true" :disabled="true" /> Disabled
</div>
</div>
`,
}),

View File

@@ -0,0 +1,469 @@
import { DownloadIcon, EyeIcon, FolderOpenIcon } from '@modrinth/assets'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { fn } from 'storybook/test'
import { onMounted, onUnmounted, ref } from 'vue'
import ButtonStyled from '../../components/base/ButtonStyled.vue'
import ContentCardTable from '../../components/instances/ContentCardTable.vue'
import type { ContentCardTableItem } from '../../components/instances/types'
// ============================================
// Fixtures
// ============================================
const fixtures = {
sodium: {
id: 'AANobbMI',
project: {
id: 'AANobbMI',
slug: 'sodium',
title: 'Sodium',
icon_url:
'https://cdn.modrinth.com/data/AANobbMI/295862f4724dc3f78df3447ad6072b2dcd3ef0c9_96.webp',
},
version: {
id: '59wygFUQ',
version_number: 'mc1.21.11-0.8.2-fabric',
file_name: 'sodium-fabric-0.8.2+mc1.21.11.jar',
},
owner: {
id: 'DzLrfrbK',
name: 'IMS',
avatar_url: 'https://avatars3.githubusercontent.com/u/31803019?v=4',
type: 'user' as const,
},
enabled: true,
},
modMenu: {
id: 'mOgUt4GM',
project: {
id: 'mOgUt4GM',
slug: 'modmenu',
title: 'Mod Menu',
icon_url:
'https://cdn.modrinth.com/data/mOgUt4GM/5a20ed1450a0e1e79a1fe04e61bb4e5878bf1d20.png',
},
version: {
id: 'QuU0ciaR',
version_number: '16.0.0',
file_name: 'modmenu-16.0.0.jar',
},
owner: { id: 'u2', name: 'Prospector', type: 'user' as const },
enabled: true,
},
fabricApi: {
id: 'P7dR8mSH',
project: {
id: 'P7dR8mSH',
slug: 'fabric-api',
title: 'Fabric API',
icon_url: 'https://cdn.modrinth.com/data/P7dR8mSH/icon.png',
},
version: {
id: 'Lwa1Q6e4',
version_number: '0.141.3+26.1',
file_name: 'fabric-api-0.141.3+26.1.jar',
},
owner: {
id: 'BZoBsPo6',
name: 'FabricMC',
avatar_url: 'https://cdn.modrinth.com/data/P7dR8mSH/icon.png',
type: 'organization' as const,
},
enabled: false,
},
} satisfies Record<string, ContentCardTableItem>
const defaultItems: ContentCardTableItem[] = [fixtures.sodium, fixtures.modMenu, fixtures.fabricApi]
/** Generate n items for stress testing */
function generateItems(count: number): ContentCardTableItem[] {
return Array.from({ length: count }, (_, i) => ({
...fixtures.sodium,
id: `item-${i}`,
project: { ...fixtures.sodium.project, title: `Mod ${i + 1}` },
version: { ...fixtures.sodium.version!, version_number: `1.0.${i}` },
enabled: i % 3 !== 0,
}))
}
// ============================================
// Meta
// ============================================
const meta = {
title: 'Instances/ContentCardTable',
component: ContentCardTable,
parameters: { layout: 'padded' },
args: {
items: defaultItems,
showSelection: false,
virtualized: true,
'onUpdate:enabled': fn(),
onDelete: fn(),
onUpdate: fn(),
},
} satisfies Meta<typeof ContentCardTable>
export default meta
type Story = StoryObj<typeof meta>
// ============================================
// Core Stories
// ============================================
export const Default: Story = {}
export const WithSelection: Story = {
args: { showSelection: true },
render: (args) => ({
components: { ContentCardTable },
setup() {
const selectedIds = ref<string[]>([])
return { args, selectedIds }
},
template: `
<div class="flex flex-col gap-4">
<ContentCardTable v-bind="args" v-model:selected-ids="selectedIds" />
<p class="text-sm text-secondary">
Selected: <strong>{{ selectedIds.length }}</strong>
<span v-if="selectedIds.length"> ({{ selectedIds.join(', ') }})</span>
</p>
</div>
`,
}),
}
export const Empty: Story = {
args: { items: [] },
}
export const EmptyCustom: Story = {
args: { items: [] },
render: (args) => ({
components: { ContentCardTable, ButtonStyled },
setup: () => ({ args }),
template: `
<ContentCardTable v-bind="args">
<template #empty>
<div class="flex flex-col items-center gap-4 py-8">
<span class="text-lg text-secondary">No mods installed</span>
<ButtonStyled color="green"><button>Browse mods</button></ButtonStyled>
</div>
</template>
</ContentCardTable>
`,
}),
}
// ============================================
// States
// ============================================
/** All possible item states in one view */
export const AllStates: Story = {
args: {
showSelection: true,
items: [
{ ...fixtures.sodium, enabled: true },
{ ...fixtures.modMenu, hasUpdate: true },
{ ...fixtures.fabricApi, enabled: false },
{
id: 'long-name',
project: {
id: 'long-name',
slug: 'long-mod',
title: '[EMF] Entity Model Features - The Ultimate Entity Rendering Mod',
icon_url: fixtures.sodium.project.icon_url,
},
version: {
id: 'v1',
version_number: '2.4.1-beta.15+mc1.21.1-fabric-loader0.16.0',
file_name: 'emf-2.4.1-beta.15.jar',
},
owner: { id: 'u1', name: 'Traben', type: 'user' },
enabled: true,
},
{
id: 'no-icon',
project: { id: 'no-icon', slug: 'imported', title: 'Imported mod', icon_url: undefined },
version: { id: 'v1', version_number: 'Unknown', file_name: 'imported.jar' },
enabled: true,
},
{
id: 'no-avatar',
project: {
id: 'no-avatar',
slug: 'no-avatar',
title: 'No Owner Avatar',
icon_url: fixtures.modMenu.project.icon_url,
},
version: { id: 'v1', version_number: '1.0.0', file_name: 'mod.jar' },
owner: { id: 'u1', name: 'Anonymous', avatar_url: undefined, type: 'user' },
enabled: true,
},
{ ...fixtures.modMenu, id: 'disabled-item', disabled: true, enabled: false },
],
},
parameters: {
docs: {
description: {
story:
'Demonstrates: enabled, update available, disabled toggle, long names (truncation), missing icon, missing avatar, fully disabled item.',
},
},
},
}
/** Items with update badges */
export const WithUpdates: Story = {
args: {
items: [
{ ...fixtures.sodium, hasUpdate: true },
{ ...fixtures.modMenu, hasUpdate: true },
fixtures.fabricApi,
],
},
}
/** Per-item disabled state (e.g., during async operations) */
export const ItemsDisabled: Story = {
args: {
showSelection: true,
items: [
fixtures.sodium,
{ ...fixtures.modMenu, disabled: true },
{ ...fixtures.fabricApi, disabled: true },
],
},
parameters: {
docs: {
description: { story: 'Items with `disabled: true` have all interactions disabled.' },
},
},
}
// ============================================
// Slots
// ============================================
export const CustomButtons: Story = {
args: { showSelection: true },
render: (args) => ({
components: { ContentCardTable, ButtonStyled, EyeIcon, FolderOpenIcon, DownloadIcon },
setup: () => ({ args }),
template: `
<ContentCardTable v-bind="args">
<template #itemButtonsLeft="{ item }">
<ButtonStyled v-tooltip="'Download'" circular type="transparent" color="green" color-fill="text">
<button><DownloadIcon class="size-5" /></button>
</ButtonStyled>
</template>
<template #itemButtonsRight="{ item }">
<ButtonStyled v-tooltip="'View'" circular type="transparent">
<button><EyeIcon class="size-5 text-secondary" /></button>
</ButtonStyled>
<ButtonStyled v-tooltip="'Folder'" circular type="transparent">
<button><FolderOpenIcon class="size-5 text-secondary" /></button>
</ButtonStyled>
</template>
</ContentCardTable>
`,
}),
}
export const WithOverflowMenu: Story = {
args: {
showSelection: true,
items: [
{
...fixtures.sodium,
overflowOptions: [
{ id: 'view', action: () => console.log('View') },
{ id: 'folder', action: () => console.log('Folder') },
{ divider: true },
{ id: 'remove', action: () => console.log('Remove'), color: 'red' as const },
],
},
{
...fixtures.modMenu,
overflowOptions: [
{ id: 'view', action: () => console.log('View') },
{ divider: true },
{ id: 'remove', action: () => console.log('Remove'), color: 'red' as const },
],
},
],
},
render: (args) => ({
components: { ContentCardTable },
setup: () => ({ args }),
template: `
<ContentCardTable v-bind="args">
<template #view>View on Modrinth</template>
<template #folder>Open folder</template>
<template #remove>Remove</template>
</ContentCardTable>
`,
}),
}
// ============================================
// Interactive
// ============================================
export const Interactive: Story = {
args: { showSelection: true },
render: (args) => ({
components: { ContentCardTable },
setup() {
const items = ref<ContentCardTableItem[]>(
defaultItems.map((item) => ({ ...item, enabled: item.id !== fixtures.fabricApi.id })),
)
const selectedIds = ref<string[]>([])
const handleToggle = (id: string, value: boolean) => {
const item = items.value.find((i) => i.id === id)
if (item) item.enabled = value
}
const handleDelete = (id: string) => {
items.value = items.value.filter((i) => i.id !== id)
selectedIds.value = selectedIds.value.filter((i) => i !== id)
}
return { args, items, selectedIds, handleToggle, handleDelete }
},
template: `
<div class="flex flex-col gap-4">
<ContentCardTable
:items="items"
:show-selection="args.showSelection"
v-model:selected-ids="selectedIds"
@update:enabled="handleToggle"
@delete="handleDelete"
/>
<p class="text-sm text-secondary">
Items: <strong>{{ items.length }}</strong> · Selected: <strong>{{ selectedIds.length }}</strong>
</p>
</div>
`,
}),
}
export const BulkActions: Story = {
render: () => ({
components: { ContentCardTable, ButtonStyled },
setup() {
const items = ref<ContentCardTableItem[]>(
defaultItems.map((item, i) => ({ ...item, enabled: i !== 2 })),
)
const selectedIds = ref<string[]>([])
const setEnabled = (value: boolean) => {
items.value.forEach((item) => {
if (selectedIds.value.includes(item.id)) item.enabled = value
})
}
const deleteSelected = () => {
items.value = items.value.filter((item) => !selectedIds.value.includes(item.id))
selectedIds.value = []
}
const handleToggle = (id: string, value: boolean) => {
const item = items.value.find((i) => i.id === id)
if (item) item.enabled = value
}
return { items, selectedIds, setEnabled, deleteSelected, handleToggle }
},
template: `
<div class="flex flex-col gap-4">
<div class="flex items-center gap-2">
<span class="text-sm text-secondary">{{ selectedIds.length }} selected</span>
<template v-if="selectedIds.length">
<ButtonStyled size="small" color="green">
<button @click="setEnabled(true)">Enable</button>
</ButtonStyled>
<ButtonStyled size="small" type="transparent">
<button @click="setEnabled(false)">Disable</button>
</ButtonStyled>
<ButtonStyled size="small" color="red">
<button @click="deleteSelected">Delete</button>
</ButtonStyled>
</template>
</div>
<ContentCardTable
:items="items"
show-selection
v-model:selected-ids="selectedIds"
@update:enabled="handleToggle"
/>
</div>
`,
}),
}
// ============================================
// Performance
// ============================================
export const Virtualization: Story = {
parameters: {
docs: {
description: {
story:
'2000 items with virtualization. Toggle to compare DOM node count. Virtualized should render ~20-30 nodes vs 2000.',
},
},
},
render: () => ({
components: { ContentCardTable },
setup() {
const items = ref(generateItems(2000))
const selectedIds = ref<string[]>([])
const virtualized = ref(true)
const tableRef = ref<InstanceType<typeof ContentCardTable> | null>(null)
const domNodes = ref(0)
let raf: number
const updateNodeCount = () => {
if (tableRef.value?.$el) {
domNodes.value = (tableRef.value.$el as HTMLElement).querySelectorAll(
'[data-content-card-item]',
).length
}
raf = requestAnimationFrame(updateNodeCount)
}
onMounted(() => {
raf = requestAnimationFrame(updateNodeCount)
})
onUnmounted(() => cancelAnimationFrame(raf))
return { items, selectedIds, virtualized, tableRef, domNodes }
},
template: `
<div>
<div class="sticky top-0 z-10 mb-4 flex items-center gap-3 rounded-lg bg-surface-2 p-3">
<label class="flex cursor-pointer items-center gap-2">
<input type="checkbox" v-model="virtualized" class="h-4 w-4 rounded" />
<span class="font-medium text-contrast">Virtualization</span>
</label>
<span class="ml-auto font-mono text-sm">
DOM: <span :class="domNodes > 100 ? 'text-red-500' : 'text-green-500'">{{ domNodes }}</span>
/ {{ items.length }}
</span>
</div>
<ContentCardTable
ref="tableRef"
:items="items"
:virtualized="virtualized"
show-selection
v-model:selected-ids="selectedIds"
/>
</div>
`,
}),
}

View File

@@ -0,0 +1,633 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { fn } from 'storybook/test'
import { ref } from 'vue'
import ContentCardItem from '../../components/instances/ContentCardItem.vue'
import ContentModpackCard from '../../components/instances/ContentModpackCard.vue'
import type {
ContentModpackCardCategory,
ContentModpackCardProject,
ContentModpackCardVersion,
ContentOwner,
} from '../../components/instances/types'
import NewModal from '../../components/modal/NewModal.vue'
// Real project data from Modrinth API
const fabulouslyOptimizedProject: ContentModpackCardProject = {
id: '1KVo5zza',
slug: 'fabulously-optimized',
title: 'Fabulously Optimized',
icon_url:
'https://cdn.modrinth.com/data/1KVo5zza/9f1ded4949c2a9db5ca382d3bcc912c7245486b4_96.webp',
description:
'Beautiful graphics, speedy performance and familiar features in a simple package. 1.21.11 beta!',
downloads: 8708191,
followers: 3762,
}
const cobblemonProject: ContentModpackCardProject = {
id: '5FFgwNNP',
slug: 'cobblemon-fabric',
title: 'Cobblemon Official Modpack [Fabric]',
icon_url: 'https://cdn.modrinth.com/data/5FFgwNNP/e7f9ee2e9d361623847853fe2ddce42f519ee64f.png',
description: 'The official modpack of the Cobblemon mod, for Fabric!',
downloads: 4940845,
followers: 2051,
}
const simplyOptimizedProject: ContentModpackCardProject = {
id: 'BYfVnHa7',
slug: 'sop',
title: 'Simply Optimized',
icon_url: 'https://cdn.modrinth.com/data/BYfVnHa7/845e93223da7e8d1ed1a33364b5bdb4c316ac518.png',
description:
'The leading, well-researched optimization modpack with a focus on pure performance.',
downloads: 2903242,
followers: 1387,
}
// Version data from Modrinth API
const fabulouslyOptimizedVersion: ContentModpackCardVersion = {
id: 'YEEXo8mO',
version_number: '1.12.1',
date_published: '2022-02-10T06:53:28.379507Z',
}
const cobblemonVersion: ContentModpackCardVersion = {
id: 'bpaivauC',
version_number: '1.5.2',
date_published: '2024-05-27T07:12:36.043005Z',
}
// Owner data from Modrinth API
const userOwner: ContentOwner = {
id: '2avTeeAE',
name: 'robotkoer',
avatar_url: 'https://cdn.modrinth.com/user/2avTeeAE/icon.png',
type: 'user',
}
const cobblemonOwner: ContentOwner = {
id: 'AEFONbAM',
name: 'Reisen',
avatar_url:
'https://cdn.modrinth.com/user/AEFONbAM/9e97453507a8245981d5cd825280f23be44f15ac.jpeg',
type: 'user',
}
// Categories (using Labrinth.Tags.v2.Category structure with optional action)
const optimizationCategories: ContentModpackCardCategory[] = [
{ name: 'Fabric', icon: 'fabric', project_type: 'modpack', header: 'loaders' },
{ name: 'Lightweight', icon: 'lightweight', project_type: 'modpack', header: 'categories' },
{ name: 'Multiplayer', icon: 'multiplayer', project_type: 'modpack', header: 'categories' },
{ name: 'Optimization', icon: 'optimization', project_type: 'modpack', header: 'categories' },
]
const cobblemonCategories: ContentModpackCardCategory[] = [
{ name: 'Adventure', icon: 'adventure', project_type: 'modpack', header: 'categories' },
{ name: 'Fabric', icon: 'fabric', project_type: 'modpack', header: 'loaders' },
{ name: 'Lightweight', icon: 'lightweight', project_type: 'modpack', header: 'categories' },
{ name: 'Multiplayer', icon: 'multiplayer', project_type: 'modpack', header: 'categories' },
]
const meta = {
title: 'Instances/ContentModpackCard',
component: ContentModpackCard,
parameters: {
layout: 'padded',
},
argTypes: {
project: {
control: 'object',
description:
'Project information (id, slug, title, icon_url, description, downloads, followers)',
},
version: {
control: 'object',
description: 'Version information (id, version_number, date_published)',
},
owner: {
control: 'object',
description: 'Owner/author information (user or organization)',
},
categories: {
control: 'object',
description: 'Category tags with optional click actions',
},
disabled: {
control: 'boolean',
description: 'Grays out the card when true',
},
overflowOptions: {
control: 'object',
description: 'Options for the overflow menu',
},
},
} satisfies Meta<typeof ContentModpackCard>
export default meta
type Story = StoryObj<typeof meta>
// ============================================
// All Types Overview
// ============================================
export const AllTypes: Story = {
args: {
project: fabulouslyOptimizedProject,
},
render: () => ({
components: { ContentModpackCard },
setup() {
const cards = [
{
label: 'Full featured (all actions)',
project: fabulouslyOptimizedProject,
version: fabulouslyOptimizedVersion,
owner: userOwner,
categories: optimizationCategories,
hasUpdate: true,
hasContent: true,
hasUnlink: true,
},
{
label: 'With update available only',
project: cobblemonProject,
version: cobblemonVersion,
owner: cobblemonOwner,
categories: cobblemonCategories,
hasUpdate: true,
},
{
label: 'With content button only',
project: simplyOptimizedProject,
version: fabulouslyOptimizedVersion,
owner: userOwner,
hasContent: true,
},
{
label: 'Minimal (project only)',
project: fabulouslyOptimizedProject,
},
{
label: 'With version info only',
project: cobblemonProject,
version: cobblemonVersion,
},
{
label: 'With owner only',
project: simplyOptimizedProject,
owner: userOwner,
},
{
label: 'Disabled state',
project: fabulouslyOptimizedProject,
version: fabulouslyOptimizedVersion,
owner: userOwner,
categories: optimizationCategories,
disabled: true,
},
]
return { cards }
},
template: /*html*/ `
<div class="flex flex-col gap-6">
<template v-for="card in cards" :key="card.label">
<h3 class="text-sm font-medium text-secondary">{{ card.label }}</h3>
<ContentModpackCard
:project="card.project"
:version="card.version"
:owner="card.owner"
:categories="card.categories"
:disabled="card.disabled"
@update="card.hasUpdate ? () => {} : undefined"
@content="card.hasContent ? () => {} : undefined"
@unlink="card.hasUnlink ? () => {} : undefined"
/>
</template>
</div>
`,
}),
}
// ============================================
// Basic Stories
// ============================================
export const Default: Story = {
args: {
project: cobblemonProject,
version: cobblemonVersion,
owner: userOwner,
categories: optimizationCategories,
onUpdate: fn(),
onContent: fn(),
onUnlink: fn(),
},
}
export const MinimalProjectOnly: Story = {
args: {
project: cobblemonProject,
},
}
export const WithVersion: Story = {
args: {
project: simplyOptimizedProject,
version: fabulouslyOptimizedVersion,
},
}
export const WithUserOwner: Story = {
args: {
project: simplyOptimizedProject,
version: fabulouslyOptimizedVersion,
owner: userOwner,
categories: [
{ name: 'Adventure', icon: 'adventure', project_type: 'modpack', header: 'categories' },
],
},
}
export const WithOrganizationOwner: Story = {
args: {
project: cobblemonProject,
version: cobblemonVersion,
owner: userOwner,
categories: optimizationCategories,
},
}
// ============================================
// Action Button Stories
// ============================================
export const WithUpdateButton: Story = {
args: {
project: cobblemonProject,
version: cobblemonVersion,
owner: userOwner,
categories: optimizationCategories,
onUpdate: fn(),
},
}
export const WithContentButton: Story = {
args: {
project: cobblemonProject,
version: cobblemonVersion,
owner: userOwner,
categories: optimizationCategories,
onContent: fn(),
},
}
export const WithUnlinkButton: Story = {
args: {
project: cobblemonProject,
version: cobblemonVersion,
owner: userOwner,
onUnlink: fn(),
},
}
export const WithAllActions: Story = {
args: {
project: cobblemonProject,
version: cobblemonVersion,
owner: userOwner,
categories: optimizationCategories,
onUpdate: fn(),
onContent: fn(),
onUnlink: fn(),
overflowOptions: [
{ id: 'view', action: () => console.log('View') },
{ id: 'settings', action: () => console.log('Settings') },
{ divider: true },
{ id: 'remove', action: () => console.log('Remove'), color: 'red' },
],
},
}
// ============================================
// State Stories
// ============================================
export const Disabled: Story = {
args: {
project: cobblemonProject,
version: cobblemonVersion,
owner: userOwner,
categories: optimizationCategories,
disabled: true,
},
}
export const LongTitle: Story = {
args: {
project: {
...cobblemonProject,
title: 'Super Long Modpack Title That Should Display Properly On All Screen Sizes',
description:
'This is an extremely long description that should wrap properly and not break the layout. It contains lots of information about what this modpack includes and what makes it special compared to other modpacks available on the platform.',
},
version: cobblemonVersion,
owner: {
...userOwner,
name: 'Really Long Organization Name Studios',
},
categories: [
{ name: 'Adventure', icon: 'adventure', project_type: 'modpack', header: 'categories' },
{ name: 'Technology', icon: 'technology', project_type: 'modpack', header: 'categories' },
{ name: 'Magic', icon: 'magic', project_type: 'modpack', header: 'categories' },
{ name: 'Exploration', icon: 'exploration', project_type: 'modpack', header: 'categories' },
{ name: 'Multiplayer', icon: 'multiplayer', project_type: 'modpack', header: 'categories' },
],
onUpdate: fn(),
onContent: fn(),
},
}
export const NoDescription: Story = {
args: {
project: {
...cobblemonProject,
description: undefined,
},
version: cobblemonVersion,
owner: userOwner,
categories: optimizationCategories,
},
}
export const NoStats: Story = {
args: {
project: {
...cobblemonProject,
downloads: undefined,
followers: undefined,
},
version: cobblemonVersion,
owner: userOwner,
},
}
// ============================================
// Categories Stories
// ============================================
export const WithClickableCategories: Story = {
render: (args) => ({
components: { ContentModpackCard },
setup() {
const clickedCategory = ref<string | null>(null)
const categories: ContentModpackCardCategory[] = [
{
name: 'Adventure',
icon: 'adventure',
project_type: 'modpack',
header: 'categories',
action: () => (clickedCategory.value = 'Adventure'),
},
{
name: 'Lightweight',
icon: 'lightweight',
project_type: 'modpack',
header: 'categories',
action: () => (clickedCategory.value = 'Lightweight'),
},
{
name: 'Multiplayer',
icon: 'multiplayer',
project_type: 'modpack',
header: 'categories',
action: () => (clickedCategory.value = 'Multiplayer'),
},
]
return { args, categories, clickedCategory }
},
template: /*html*/ `
<div class="flex flex-col gap-4">
<ContentModpackCard
:project="args.project"
:version="args.version"
:owner="args.owner"
:categories="categories"
/>
<div class="text-sm text-secondary">
Clicked category: <strong>{{ clickedCategory || 'None' }}</strong>
</div>
</div>
`,
}),
args: {
project: cobblemonProject,
version: cobblemonVersion,
owner: userOwner,
},
}
// ============================================
// Overflow Menu Stories
// ============================================
export const WithOverflowMenu: Story = {
render: (args) => ({
components: { ContentModpackCard },
setup() {
return { args }
},
template: /*html*/ `
<ContentModpackCard v-bind="args">
<template #view>View on Modrinth</template>
<template #settings>Settings</template>
<template #remove>Remove modpack</template>
</ContentModpackCard>
`,
}),
args: {
project: cobblemonProject,
version: cobblemonVersion,
owner: userOwner,
categories: optimizationCategories,
overflowOptions: [
{ id: 'view', action: () => console.log('View') },
{ id: 'settings', action: () => console.log('Settings') },
{ divider: true },
{ id: 'remove', action: () => console.log('Remove'), color: 'red' },
],
},
}
// ============================================
// Interactive Stories
// ============================================
export const WithContentModal: Story = {
args: {
project: cobblemonProject,
},
render: () => ({
components: { ContentModpackCard, NewModal, ContentCardItem },
setup() {
const modalRef = ref<InstanceType<typeof NewModal> | null>(null)
const modpackContent = [
{
project: {
id: '1',
slug: 'sodium',
title: 'Sodium',
icon_url:
'https://cdn.modrinth.com/data/AANobbMI/295862f4724dc3f78df3447ad6072b2dcd3ef0c9_96.webp',
},
version: { id: 'v1', version_number: '0.8.2', file_name: 'sodium-fabric-0.8.2.jar' },
},
{
project: {
id: '2',
slug: 'modmenu',
title: 'Mod Menu',
icon_url:
'https://cdn.modrinth.com/data/mOgUt4GM/5a20ed1450a0e1e79a1fe04e61bb4e5878bf1d20.png',
},
version: { id: 'v2', version_number: '16.0.0', file_name: 'modmenu-16.0.0.jar' },
},
{
project: {
id: '3',
slug: 'fabric-api',
title: 'Fabric API',
icon_url: 'https://cdn.modrinth.com/data/P7dR8mSH/icon.png',
},
version: { id: 'v3', version_number: '0.141.3', file_name: 'fabric-api-0.141.3.jar' },
},
]
return {
cobblemonProject,
cobblemonVersion,
userOwner,
optimizationCategories,
modalRef,
modpackContent,
}
},
template: /*html*/ `
<div>
<ContentModpackCard
:project="cobblemonProject"
:version="cobblemonVersion"
:owner="userOwner"
:categories="optimizationCategories"
@content="modalRef?.show()"
@update="() => alert('Update clicked')"
/>
<NewModal ref="modalRef" header="Modpack Content">
<div class="flex flex-col gap-4">
<ContentCardItem
v-for="item in modpackContent"
:key="item.project.id"
:project="item.project"
:version="item.version"
/>
</div>
</NewModal>
</div>
`,
}),
}
// ============================================
// Responsive Stories
// ============================================
export const ResponsiveView: Story = {
args: {
project: cobblemonProject,
},
render: () => ({
components: { ContentModpackCard },
setup() {
return {
cobblemonProject,
cobblemonVersion,
userOwner,
optimizationCategories,
}
},
template: /*html*/ `
<div class="flex flex-col gap-8">
<div>
<h3 class="text-sm font-medium text-secondary mb-2">Desktop (full width)</h3>
<div class="w-full">
<ContentModpackCard
:project="cobblemonProject"
:version="cobblemonVersion"
:owner="userOwner"
:categories="optimizationCategories"
@update="() => {}"
@content="() => {}"
@unlink="() => {}"
/>
</div>
</div>
<div>
<h3 class="text-sm font-medium text-secondary mb-2">Mobile (&lt;640px)</h3>
<div class="w-[360px]">
<ContentModpackCard
:project="cobblemonProject"
:version="cobblemonVersion"
:owner="userOwner"
:categories="optimizationCategories"
@update="() => {}"
@content="() => {}"
/>
</div>
</div>
</div>
`,
}),
}
// ============================================
// Edge Cases
// ============================================
export const NoIcon: Story = {
args: {
project: {
...cobblemonProject,
icon_url: undefined,
},
version: cobblemonVersion,
owner: userOwner,
categories: optimizationCategories,
},
}
export const NoOwnerAvatar: Story = {
args: {
project: cobblemonProject,
version: cobblemonVersion,
owner: {
...userOwner,
avatar_url: undefined,
},
categories: optimizationCategories,
},
}
export const HighDownloadCounts: Story = {
args: {
project: {
...cobblemonProject,
downloads: 1234567890,
followers: 9876543,
},
version: cobblemonVersion,
owner: userOwner,
categories: optimizationCategories,
},
}

View File

@@ -0,0 +1,411 @@
import type { Labrinth } from '@modrinth/api-client'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { fn } from 'storybook/test'
import { ref } from 'vue'
import ButtonStyled from '../../components/base/ButtonStyled.vue'
import ContentUpdaterModal from '../../components/instances/modals/ContentUpdaterModal.vue'
// Real version data from Modrinth API - Sodium (mod)
const sodiumVersions: Labrinth.Versions.v2.Version[] = [
{
id: '59wygFUQ',
project_id: 'AANobbMI',
author_id: 'TEZXhE2U',
featured: true,
name: 'Sodium 0.8.2 for Fabric 1.21.11',
version_number: 'mc1.21.11-0.8.2-fabric',
version_type: 'release',
changelog:
'This release fixes a critical bug with FRAPI, as well as allowing mods to set non-monochrome icons.\n\n## Changes\n- Fixed FRAPI compatibility issues\n- Added support for non-monochrome mod icons\n- Various performance improvements',
date_published: '2025-12-22T20:35:06.214284Z',
downloads: 150000,
status: 'listed',
files: [],
dependencies: [],
game_versions: ['1.21.11'],
loaders: ['fabric', 'quilt'],
},
{
id: '8jueyeK2',
project_id: 'AANobbMI',
author_id: 'TEZXhE2U',
featured: false,
name: 'Sodium 0.8.2 for NeoForge 1.21.11',
version_number: 'mc1.21.11-0.8.2-neoforge',
version_type: 'release',
changelog:
'This release fixes a critical bug with FRAPI, as well as allowing mods to set non-monochrome icons.',
date_published: '2025-12-22T20:34:32.101126Z',
downloads: 80000,
status: 'listed',
files: [],
dependencies: [],
game_versions: ['1.21.11'],
loaders: ['neoforge'],
},
{
id: '2IxKzI1o',
project_id: 'AANobbMI',
author_id: 'TEZXhE2U',
featured: false,
name: 'Sodium 0.8.1 for Fabric 1.21.11',
version_number: 'mc1.21.11-0.8.1-fabric',
version_type: 'release',
changelog:
'This release adds support for the Fabric Rendering API on 1.21.11, works around AMD driver bugs, and fixes configuration screen issues.\n\n## Bug Fixes\n- Fixed AMD driver compatibility\n- Fixed configuration screen crashes\n- Improved FRAPI support',
date_published: '2025-12-18T03:16:30.884738Z',
downloads: 250000,
status: 'listed',
files: [],
dependencies: [],
game_versions: ['1.21.11'],
loaders: ['fabric', 'quilt'],
},
{
id: 'MLXdfyIk',
project_id: 'AANobbMI',
author_id: 'TEZXhE2U',
featured: false,
name: 'Sodium 0.8.0 for Fabric 1.21.11',
version_number: 'mc1.21.11-0.8.0-fabric',
version_type: 'beta',
changelog:
'This release brings many bug fixes, a brand new configuration screen, and support for Minecraft 1.21.11.\n\n## New Features\n- Completely redesigned configuration screen\n- Support for Minecraft 1.21.11\n\n## Bug Fixes\n- Fixed various rendering issues\n- Improved memory usage',
date_published: '2025-12-09T17:11:11.360476Z',
downloads: 180000,
status: 'listed',
files: [],
dependencies: [],
game_versions: ['1.21.11'],
loaders: ['fabric', 'quilt'],
},
{
id: 'sFfidWgd',
project_id: 'AANobbMI',
author_id: 'TEZXhE2U',
featured: false,
name: 'Sodium 0.7.3 for Fabric 1.21.10',
version_number: 'mc1.21.10-0.7.3-fabric',
version_type: 'release',
changelog: 'This release fixes a stuttering issue affecting Intel cards.',
date_published: '2025-11-10T18:51:15.477709Z',
downloads: 320000,
status: 'listed',
files: [],
dependencies: [],
game_versions: ['1.21.9', '1.21.10'],
loaders: ['fabric', 'quilt'],
},
{
id: '24jH02Sf',
project_id: 'AANobbMI',
author_id: 'TEZXhE2U',
featured: false,
name: 'Sodium 0.7.0 for Fabric 1.21.8',
version_number: 'mc1.21.8-0.7.0-fabric',
version_type: 'alpha',
changelog:
'Major performance optimizations with quad splitting translucency sorting, improved chunk meshing, terrain rendering enhancements, and entity/particle performance improvements.\n\n## Performance\n- 30% faster chunk rendering\n- Reduced memory allocations\n- Better CPU utilization',
date_published: '2025-09-30T15:07:01.867787Z',
downloads: 450000,
status: 'listed',
files: [],
dependencies: [],
game_versions: ['1.21.6', '1.21.7', '1.21.8'],
loaders: ['fabric', 'quilt'],
},
]
// Real version data from Modrinth API - Cobblemon modpack
const cobblemonVersions: Labrinth.Versions.v2.Version[] = [
{
id: 'DbQNxSJ0',
project_id: '5FFgwNNP',
author_id: 'AEFONbAM',
featured: true,
name: 'Cobblemon Official Modpack [Fabric] 1.7.1',
version_number: '1.7.1',
version_type: 'release',
changelog:
'Updated to Cobblemon 1.7.1.\n\n## Modified Mods\n- EMF\n- ETF\n- Balm\n- FancyMenu\n- JEED\n- JEI',
date_published: '2025-11-29T02:27:41.839520Z',
downloads: 85000,
status: 'listed',
files: [],
dependencies: [],
game_versions: ['1.21.1'],
loaders: ['fabric'],
},
{
id: 'jMz7A3RO',
project_id: '5FFgwNNP',
author_id: 'AEFONbAM',
featured: false,
name: 'Cobblemon Official Modpack [Fabric] 1.7',
version_number: '1.7',
version_type: 'release',
changelog:
'Updated to Cobblemon 1.7.\n\n## Changes\n- Removed Medal\n- Updated Fabric API\n- Updated JEI\n- Updated rendering mods',
date_published: '2025-11-22T00:48:57.491974Z',
downloads: 120000,
status: 'listed',
files: [],
dependencies: [],
game_versions: ['1.21.1'],
loaders: ['fabric'],
},
{
id: '98odLiu9',
project_id: '5FFgwNNP',
author_id: 'AEFONbAM',
featured: false,
name: 'Cobblemon Official Modpack [Fabric] 1.6.1.4',
version_number: '1.6.1.4',
version_type: 'release',
changelog: 'Updated Medal to 1.0.3 to resolve a crash issue.',
date_published: '2025-07-01T04:32:02.692075Z',
downloads: 95000,
status: 'listed',
files: [],
dependencies: [],
game_versions: ['1.21.1'],
loaders: ['fabric'],
},
{
id: 'ZGcN3At3',
project_id: '5FFgwNNP',
author_id: 'AEFONbAM',
featured: false,
name: 'Cobblemon Official Modpack [Fabric] 1.6.1.1',
version_number: '1.6.1.1',
version_type: 'beta',
changelog:
'## Added Mods\n- Advanced Loot Info\n- CIT Resewn\n- EMI variants\n- Medal\n- Tips\n\n## Removed Mods\n- Architectury\n- REI',
date_published: '2025-06-11T01:10:12.921145Z',
downloads: 78000,
status: 'listed',
files: [],
dependencies: [],
game_versions: ['1.21.1'],
loaders: ['fabric'],
},
{
id: 'cqaC80tF',
project_id: '5FFgwNNP',
author_id: 'AEFONbAM',
featured: false,
name: 'Cobblemon Official Modpack [Fabric] 1.6.1',
version_number: '1.6.1',
version_type: 'release',
changelog: 'Updated to Cobblemon 1.6.1 release.',
date_published: '2025-01-26T06:37:28.977532Z',
downloads: 210000,
status: 'listed',
files: [],
dependencies: [],
game_versions: ['1.21.1'],
loaders: ['fabric'],
},
{
id: 'bpaivauC',
project_id: '5FFgwNNP',
author_id: 'AEFONbAM',
featured: false,
name: 'Cobblemon Official Modpack [Fabric] 1.5.2',
version_number: '1.5.2',
version_type: 'release',
changelog: 'Updated to Cobblemon 1.5.2. Adjusted InvMove defaults to prevent REI conflicts.',
date_published: '2024-05-27T07:12:36.043005Z',
downloads: 350000,
status: 'listed',
files: [],
dependencies: [],
game_versions: ['1.20.1'],
loaders: ['fabric'],
},
]
const meta = {
title: 'Instances/ContentUpdaterModal',
component: ContentUpdaterModal,
parameters: {
layout: 'centered',
},
argTypes: {
versions: {
control: 'object',
description: 'Array of versions to display',
},
currentGameVersion: {
control: 'text',
description: 'Current game version for compatibility checking',
},
currentLoader: {
control: 'text',
description: 'Current loader for compatibility checking',
},
currentVersionId: {
control: 'text',
description: 'ID of the currently installed version',
},
header: {
control: 'text',
description: 'Modal header text',
},
},
} satisfies Meta<typeof ContentUpdaterModal>
export default meta
type Story = StoryObj<typeof meta>
// ============================================
// Mod Example (Sodium)
// ============================================
export const ModExample: Story = {
render: (args) => ({
components: { ContentUpdaterModal, ButtonStyled },
setup() {
const modalRef = ref<InstanceType<typeof ContentUpdaterModal> | null>(null)
const openModal = () => modalRef.value?.show()
const handleUpdate = (version: Labrinth.Versions.v2.Version) => {
console.log('Update to version:', version)
alert(`Updating to ${version.name}`)
}
return { args, modalRef, openModal, handleUpdate }
},
template: /*html*/ `
<div>
<ButtonStyled color="brand">
<button @click="openModal">Update Sodium</button>
</ButtonStyled>
<ContentUpdaterModal
ref="modalRef"
v-bind="args"
@update="handleUpdate"
@cancel="() => console.log('Cancelled')"
/>
</div>
`,
}),
args: {
versions: sodiumVersions,
currentGameVersion: '1.21.11',
currentLoader: 'fabric',
currentVersionId: '2IxKzI1o', // 0.8.1 is current
header: 'Update mod',
onUpdate: fn(),
onCancel: fn(),
},
}
// ============================================
// Modpack Example (Cobblemon)
// ============================================
export const ModpackExample: Story = {
render: (args) => ({
components: { ContentUpdaterModal, ButtonStyled },
setup() {
const modalRef = ref<InstanceType<typeof ContentUpdaterModal> | null>(null)
const openModal = () => modalRef.value?.show()
const handleUpdate = (version: Labrinth.Versions.v2.Version) => {
console.log('Update to version:', version)
alert(`Updating to ${version.name}`)
}
return { args, modalRef, openModal, handleUpdate }
},
template: /*html*/ `
<div>
<ButtonStyled color="brand">
<button @click="openModal">Update Cobblemon Modpack</button>
</ButtonStyled>
<ContentUpdaterModal
ref="modalRef"
v-bind="args"
@update="handleUpdate"
@cancel="() => console.log('Cancelled')"
/>
</div>
`,
}),
args: {
versions: cobblemonVersions,
currentGameVersion: '1.21.1',
currentLoader: 'fabric',
currentVersionId: 'jMz7A3RO', // 1.7 is current
header: 'Update modpack',
onUpdate: fn(),
onCancel: fn(),
},
}
// ============================================
// With Incompatible Versions
// ============================================
export const WithIncompatibleVersions: Story = {
render: (args) => ({
components: { ContentUpdaterModal, ButtonStyled },
setup() {
const modalRef = ref<InstanceType<typeof ContentUpdaterModal> | null>(null)
const openModal = () => modalRef.value?.show()
return { args, modalRef, openModal }
},
template: /*html*/ `
<div>
<ButtonStyled color="brand">
<button @click="openModal">Update (Shows Incompatible)</button>
</ButtonStyled>
<ContentUpdaterModal
ref="modalRef"
v-bind="args"
@update="(v) => console.log('Update:', v)"
/>
</div>
`,
}),
args: {
versions: sodiumVersions,
currentGameVersion: '1.21.10', // Older version - some versions won't be compatible
currentLoader: 'fabric',
currentVersionId: 'sFfidWgd',
header: 'Update mod',
},
}
// ============================================
// All Version Types (Release, Beta, Alpha)
// ============================================
export const AllVersionTypes: Story = {
render: (args) => ({
components: { ContentUpdaterModal, ButtonStyled },
setup() {
const modalRef = ref<InstanceType<typeof ContentUpdaterModal> | null>(null)
const openModal = () => modalRef.value?.show()
return { args, modalRef, openModal }
},
template: /*html*/ `
<div>
<ButtonStyled color="brand">
<button @click="openModal">View All Version Types</button>
</ButtonStyled>
<ContentUpdaterModal
ref="modalRef"
v-bind="args"
@update="(v) => console.log('Update:', v)"
/>
</div>
`,
}),
args: {
// Sodium has release, beta, and alpha versions
versions: sodiumVersions,
currentGameVersion: '1.21.11',
currentLoader: 'fabric',
currentVersionId: '24jH02Sf', // Alpha version is current
header: 'Update mod',
},
}

View File

@@ -211,3 +211,31 @@ export const NotClosable: Story = {
`,
}),
}
export const NoPadding: Story = {
render: () => ({
components: { NewModal, ButtonStyled },
setup() {
const modalRef = ref<InstanceType<typeof NewModal> | null>(null)
const openModal = () => modalRef.value?.show()
return { modalRef, openModal }
},
template: `
<div>
<ButtonStyled color="brand">
<button @click="openModal">Open Modal (No Padding)</button>
</ButtonStyled>
<NewModal ref="modalRef" header="No Padding Modal" no-padding>
<p>This modal has no default padding on the content area.</p>
<template #actions>
<div class="flex gap-2 justify-end p-6 pt-0">
<ButtonStyled color="brand">
<button @click="modalRef?.hide()">Close</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</div>
`,
}),
}

View File

@@ -0,0 +1,22 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import ProjectCombobox from '../../components/project/ProjectCombobox.vue'
const meta = {
title: 'Project/ProjectCombobox',
component: ProjectCombobox,
} satisfies Meta<typeof ProjectCombobox>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: {
placeholder: 'Select project',
searchPlaceholder: 'Search by name or paste ID...',
loadingMessage: 'Loading...',
noResultsMessage: 'No results found',
disabled: false,
limit: 20,
},
}

View File

@@ -337,6 +337,38 @@ export const commonMessages = defineMessages({
id: 'label.yes',
defaultMessage: 'Yes',
},
updateAvailableLabel: {
id: 'label.update-available',
defaultMessage: 'Update available',
},
changelogLabel: {
id: 'label.changelog',
defaultMessage: 'Changelog',
},
updateButton: {
id: 'button.update',
defaultMessage: 'Update',
},
contentLabel: {
id: 'label.content',
defaultMessage: 'Content',
},
versionLabel: {
id: 'label.version',
defaultMessage: 'Version',
},
projectLabel: {
id: 'label.project',
defaultMessage: 'Project',
},
actionsLabel: {
id: 'label.actions',
defaultMessage: 'Actions',
},
noItemsLabel: {
id: 'label.no-items',
defaultMessage: 'No items',
},
})
export const formFieldLabels = defineMessages({

View File

@@ -7,4 +7,5 @@ export * from './notices'
export * from './savable'
export * from './search'
export * from './tag-messages'
export * from './truncate'
export * from './vue-children'

View File

@@ -0,0 +1,27 @@
import type { Ref } from 'vue'
import { unref } from 'vue'
/**
* Checks if an element's content is truncated (showing ellipsis).
* Returns the tooltip text if truncated, undefined otherwise.
*
* @param element - HTMLElement, Ref<HTMLElement>, or null
* @param tooltipText - Text to show in tooltip when truncated
* @returns The tooltip text if element is truncated, undefined otherwise
*
* @example
* ```vue
* <span ref="titleRef" class="truncate" v-tooltip="truncatedTooltip(titleRef, project.title)">
* {{ project.title }}
* </span>
* ```
*/
export function truncatedTooltip(
element: HTMLElement | Ref<HTMLElement | null> | null | undefined,
tooltipText: string,
): string | undefined {
const el = unref(element)
if (!el) return undefined
return el.scrollWidth > el.clientWidth ? tooltipText : undefined
}

644
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff