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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user