feat: shared components for worlds + p2p instances (#5135)

* feat: base content card component

* fix: tooltips + colors

* feat: fix orgs

* feat: add ContentModpackCard

* fix: extract types

* feat: selection v-model

* add show icon in selected for combobox with stories

* feat: add project combobox

* clean up project combobox

* feat: start install to play modal

* fix: events

* feat: figma alignments

* feat: migrate toggle to tailwind

* fix: row borders

* feat: disabled state

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

* fix: lint

* feat: virtualization + smaller contentcard items

* feat: fix gap + border issues on last elm

* fix: use TeleportOverflowMenu

* fix: hasUpdate type

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

* fix: storybook

* feat: start on updater modal

* feat: finish content updater modal

* feat: i18n pass

* remove install to play modal from ui package

* pnpm prepr

* feat: reusable table component

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

* feat: add table overflow menu story example

* feat: add surface-1.5 and use in table

* chore: export table in index

* fix: allow more loose typing on columns

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

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

* fix: lint

* feat: add no padding story for new modal

---------

Signed-off-by: Calum H. <contact@cal.engineer>
Co-authored-by: tdgao <mr.trumgao@gmail.com>
This commit is contained in:
Calum H.
2026-01-28 20:09:24 +00:00
committed by GitHub
parent 728f8db7b9
commit 78aca7e5c0
52 changed files with 4097 additions and 939 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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