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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -274,10 +274,6 @@ watch(
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.stylized-toggle:checked::after {
|
||||
background: var(--color-accent-contrast) !important;
|
||||
}
|
||||
|
||||
.button-base:active {
|
||||
scale: none !important;
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
`,
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
172
packages/ui/src/components/base/Table.vue
Normal file
172
packages/ui/src/components/base/Table.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
198
packages/ui/src/components/instances/ContentCardItem.vue
Normal file
198
packages/ui/src/components/instances/ContentCardItem.vue
Normal 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>
|
||||
292
packages/ui/src/components/instances/ContentCardTable.vue
Normal file
292
packages/ui/src/components/instances/ContentCardTable.vue
Normal 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>
|
||||
183
packages/ui/src/components/instances/ContentModpackCard.vue
Normal file
183
packages/ui/src/components/instances/ContentModpackCard.vue
Normal 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>
|
||||
18
packages/ui/src/components/instances/index.ts
Normal file
18
packages/ui/src/components/instances/index.ts
Normal 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'
|
||||
@@ -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>
|
||||
47
packages/ui/src/components/instances/types.ts
Normal file
47
packages/ui/src/components/instances/types.ts
Normal 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
|
||||
}
|
||||
@@ -78,7 +78,7 @@ const props = defineProps({
|
||||
},
|
||||
proceedIcon: {
|
||||
type: Object,
|
||||
default: TrashIcon,
|
||||
default: () => TrashIcon,
|
||||
},
|
||||
proceedLabel: {
|
||||
type: String,
|
||||
|
||||
@@ -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,
|
||||
|
||||
183
packages/ui/src/components/project/ProjectCombobox.vue
Normal file
183
packages/ui/src/components/project/ProjectCombobox.vue
Normal 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>
|
||||
@@ -95,7 +95,7 @@ interface Option {
|
||||
}
|
||||
|
||||
type Divider = {
|
||||
divider: true
|
||||
divider?: boolean
|
||||
shown?: boolean
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
455
packages/ui/src/stories/base/Table.stories.ts
Normal file
455
packages/ui/src/stories/base/Table.stories.ts
Normal 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>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
@@ -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>
|
||||
`,
|
||||
}),
|
||||
|
||||
469
packages/ui/src/stories/instances/ContentCardTable.stories.ts
Normal file
469
packages/ui/src/stories/instances/ContentCardTable.stories.ts
Normal 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>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
633
packages/ui/src/stories/instances/ContentModpackCard.stories.ts
Normal file
633
packages/ui/src/stories/instances/ContentModpackCard.stories.ts
Normal 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 (<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,
|
||||
},
|
||||
}
|
||||
411
packages/ui/src/stories/instances/ContentUpdaterModal.stories.ts
Normal file
411
packages/ui/src/stories/instances/ContentUpdaterModal.stories.ts
Normal 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',
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
`,
|
||||
}),
|
||||
}
|
||||
|
||||
22
packages/ui/src/stories/project/ProjectCombobox.stories.ts
Normal file
22
packages/ui/src/stories/project/ProjectCombobox.stories.ts
Normal 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,
|
||||
},
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -7,4 +7,5 @@ export * from './notices'
|
||||
export * from './savable'
|
||||
export * from './search'
|
||||
export * from './tag-messages'
|
||||
export * from './truncate'
|
||||
export * from './vue-children'
|
||||
|
||||
27
packages/ui/src/utils/truncate.ts
Normal file
27
packages/ui/src/utils/truncate.ts
Normal 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
644
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user