refactor: content tab styling (#7091)

* refactor: managed content card

* fix: update filter pill styling to be chips-like

* fix: modpack exporting

* fixed: vanilla server projs + header

* refactor: filters

* refactor: detect external file icons + other metadata inside

* refactor: support json5 metadata

* fix: qa

* fix: qa

* fix: fmt + prepr

* fix: fmt

* fix: qa

* fix: headers

* feat: locking

* fix: clippy

* fix: qa

* fix: frozen loc

* fix: shared instances diff calc break

* fix: env filter + remove categories
This commit is contained in:
Calum H.
2026-08-13 14:55:35 +00:00
committed by GitHub
parent e087e571f3
commit 06b7c44dcc
126 changed files with 3654 additions and 3473 deletions
@@ -8,103 +8,126 @@
<template v-else>{{ label }}</template>
</span>
<MultiSelect
v-for="preview in appliedFilterPreviews"
:key="preview.key"
class="min-w-0 max-w-full"
:model-value="getPreviewSelectedValues(preview.key)"
:options="preview.options"
:max-height="500"
:clearable="false"
:show-chevron="false"
:fit-content="true"
:searchable="preview.category.searchable"
:search-placeholder="preview.category.searchPlaceholder"
trigger-type="base"
trigger-size="lg"
:trigger-class="effectivePreviewTriggerClass"
:dropdown-width="getPreviewDropdownWidth(preview.category)"
:dropdown-min-width="getPreviewDropdownMinWidth(preview.category)"
:checkbox-position="checkboxPosition"
show-selection-actions
@update:model-value="(nextValue) => setPreviewSelectedValues(preview.key, nextValue)"
@open="openPreviewFilterDraft(preview.key)"
@close="commitPreviewFilterDraft(preview.key)"
>
<template v-if="$slots['preview-top'] && preview.category.syntheticOptions?.length" #top>
<slot
name="preview-top"
:category="preview.category"
:selected-values="getPreviewSelectedValues(preview.key)"
:set-selected-values="(values) => setPreviewSelectedValues(preview.key, values)"
:close-menu="(event) => closePreviewFilterMenu(preview.key, event)"
></slot>
</template>
<template v-if="$slots['search-actions']" #search-actions>
<slot
name="search-actions"
:category="preview.category"
:selected-values="getPreviewSelectedValues(preview.key)"
:set-selected-values="(values) => setPreviewSelectedValues(preview.key, values)"
:close-menu="(event) => closePreviewFilterMenu(preview.key, event)"
></slot>
</template>
<template #input-content="{ isOpen, openDirection }">
<div class="flex min-h-8 min-w-0 max-w-full items-center gap-2 sm:max-w-80">
<FilterIcon
v-if="showPreviewFilterIcon"
class="size-5 shrink-0 text-primary"
aria-hidden="true"
/>
<span class="min-w-0 flex-1 truncate">
<span class="font-medium">{{ preview.label }}:</span>
<span class="ml-1 font-semibold text-contrast">{{ preview.summary }}</span>
</span>
<div class="flex shrink-0 items-center gap-1.5">
<button
type="button"
class="flex cursor-pointer items-center justify-center rounded border-none bg-transparent p-0.5 text-secondary transition-colors hover:text-contrast"
:aria-label="`Clear ${preview.label} filter`"
@click.stop="clearFilterCategory(preview.key)"
>
<XIcon class="size-4 text-primary" />
</button>
<div class="h-5 w-[1px] shrink-0 bg-surface-5"></div>
<ChevronLeftIcon
class="size-5 shrink-0 text-secondary transition-transform duration-150"
:class="isOpen ? (openDirection === 'down' ? 'rotate-90' : '-rotate-90') : '-rotate-90'"
<template v-for="preview in appliedFilterPreviews" :key="preview.key">
<Button
v-if="preview.category.direct"
type="outlined"
native-type="button"
size="lg"
:class="effectivePreviewTriggerClass"
:aria-label="`Clear ${preview.label} filter`"
@click="clearFilterCategory(preview.key)"
>
<span class="font-semibold">{{ preview.label }}</span>
<XIcon class="size-4" />
</Button>
<MultiSelect
v-else
class="min-w-0 max-w-full"
:model-value="getPreviewSelectedValues(preview.key)"
:options="preview.options"
:max-height="500"
:clearable="false"
:show-chevron="false"
:fit-content="true"
:searchable="preview.category.searchable"
:search-placeholder="preview.category.searchPlaceholder"
trigger-type="base"
trigger-size="lg"
:trigger-class="effectivePreviewTriggerClass"
:dropdown-width="getPreviewDropdownWidth(preview.category)"
:dropdown-min-width="getPreviewDropdownMinWidth(preview.category)"
:checkbox-position="checkboxPosition"
show-selection-actions
@update:model-value="(nextValue) => setPreviewSelectedValues(preview.key, nextValue)"
@open="openPreviewFilterDraft(preview.key)"
@close="commitPreviewFilterDraft(preview.key)"
>
<template v-if="$slots['preview-top'] && preview.category.syntheticOptions?.length" #top>
<slot
name="preview-top"
:category="preview.category"
:selected-values="getPreviewSelectedValues(preview.key)"
:set-selected-values="(values) => setPreviewSelectedValues(preview.key, values)"
:close-menu="(event) => closePreviewFilterMenu(preview.key, event)"
></slot>
</template>
<template v-if="$slots['search-actions']" #search-actions>
<slot
name="search-actions"
:category="preview.category"
:selected-values="getPreviewSelectedValues(preview.key)"
:set-selected-values="(values) => setPreviewSelectedValues(preview.key, values)"
:close-menu="(event) => closePreviewFilterMenu(preview.key, event)"
></slot>
</template>
<template #input-content="{ isOpen, openDirection }">
<div class="flex min-h-8 min-w-0 max-w-full items-center gap-2 sm:max-w-80">
<FilterIcon
v-if="showPreviewFilterIcon"
class="size-5 shrink-0 text-primary"
aria-hidden="true"
/>
<slot
name="preview-content"
:category="preview.category"
:selected-values="getPreviewSelectedValues(preview.key)"
:label="preview.label"
:summary="preview.summary"
>
<span class="min-w-0 flex-1 truncate">
<span class="font-medium">{{ preview.label }}:</span>
<span class="ml-1 font-semibold text-contrast">{{ preview.summary }}</span>
</span>
</slot>
<div class="flex shrink-0 items-center gap-1.5">
<button
type="button"
class="flex cursor-pointer items-center justify-center rounded border-none bg-transparent p-0.5 text-secondary transition-colors hover:text-contrast"
:aria-label="`Clear ${preview.label} filter`"
@click.stop="clearFilterCategory(preview.key)"
>
<XIcon class="size-4 text-primary" />
</button>
<div class="h-5 w-[1px] shrink-0 bg-surface-5"></div>
<ChevronLeftIcon
class="size-5 shrink-0 text-secondary transition-transform duration-150"
:class="
isOpen ? (openDirection === 'down' ? 'rotate-90' : '-rotate-90') : '-rotate-90'
"
/>
</div>
</div>
</div>
</template>
<template v-if="$slots['preview-footer']" #bottom>
<slot
name="preview-footer"
:category="preview.category"
:selected-values="getPreviewSelectedValues(preview.key)"
:set-selected-values="(values) => setPreviewSelectedValues(preview.key, values)"
:close-menu="(event) => closePreviewFilterMenu(preview.key, event)"
></slot>
</template>
<template v-if="$slots.option" #option="{ item, selected, index }">
<slot
name="option"
:category="preview.category"
:option="item"
:selected="selected"
:index="index"
></slot>
</template>
<template v-else-if="$slots['option-right']" #option-right="{ item, selected, index }">
<slot
name="option-right"
:category="preview.category"
:option="item"
:selected="selected"
:index="index"
></slot>
</template>
</MultiSelect>
</template>
<template v-if="$slots['preview-footer']" #bottom>
<slot
name="preview-footer"
:category="preview.category"
:selected-values="getPreviewSelectedValues(preview.key)"
:set-selected-values="(values) => setPreviewSelectedValues(preview.key, values)"
:close-menu="(event) => closePreviewFilterMenu(preview.key, event)"
></slot>
</template>
<template v-if="$slots.option" #option="{ item, selected, index }">
<slot
name="option"
:category="preview.category"
:option="item"
:selected="selected"
:index="index"
></slot>
</template>
<template v-else-if="$slots['option-right']" #option-right="{ item, selected, index }">
<slot
name="option-right"
:category="preview.category"
:option="item"
:selected="selected"
:index="index"
></slot>
</template>
</MultiSelect>
</template>
<div class="flex h-10 min-w-0 max-w-full items-center gap-2">
<Button
@@ -132,7 +155,7 @@
<div
v-if="isAddMenuOpen && !isMobileActiveSubmenu"
ref="menuContainer"
class="fixed z-[9999] flex flex-col overflow-hidden rounded-[14px] border border-solid border-surface-5 bg-surface-4 shadow-2xl"
class="fixed z-[9999] flex flex-col overflow-x-hidden overflow-y-auto rounded-[14px] border border-solid border-surface-5 bg-surface-4 shadow-2xl"
:style="addMenuStyle"
role="menu"
@mousedown.stop
@@ -145,15 +168,24 @@
:ref="(element) => setCategoryButtonRef(category.key, element)"
type="button"
class="group/filter-menu-button flex h-12 w-full appearance-none items-center justify-between gap-1 border-0 px-4 text-left text-base font-semibold text-primary shadow-none transition-all duration-150 hover:brightness-110 focus:brightness-110 bg-surface-4"
:class="category.key === activeCategoryKey ? '!brightness-110' : ''"
role="menuitem"
@click="activateCategory(category.key)"
@mouseenter="handleCategoryMouseEnter(category.key)"
@focus="handleCategoryFocus(category.key)"
:class="
category.key === activeCategoryKey || isDirectCategorySelected(category)
? '!brightness-110'
: ''
"
:role="category.direct ? 'menuitemcheckbox' : 'menuitem'"
:aria-checked="category.direct ? isDirectCategorySelected(category) : undefined"
@click="handleCategoryClick(category)"
@mouseenter="handleCategoryMouseEnter(category)"
@focus="handleCategoryFocus(category)"
>
<span>{{ category.label }}</span>
<div class="flex items-center gap-1">
<ChevronRightIcon class="size-5 text-secondary" />
<CheckIcon
v-if="category.direct && isDirectCategorySelected(category)"
class="size-5 text-brand"
/>
<ChevronRightIcon v-else-if="!category.direct" class="size-5 text-secondary" />
</div>
</button>
</div>
@@ -417,6 +449,7 @@ export type DropdownFilterBarCategory = {
key: string
label: string
options: DropdownFilterBarItem[]
direct?: boolean
syntheticOptions?: DropdownFilterBarOption[]
searchable?: boolean
disableLocalOptionsFilter?: boolean
@@ -461,6 +494,7 @@ type ViewportRect = {
type MenuPositionOptions = {
triggerRect: DOMRect
dropdownRect: DOMRect
dropdownHeight: number
viewport: ViewportRect
}
@@ -1341,7 +1375,42 @@ function getSubmenuWidthInPixels(category: DropdownFilterBarCategory): number {
}
function getWidestSubmenuWidthInPixels(categories: DropdownFilterBarCategory[]): number {
return Math.max(...categories.map((category) => getSubmenuWidthInPixels(category)), 0)
return Math.max(
...categories
.filter((category) => !category.direct)
.map((category) => getSubmenuWidthInPixels(category)),
0,
)
}
function getDirectCategoryOption(category: DropdownFilterBarCategory) {
if (!category.direct) return undefined
return category.options.find(isDropdownFilterOption)
}
function isDirectCategorySelected(category: DropdownFilterBarCategory) {
const option = getDirectCategoryOption(category)
return !!option && isFilterValueSelected(category.key, option.value)
}
function deactivateCategory() {
clearPendingCategoryTimeout()
pendingCategoryKey.value = null
activeCategoryKey.value = null
categorySearchQuery.value = ''
hasSubmenuPosition.value = false
}
function handleCategoryClick(category: DropdownFilterBarCategory) {
const option = getDirectCategoryOption(category)
if (!option) {
activateCategory(category.key)
return
}
deactivateCategory()
toggleFilterOption(category.key, option)
scheduleAddMenuPositionUpdate()
}
function activateCategory(categoryKey: string) {
@@ -1357,18 +1426,27 @@ function activateCategory(categoryKey: string) {
}
}
function handleCategoryFocus(categoryKey: string) {
function handleCategoryFocus(category: DropdownFilterBarCategory) {
if (category.direct) {
deactivateCategory()
return
}
if (isMobileAddMenuLayout.value) {
return
}
activateCategory(categoryKey)
activateCategory(category.key)
}
function handleCategoryMouseEnter(categoryKey: string) {
function handleCategoryMouseEnter(category: DropdownFilterBarCategory) {
if (category.direct) {
deactivateCategory()
return
}
if (isMobileAddMenuLayout.value) {
return
}
const categoryKey = category.key
if (!activeCategoryKey.value) {
activateCategory(categoryKey)
@@ -1448,7 +1526,12 @@ function getViewportRect(): ViewportRect {
}
}
function getAddMenuPosition({ triggerRect, dropdownRect, viewport }: MenuPositionOptions) {
function getAddMenuPosition({
triggerRect,
dropdownRect,
dropdownHeight,
viewport,
}: MenuPositionOptions) {
const dropdownWidth = Math.max(ADD_MENU_WIDTH, triggerRect.width)
const positionedDropdownWidth = Math.max(dropdownRect.width, dropdownWidth)
const triggerTop = triggerRect.top + viewport.offsetTop
@@ -1463,18 +1546,24 @@ function getAddMenuPosition({ triggerRect, dropdownRect, viewport }: MenuPositio
minLeft,
viewportRight - positionedDropdownWidth - DROPDOWN_VIEWPORT_MARGIN,
)
const hasSpaceBelow =
triggerBottom + dropdownRect.height + DROPDOWN_GAP + DROPDOWN_VIEWPORT_MARGIN <= viewportBottom
const hasSpaceAbove =
triggerTop - dropdownRect.height - DROPDOWN_GAP - DROPDOWN_VIEWPORT_MARGIN > viewportTop
const opensUp = !hasSpaceBelow && hasSpaceAbove
const spaceBelow = Math.max(
0,
viewportBottom - triggerBottom - DROPDOWN_GAP - DROPDOWN_VIEWPORT_MARGIN,
)
const spaceAbove = Math.max(0, triggerTop - viewportTop - DROPDOWN_GAP - DROPDOWN_VIEWPORT_MARGIN)
const hasSpaceBelow = dropdownHeight <= spaceBelow
const hasSpaceAbove = dropdownHeight <= spaceAbove
const opensUp = !hasSpaceBelow && (hasSpaceAbove || spaceAbove > spaceBelow)
const availableHeight = opensUp ? spaceAbove : spaceBelow
const positionedDropdownHeight = Math.min(dropdownHeight, availableHeight)
const top = opensUp
? triggerTop - dropdownRect.height - DROPDOWN_GAP
? triggerTop - positionedDropdownHeight - DROPDOWN_GAP
: triggerBottom + DROPDOWN_GAP
const left = Math.min(Math.max(minLeft, triggerLeft), maxLeft)
return {
left: `${left}px`,
maxHeight: `${availableHeight}px`,
minWidth: `${triggerRect.width}px`,
top: `${Math.max(viewportTop + DROPDOWN_VIEWPORT_MARGIN, top)}px`,
width: `${dropdownWidth}px`,
@@ -1601,6 +1690,7 @@ function updateAddMenuPosition(): boolean {
addMenuStyle.value = getAddMenuPosition({
triggerRect,
dropdownRect,
dropdownHeight: positioningElement.scrollHeight,
viewport,
})
return true
+30 -20
View File
@@ -1,22 +1,26 @@
<template>
<div class="flex flex-wrap items-center gap-1.5">
<FilterIcon class="size-5 text-secondary" />
<button
:class="pillClass(modelValue.length === 0)"
:aria-pressed="modelValue.length === 0"
@click="modelValue = []"
>
<slot name="all"> All </slot>
</button>
<button
v-for="option in options"
:key="option.id"
:class="pillClass(modelValue.includes(option.id))"
:aria-pressed="modelValue.includes(option.id)"
@click="toggle(option.id)"
>
{{ option.label }}
</button>
<div class="flex items-center gap-2">
<FilterIcon class="size-5 shrink-0 text-secondary" />
<div class="filter-pills__chips flex flex-wrap items-center gap-1.5">
<button
type="button"
:class="pillClass(modelValue.length === 0)"
:aria-pressed="modelValue.length === 0"
@click="modelValue = []"
>
<slot name="all"> All </slot>
</button>
<button
v-for="option in options"
:key="option.id"
type="button"
:class="pillClass(modelValue.includes(option.id))"
:aria-pressed="modelValue.includes(option.id)"
@click="toggle(option.id)"
>
{{ option.label }}
</button>
</div>
</div>
</template>
@@ -36,10 +40,10 @@ defineProps<{
function pillClass(active: boolean) {
return [
'cursor-pointer rounded-full border border-solid px-3 py-1.5 text-base font-semibold leading-5 transition-all duration-100 active:scale-[0.97]',
'cursor-pointer rounded-xl border border-solid px-3 py-1.5 text-sm font-medium leading-5 transition-all duration-100 active:scale-[0.97] focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-brand-shadow',
active
? 'border-brand bg-brand-highlight text-brand'
: 'border-surface-5 bg-surface-4 text-primary hover:bg-surface-5',
: 'border-surface-5 bg-transparent text-primary hover:bg-surface-3',
]
}
@@ -51,3 +55,9 @@ function toggle(id: string) {
}
}
</script>
<style scoped>
.filter-pills__chips {
filter: drop-shadow(0 1px 1.5px rgba(0, 0, 0, 0.15));
}
</style>
@@ -46,7 +46,7 @@ const typeClasses: Record<ButtonType, string> = {
colored:
'button-frame--colored bg-[--button-color] text-[var(--color-accent-contrast)] [&>svg]:text-inherit',
outlined:
'button-frame--outlined bg-transparent text-[var(--button-color,var(--color-contrast))] [&>svg]:text-inherit',
'button-frame--outlined bg-transparent text-[var(--button-color,var(--color-contrast))] [&>svg]:text-[var(--button-color,var(--color-base))]',
quiet: 'button-frame--quiet bg-transparent [&>svg]:text-inherit',
}
@@ -178,7 +178,7 @@ async function handleSelectedProjectsLeaveResult(
</PageHeaderMetadata>
</template>
</PageHeader>
<Admonition v-if="installContext.warning" type="warning" class="mb-1">
<Admonition v-if="installContext.warning" type="warning" class="mt-4 mb-1">
{{ installContext.warning }}
</Admonition>
</template>
@@ -224,6 +224,9 @@ function getFilterOpenByDefault(filterId: string): boolean {
<template v-if="lockedMessages?.modLoader" #locked-mod_loader>
{{ lockedMessages.modLoader }}
</template>
<template v-if="lockedMessages?.modLoader" #locked-shader_loader>
{{ lockedMessages.modLoader }}
</template>
<template v-if="lockedMessages?.environment" #locked-environment>
{{ lockedMessages.environment }}
</template>
@@ -2,11 +2,13 @@
import {
ArrowLeftRightIcon,
DownloadIcon,
LockIcon,
MoreVerticalIcon,
SpinnerIcon,
TrashExclamationIcon,
TrashIcon,
TriangleAlertIcon,
UploadIcon,
} from '@modrinth/assets'
import { useMagicKeys } from '@vueuse/core'
import { computed, getCurrentInstance, ref } from 'vue'
@@ -38,6 +40,14 @@ const messages = defineMessages({
id: 'content.card.select-project',
defaultMessage: 'Select {project}',
},
uploaded: {
id: 'content.card.uploaded',
defaultMessage: 'Uploaded',
},
frozen: {
id: 'content.card.frozen',
defaultMessage: 'This project is locked to its current version until unfrozen.',
},
})
interface Props {
@@ -47,7 +57,9 @@ interface Props {
versionLink?: string | RouteLocationRaw
owner?: ContentOwner
source?: ContentSource
external?: boolean
enabled?: boolean
locked?: boolean
installing?: boolean
hasUpdate?: boolean
isClientOnly?: boolean
@@ -58,6 +70,7 @@ interface Props {
disabledTooltip?: string | null
toggleDisabled?: boolean
toggleDisabledTooltip?: string | null
hideToggle?: boolean
showCheckbox?: boolean
hideDelete?: boolean
hideActions?: boolean
@@ -70,7 +83,9 @@ const props = withDefaults(defineProps<Props>(), {
versionLink: undefined,
owner: undefined,
source: undefined,
external: false,
enabled: undefined,
locked: false,
installing: false,
hasUpdate: false,
isClientOnly: false,
@@ -81,6 +96,7 @@ const props = withDefaults(defineProps<Props>(), {
disabledTooltip: undefined,
toggleDisabled: false,
toggleDisabledTooltip: undefined,
hideToggle: false,
showCheckbox: false,
hideDelete: false,
hideActions: false,
@@ -243,7 +259,11 @@ const deleteHovered = ref(false)
/>
<span class="text-sm leading-5 text-secondary">{{ owner.name }}</span>
</AutoLink>
<template v-if="version">
<span v-else-if="external" class="flex items-center gap-1 text-secondary">
<UploadIcon class="size-4 shrink-0" />
<span class="text-sm leading-5">{{ formatMessage(messages.uploaded) }}</span>
</span>
<template v-if="version && !external">
<BulletDivider class="shrink-0 @[800px]:hidden" />
<AutoLink
:target="
@@ -277,7 +297,7 @@ const deleteHovered = ref(false)
typeof versionLink === 'string' && versionLink.startsWith('http') ? '_blank' : undefined
"
:to="versionLink"
class="inline-flex min-w-0 font-medium leading-6 text-contrast !decoration-contrast"
class="inline-flex min-w-0 font-semibold leading-6 text-contrast !decoration-contrast"
:class="{ 'hover:underline': versionLink, 'cursor-pointer': versionLink }"
>
<span ref="versionNumberRef" class="truncate">{{
@@ -309,11 +329,24 @@ const deleteHovered = ref(false)
<!-- Fixed width container to reserve space for update/switch version button -->
<div
v-if="hasUpdateListener || hasSwitchVersionListener"
v-if="
locked ||
(hasUpdateListener && hasUpdate) ||
(hasSwitchVersionListener && version && !hideSwitchVersion)
"
class="flex w-8 items-center justify-center"
>
<IconButton
v-if="hasUpdate"
v-if="locked"
v-tooltip="formatMessage(messages.frozen)"
type="quiet"
:label="formatMessage(messages.frozen)"
disabled
>
<LockIcon class="size-5" />
</IconButton>
<IconButton
v-else-if="hasUpdate"
v-tooltip="
isDisabled && disabledTooltip
? disabledTooltip
@@ -353,7 +386,7 @@ const deleteHovered = ref(false)
</div>
<Toggle
v-if="enabled !== undefined"
v-if="enabled !== undefined && !hideToggle"
v-tooltip="
isToggleDisabled && (toggleDisabledTooltip || disabledTooltip)
? (toggleDisabledTooltip ?? disabledTooltip)
@@ -265,7 +265,9 @@ function handleSort(column: ContentCardTableSortColumn) {
:version-link="item.versionLink"
:owner="item.owner"
:source="item.source"
:external="item.external"
:enabled="item.enabled"
:locked="item.locked"
:installing="item.installing"
:has-update="item.hasUpdate"
:is-client-only="item.isClientOnly"
@@ -276,6 +278,7 @@ function handleSort(column: ContentCardTableSortColumn) {
:disabled-tooltip="item.disabledTooltip"
:toggle-disabled="item.toggleDisabled"
:toggle-disabled-tooltip="item.toggleDisabledTooltip"
:hide-toggle="item.hideToggle"
:show-checkbox="showSelection"
:hide-delete="hideDelete || item.hideDelete"
:hide-actions="!hasAnyActions"
@@ -329,7 +332,9 @@ function handleSort(column: ContentCardTableSortColumn) {
:version-link="item.versionLink"
:owner="item.owner"
:source="item.source"
:external="item.external"
:enabled="item.enabled"
:locked="item.locked"
:installing="item.installing"
:has-update="item.hasUpdate"
:is-client-only="item.isClientOnly"
@@ -340,6 +345,7 @@ function handleSort(column: ContentCardTableSortColumn) {
:disabled-tooltip="item.disabledTooltip"
:toggle-disabled="item.toggleDisabled"
:toggle-disabled-tooltip="item.toggleDisabledTooltip"
:hide-toggle="item.hideToggle"
:show-checkbox="showSelection"
:hide-delete="hideDelete || item.hideDelete"
:hide-actions="!hasAnyActions"
@@ -1,306 +0,0 @@
<script setup lang="ts">
import {
BoxesIcon,
ClockIcon,
DownloadIcon,
HeartIcon,
MoreVerticalIcon,
Settings2Icon,
SpinnerIcon,
} from '@modrinth/assets'
import { computed, getCurrentInstance, onMounted, onUnmounted, ref } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import AutoLink from '#ui/components/base/AutoLink.vue'
import Avatar from '#ui/components/base/Avatar.vue'
import BulletDivider from '#ui/components/base/BulletDivider.vue'
import {
Button,
IconButton,
type OverflowMenuOption,
TeleportOverflowMenu,
} from '#ui/components/base/buttons'
import TagTagItem from '#ui/components/base/TagTagItem.vue'
import { useRelativeTime } from '#ui/composables/how-ago'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
import type {
ContentModpackCardCategory,
ContentModpackCardProject,
ContentModpackCardVersion,
ContentOwner,
} from '../types'
const { formatMessage } = useVIntl()
const messages = defineMessages({
installationSettingsTooltip: {
id: 'content.modpack-card.installation-settings',
defaultMessage: 'Installation settings',
},
})
interface Props {
project: ContentModpackCardProject
projectLink?: string | RouteLocationRaw
version?: ContentModpackCardVersion
versionLink?: string | RouteLocationRaw
owner?: ContentOwner
categories?: ContentModpackCardCategory[]
disabled?: boolean
overflowOptions?: OverflowMenuOption[]
hasUpdate?: boolean
disabledText?: string
}
withDefaults(defineProps<Props>(), {
projectLink: undefined,
version: undefined,
versionLink: undefined,
owner: undefined,
categories: undefined,
disabled: false,
overflowOptions: undefined,
hasUpdate: false,
disabledText: undefined,
})
const emit = defineEmits<{
update: []
content: []
settings: []
}>()
const instance = getCurrentInstance()
const hasUpdateListener = computed(() => typeof instance?.vnode.props?.onUpdate === 'function')
const hasContentListener = computed(() => typeof instance?.vnode.props?.onContent === 'function')
const hasSettingsListener = computed(() => typeof instance?.vnode.props?.onSettings === 'function')
const formatTimeAgo = useRelativeTime()
const formatCompact = (n: number | undefined) => {
if (n === undefined) return ''
return new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 2 }).format(n)
}
const collapsedOptions = computed<OverflowMenuOption[]>(() => {
const options: OverflowMenuOption[] = []
if (hasContentListener.value) {
options.push({
id: 'content',
label: formatMessage(commonMessages.contentLabel),
action: () => emit('content'),
})
}
if (hasSettingsListener.value) {
options.push({
id: 'settings',
label: formatMessage(messages.installationSettingsTooltip),
action: () => emit('settings'),
})
}
return options
})
const containerRef = ref<HTMLElement | null>(null)
const isExpanded = ref(true)
let observer: ResizeObserver | null = null
onMounted(() => {
observer = new ResizeObserver((entries) => {
for (const entry of entries) {
isExpanded.value = entry.contentRect.width >= 700
}
})
if (containerRef.value) observer.observe(containerRef.value)
})
onUnmounted(() => {
observer?.disconnect()
observer = null
})
</script>
<template>
<div
ref="containerRef"
class="@container flex flex-col gap-4 rounded-[20px] bg-bg-raised p-6 shadow-md border border-solid border-surface-4"
:class="{ 'opacity-50': disabled }"
>
<div class="flex flex-wrap items-start justify-between gap-4">
<div class="flex min-w-0 flex-1 items-center gap-4">
<AutoLink :to="projectLink" class="shrink-0">
<Avatar :src="project.icon_url" :alt="project.title" size="5rem" no-shadow />
</AutoLink>
<div class="flex min-w-0 flex-col gap-1.5">
<div class="flex min-w-0 flex-col">
<AutoLink
:to="projectLink"
class="truncate text-xl font-semibold text-contrast"
:class="projectLink ? 'hover:underline' : ''"
>
{{ project.title }}
</AutoLink>
<span
v-if="project.filename && (owner || version)"
class="truncate text-secondary mb-2"
>
{{ project.filename }}
</span>
</div>
<div
v-if="project.filename && !(owner || version)"
class="flex min-h-8 min-w-0 items-center text-secondary"
>
<span class="truncate">{{ project.filename }}</span>
</div>
<div
v-if="owner || version"
class="flex flex-nowrap items-center gap-2 overflow-hidden text-secondary"
>
<AutoLink
v-if="owner"
:to="owner.link"
class="flex shrink-0 items-center gap-1.5 hover:underline"
>
<Avatar
:src="owner.avatar_url"
:alt="owner.name"
size="2rem"
:circle="owner.type === 'user'"
no-shadow
/>
<span class="font-medium whitespace-nowrap">{{ owner.name }}</span>
</AutoLink>
<template v-if="version">
<BulletDivider v-if="owner" />
<AutoLink
:to="versionLink"
class="shrink-0 font-medium text-secondary !decoration-secondary whitespace-nowrap"
:class="versionLink ? 'hover:underline' : ''"
>
{{ version.version_number }}
</AutoLink>
</template>
<template v-if="version?.date_published">
<BulletDivider />
<div class="flex shrink-0 items-center gap-2 whitespace-nowrap">
<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">
<template v-if="disabled">
<div class="flex items-center gap-2 text-secondary">
<SpinnerIcon class="animate-spin" />
<span class="font-semibold">{{
disabledText ?? formatMessage(commonMessages.updatingLabel)
}}</span>
</div>
</template>
<template v-else>
<!-- Expanded actions visible at >= 700px -->
<div class="hidden @[700px]:flex items-center gap-2">
<Button
v-if="hasUpdateListener && hasUpdate"
type="quiet"
color="green"
class="flex items-center gap-2"
@click="emit('update')"
>
<DownloadIcon class="!text-green" />
<span class="font-semibold">{{ formatMessage(commonMessages.updateButton) }}</span>
</Button>
<Button v-if="hasContentListener" type="base" @click="emit('content')">
<BoxesIcon />
{{ formatMessage(commonMessages.contentLabel) }}
</Button>
<IconButton
v-if="hasSettingsListener"
v-tooltip="formatMessage(messages.installationSettingsTooltip)"
type="outlined"
:label="formatMessage(messages.installationSettingsTooltip)"
@click="emit('settings')"
>
<Settings2Icon />
</IconButton>
</div>
<!-- Collapsed actions visible at < 700px -->
<div v-if="hasUpdate && hasUpdateListener" class="flex @[700px]:hidden">
<IconButton
v-tooltip="formatMessage(commonMessages.updateButton)"
type="quiet"
color="green"
:label="formatMessage(commonMessages.updateButton)"
@click="emit('update')"
>
<DownloadIcon class="size-5" />
</IconButton>
</div>
<TeleportOverflowMenu
v-if="collapsedOptions.length"
type="outlined"
label="More options"
:options="collapsedOptions"
class="flex @[700px]:hidden"
>
<MoreVerticalIcon class="size-5" />
<template #content>
<BoxesIcon class="size-5" />
{{ formatMessage(commonMessages.contentLabel) }}
</template>
<template #settings>
<Settings2Icon class="size-5" />
{{ formatMessage(messages.installationSettingsTooltip) }}
</template>
</TeleportOverflowMenu>
<TeleportOverflowMenu
v-if="overflowOptions?.length"
type="quiet"
label="More options"
class="hidden @[700px]:flex"
:options="overflowOptions"
>
<MoreVerticalIcon class="size-5" />
</TeleportOverflowMenu>
</template>
</div>
</div>
<span v-if="project.description" class="text-secondary">
{{ project.description }}
</span>
<div
v-if="project.downloads != null || project.followers != null || categories?.length"
class="flex flex-wrap items-center gap-3"
>
<div v-if="project.downloads != null" 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 != null" 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 items-center gap-1">
<TagTagItem
v-for="cat in categories"
:key="cat.name"
:tag="cat.name"
:action="cat.action"
hide-non-loader-icon
/>
</div>
</div>
</div>
</template>
@@ -0,0 +1,112 @@
<script setup lang="ts">
import { EyeIcon, WrenchIcon } from '@modrinth/assets'
import { computed } from 'vue'
import { Button, IconButton } from '#ui/components/base/buttons'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import type { ManagedContentCardData } from '../../types'
import ManagedContentCardFooter from './managed-content-card-footer.vue'
import ManagedContentCardSummary from './managed-content-card-summary.vue'
const props = withDefaults(
defineProps<{
data: ManagedContentCardData
disabled?: boolean
disabledText?: string
showViewContent?: boolean
showSettings?: boolean
showPrimaryAction?: boolean
}>(),
{
disabled: false,
disabledText: undefined,
showViewContent: false,
showSettings: false,
showPrimaryAction: false,
},
)
const emit = defineEmits<{
'view-content': []
settings: []
'primary-action': [event: MouseEvent]
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
modpackContent: {
id: 'content.managed-card.modpack-content',
defaultMessage: 'Modpack content',
},
sharedContent: {
id: 'content.managed-card.shared-content',
defaultMessage: 'Shared content',
},
viewContent: {
id: 'content.managed-card.view-content',
defaultMessage: 'View content',
},
settings: {
id: 'content.managed-card.settings',
defaultMessage: 'Installation settings',
},
})
const title = computed(() =>
formatMessage(props.data.kind === 'modpack' ? messages.modpackContent : messages.sharedContent),
)
</script>
<template>
<section
class="@container overflow-hidden rounded-[20px] border border-solid border-surface-4 shadow-[0_1px_1px_rgba(0,0,0,0.12)]"
:aria-busy="disabled || undefined"
>
<div
class="flex min-h-[94px] flex-col items-stretch justify-between gap-4 bg-surface-3 px-[18px] py-4 @[700px]:flex-row @[700px]:items-center"
>
<div class="flex min-w-0 flex-1 flex-col gap-1.5 pl-0.5">
<h2 class="m-0 text-2xl font-semibold leading-8 text-contrast">{{ title }}</h2>
<ManagedContentCardSummary :summary="data.summary" :installing="data.installing" />
</div>
<div
v-if="showViewContent || showSettings"
class="flex shrink-0 items-center gap-2 self-start"
>
<Button
v-if="showViewContent"
type="base"
size="lg"
:disabled="disabled"
@click="emit('view-content')"
>
<EyeIcon aria-hidden="true" />
{{ formatMessage(messages.viewContent) }}
</Button>
<IconButton
v-if="showSettings"
type="outlined"
size="lg"
:disabled="disabled"
:label="formatMessage(messages.settings)"
@click="emit('settings')"
>
<WrenchIcon aria-hidden="true" />
</IconButton>
</div>
</div>
<div class="h-px bg-surface-4" />
<ManagedContentCardFooter
:data="data"
:disabled="disabled"
:disabled-text="disabledText"
:show-primary-action="showPrimaryAction"
@primary-action="emit('primary-action', $event)"
/>
</section>
</template>
@@ -0,0 +1,185 @@
<script setup lang="ts">
import {
ArrowLeftRightIcon,
ClockIcon,
DownloadIcon,
LockIcon,
SpinnerIcon,
} from '@modrinth/assets'
import { computed } from 'vue'
import AutoLink from '#ui/components/base/AutoLink.vue'
import Avatar from '#ui/components/base/Avatar.vue'
import BulletDivider from '#ui/components/base/BulletDivider.vue'
import { Button } from '#ui/components/base/buttons'
import { useFormatDateTime } from '#ui/composables/format-date-time'
import { useRelativeTime } from '#ui/composables/how-ago'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import type { ManagedContentCardData } from '../../types'
const props = withDefaults(
defineProps<{
data: ManagedContentCardData
showPrimaryAction?: boolean
disabled?: boolean
disabledText?: string
}>(),
{
showPrimaryAction: false,
disabled: false,
disabledText: undefined,
},
)
const emit = defineEmits<{
'primary-action': [event: MouseEvent]
}>()
const { formatMessage } = useVIntl()
const formatTimeAgo = useRelativeTime()
const formatDateTime = useFormatDateTime({
dateStyle: 'long',
timeStyle: 'short',
})
const messages = defineMessages({
managedBy: {
id: 'content.managed-card.managed-by',
defaultMessage: 'Managed by',
},
server: {
id: 'content.managed-card.server-suffix',
defaultMessage: 'server',
},
updated: {
id: 'content.managed-card.updated',
defaultMessage: 'Updated {time}',
},
synced: {
id: 'content.managed-card.synced',
defaultMessage: 'Synced {time}',
},
switchVersion: {
id: 'content.managed-card.switch-version',
defaultMessage: 'Switch version',
},
updateAvailable: {
id: 'content.managed-card.update-available',
defaultMessage: 'Update available',
},
updating: {
id: 'content.managed-card.updating',
defaultMessage: 'Updating...',
},
})
const timestamp = computed(() => {
if (props.data.kind === 'modpack') {
return props.data.updatedAt
}
return props.data.syncedAt
})
const timestampLabel = computed(() => {
if (!timestamp.value) return null
return formatMessage(props.data.kind === 'modpack' ? messages.updated : messages.synced, {
time: formatTimeAgo(timestamp.value),
})
})
const timestampTooltip = computed(() =>
timestamp.value ? formatDateTime(timestamp.value) : undefined,
)
const showAction = computed(
() =>
props.showPrimaryAction &&
(props.data.kind === 'modpack'
? !!props.data.versionNumber
: props.data.updateAvailable || props.disabled),
)
</script>
<template>
<div
class="flex min-h-14 flex-col items-start justify-between gap-x-4 gap-y-3 bg-surface-2 px-[18px] py-4 !font-semibold text-secondary @[700px]:flex-row @[700px]:items-center"
>
<div class="flex min-w-0 flex-wrap items-center gap-2">
<div class="flex min-w-0 flex-wrap items-center gap-1.5 @[700px]:flex-nowrap">
<LockIcon aria-hidden="true" class="size-5 shrink-0" />
<div class="flex min-w-0 flex-wrap items-center gap-[5px] @[700px]:flex-nowrap">
<span class="whitespace-nowrap">
{{ formatMessage(messages.managedBy) }}
</span>
<AutoLink
:to="data.manager.link"
class="flex min-w-0 items-center gap-1.5 text-secondary"
:class="data.manager.link ? 'hover:underline' : ''"
>
<Avatar
:src="data.manager.iconUrl"
:alt="data.manager.name"
size="1.5rem"
:circle="data.kind === 'shared-instance'"
:tint-by="data.manager.name"
no-shadow
:class="data.kind === 'shared-instance' ? '' : '!rounded-[6px]'"
/>
<span class="break-words @[700px]:truncate @[700px]:whitespace-nowrap">
{{ data.manager.name }}
</span>
</AutoLink>
<span v-if="data.kind === 'server'" class="whitespace-nowrap">
{{ formatMessage(messages.server) }}
</span>
</div>
</div>
<template v-if="data.kind === 'modpack' && data.versionNumber">
<BulletDivider class="!mx-0" />
<AutoLink
:to="data.versionLink"
class="whitespace-nowrap text-secondary !decoration-secondary"
:class="data.versionLink ? 'hover:underline' : ''"
>
{{ data.versionNumber }}
</AutoLink>
</template>
<template v-if="timestampLabel">
<BulletDivider class="!mx-0" />
<div
v-tooltip="timestampTooltip"
class="flex items-center whitespace-nowrap"
:class="[data.kind === 'modpack' ? 'gap-2' : 'gap-1.5', 'cursor-help']"
>
<ClockIcon aria-hidden="true" class="size-5 shrink-0" />
<span>{{ timestampLabel }}</span>
</div>
</template>
</div>
<Button
v-if="showAction"
type="quiet"
:color="data.kind === 'modpack' ? undefined : 'green'"
:disabled="disabled"
class=""
@click="emit('primary-action', $event)"
>
<SpinnerIcon v-if="disabled" aria-hidden="true" class="animate-spin" />
<ArrowLeftRightIcon v-else-if="data.kind === 'modpack'" aria-hidden="true" />
<DownloadIcon v-else aria-hidden="true" />
<span>
{{
disabled
? (disabledText ?? formatMessage(messages.updating))
: data.kind === 'modpack'
? formatMessage(messages.switchVersion)
: formatMessage(messages.updateAvailable)
}}
</span>
</Button>
</div>
</template>
@@ -0,0 +1,101 @@
<script setup lang="ts">
import { BoxIcon, BracesIcon, GlassesIcon, PaintbrushIcon, PlugIcon } from '@modrinth/assets'
import { type Component, computed, onBeforeUnmount, ref, watch } from 'vue'
import BulletDivider from '#ui/components/base/BulletDivider.vue'
import { useFormatNumber } from '#ui/composables/format-number'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { formatProjectTypeSentence } from '#ui/utils/common-messages'
import type { ManagedContentSummaryItem, ManagedContentSummaryType } from '../../types'
const props = defineProps<{
summary?: ManagedContentSummaryItem[]
installing?: boolean
}>()
const { formatMessage } = useVIntl()
const formatNumber = useFormatNumber()
const messages = defineMessages({
loading: {
id: 'content.managed-card.summary.loading',
defaultMessage: 'Loading managed content summary',
},
empty: {
id: 'content.managed-card.summary.empty',
defaultMessage: 'No managed content',
},
installing: {
id: 'content.managed-card.summary.installing',
defaultMessage: 'Installing content...',
},
})
const icons: Record<ManagedContentSummaryType, Component> = {
mod: BoxIcon,
plugin: PlugIcon,
datapack: BracesIcon,
resourcepack: PaintbrushIcon,
shader: GlassesIcon,
}
const MINIMUM_LOADING_TIME = 500
const minimumLoadingVisible = ref(false)
let minimumLoadingTimer: ReturnType<typeof setTimeout> | undefined
watch(
() => props.summary === undefined,
(isLoading) => {
if (!isLoading) return
minimumLoadingVisible.value = true
clearTimeout(minimumLoadingTimer)
minimumLoadingTimer = setTimeout(() => {
minimumLoadingVisible.value = false
}, MINIMUM_LOADING_TIME)
},
{ immediate: true },
)
onBeforeUnmount(() => clearTimeout(minimumLoadingTimer))
const showLoading = computed(() => props.summary === undefined || minimumLoadingVisible.value)
const visibleSummary = computed(() => props.summary?.filter((item) => item.count > 0))
</script>
<template>
<div
v-if="!installing && showLoading"
class="flex h-6 items-center gap-2"
role="status"
:aria-label="formatMessage(messages.loading)"
>
<div class="h-5 w-20 animate-pulse rounded bg-surface-4" />
<BulletDivider class="!mx-0" />
<div class="h-5 w-32 animate-pulse rounded bg-surface-4" />
<BulletDivider class="!mx-0" />
<div class="h-5 w-20 animate-pulse rounded bg-surface-4" />
</div>
<span v-else-if="installing" class="font-medium leading-6 text-secondary" role="status">
{{ formatMessage(messages.installing) }}
</span>
<div v-else-if="visibleSummary?.length" class="flex flex-wrap items-center gap-2 text-secondary">
<template v-for="(item, index) in visibleSummary" :key="item.type">
<BulletDivider v-if="index > 0" class="!mx-0" />
<div class="flex items-center gap-1.5 whitespace-nowrap">
<component :is="icons[item.type]" aria-hidden="true" class="size-5 shrink-0" />
<span class="font-medium leading-6">
{{ formatNumber(item.count) }}
{{ formatProjectTypeSentence(formatMessage, item.type, item.count) }}
</span>
</div>
</template>
</div>
<span v-else class="font-medium leading-6 text-secondary">
{{ formatMessage(messages.empty) }}
</span>
</template>
@@ -3,7 +3,6 @@ import {
ArrowLeftRightIcon,
BoxIcon,
ExternalIcon,
FilterIcon,
GlassesIcon,
PaintbrushIcon,
SearchIcon,
@@ -17,6 +16,7 @@ import BulletDivider from '#ui/components/base/BulletDivider.vue'
import type { OverflowMenuOption } from '#ui/components/base/buttons'
import { ButtonLink } from '#ui/components/base/buttons'
import Checkbox from '#ui/components/base/Checkbox.vue'
import FilterPills from '#ui/components/base/FilterPills.vue'
import StyledInput from '#ui/components/base/StyledInput.vue'
import NewModal from '#ui/components/modal/NewModal.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
@@ -28,7 +28,7 @@ import {
normalizeProjectType,
} from '#ui/utils/common-messages'
import { getClientWarningType, isClientOnlyEnvironment } from '../../composables/content-filtering'
import { getClientWarningType } from '../../composables/content-filtering'
import type { ContentCardProject, ContentCardTableItem, ContentItem } from '../../types'
import ContentCardTable from '../ContentCardTable.vue'
import ContentSelectionBar from '../ContentSelectionBar.vue'
@@ -38,24 +38,26 @@ const pageContext = injectPageContext(null)
interface Props {
header?: string
modpackName?: string
modpackIconUrl?: string
sourceName?: string
sourceIconUrl?: string
enableToggle?: boolean
actionDisabled?: boolean
actionDisabledTooltip?: string | null
getOverflowOptions?: (item: ContentItem) => OverflowMenuOption[]
switchVersion?: (item: ContentItem) => void
showEnvironmentWarnings?: boolean
}
const props = withDefaults(defineProps<Props>(), {
header: undefined,
modpackName: undefined,
modpackIconUrl: undefined,
sourceName: undefined,
sourceIconUrl: undefined,
enableToggle: false,
actionDisabled: false,
actionDisabledTooltip: undefined,
getOverflowOptions: undefined,
switchVersion: undefined,
showEnvironmentWarnings: false,
})
const emit = defineEmits<{
@@ -67,44 +69,44 @@ const emit = defineEmits<{
const messages = defineMessages({
header: {
id: 'instances.modpack-content-modal.header',
defaultMessage: 'Modpack content',
id: 'instances.managed-content-modal.header',
defaultMessage: 'Managed content',
},
searchPlaceholder: {
id: 'instances.modpack-content-modal.search-placeholder',
id: 'instances.managed-content-modal.search-placeholder',
defaultMessage: 'Search {count, number} {count, plural, one {project} other {projects}}',
},
loading: {
id: 'instances.modpack-content-modal.loading',
id: 'instances.managed-content-modal.loading',
defaultMessage: 'Loading content...',
},
emptyTitle: {
id: 'instances.modpack-content-modal.empty-title',
id: 'instances.managed-content-modal.empty-title',
defaultMessage: 'No content found',
},
emptyDescription: {
id: 'instances.modpack-content-modal.empty-description',
defaultMessage: 'This modpack does not include any additional content.',
id: 'instances.managed-content-modal.empty-description',
defaultMessage: 'This source does not include any managed content.',
},
noResults: {
id: 'instances.modpack-content-modal.no-results',
id: 'instances.managed-content-modal.no-results',
defaultMessage: 'No projects match your search.',
},
externalContent: {
id: 'instances.modpack-content-modal.external-content',
id: 'instances.managed-content-modal.external-content',
defaultMessage: 'External',
},
externalContentDescription: {
id: 'instances.modpack-content-modal.external-content-description',
id: 'instances.managed-content-modal.external-content-description',
defaultMessage: 'This file is not published on Modrinth.',
},
openInSlicer: {
id: 'instances.modpack-content-modal.open-in-slicer',
id: 'instances.managed-content-modal.open-in-slicer',
defaultMessage: 'Open in Slicer',
},
})
export interface ModpackContentModalState {
export interface ManagedContentModalState {
items: ContentItem[]
searchQuery: string
selectedFilters: string[]
@@ -124,6 +126,7 @@ const selectedIds = ref<string[]>([])
const selectedItems = computed(() =>
items.value.filter((item) => selectedIds.value.includes(item.id)),
)
const toggleableSelectedItems = computed(() => selectedItems.value)
const allSelected = computed(() => {
if (filteredItems.value.length === 0) return false
@@ -173,7 +176,9 @@ const filterOptions = computed(() => {
}
})
if (items.value.some((item) => getClientWarningType(item) !== null)) {
if (
items.value.some((item) => getClientWarningType(item, props.showEnvironmentWarnings) !== null)
) {
options.push({ id: 'warnings', label: 'Warnings' })
}
@@ -193,15 +198,6 @@ const stats = computed(() => {
return counts
})
function toggleFilter(filterId: string) {
const index = selectedFilters.value.indexOf(filterId)
if (index === -1) {
selectedFilters.value.push(filterId)
} else {
selectedFilters.value.splice(index, 1)
}
}
const attributeFilterIds = new Set(['disabled', 'warnings'])
const typeFilteredCount = computed(() => {
@@ -213,7 +209,8 @@ const typeFilteredCount = computed(() => {
if (typeFilters.length > 0 && !typeFilters.includes(normalizeProjectType(item.project_type)))
return false
if (hasDisabledFilter && item.enabled) return false
if (hasWarningsFilter && getClientWarningType(item) === null) return false
if (hasWarningsFilter && getClientWarningType(item, props.showEnvironmentWarnings) === null)
return false
return true
}).length
})
@@ -236,7 +233,8 @@ const filteredItems = computed(() => {
if (typeFilters.length > 0 && !typeFilters.includes(normalizeProjectType(item.project_type)))
return false
if (hasDisabledFilter && item.enabled) return false
if (hasWarningsFilter && getClientWarningType(item) === null) return false
if (hasWarningsFilter && getClientWarningType(item, props.showEnvironmentWarnings) === null)
return false
return true
})
}
@@ -244,19 +242,24 @@ const filteredItems = computed(() => {
return sortContentItems(result, !query)
})
function contentVersionLabel(item: ContentItem): string {
if (item.embedded_metadata?.version) return item.embedded_metadata.version
return formatMessage(commonMessages.unknownLabel)
}
const tableItems = computed<ContentCardTableItem[]>(() =>
filteredItems.value.map((item) => ({
id: item.id,
project: item.project ?? {
id: item.id,
slug: null,
title: item.file_name,
icon_url: null,
title: item.embedded_metadata?.name ?? item.file_name,
icon_url: item.embedded_metadata?.icon_url ?? null,
},
projectLink: !item.external && item.project?.id ? `/project/${item.project.id}` : undefined,
version: item.version ?? {
id: item.id,
version_number: 'Unknown',
version_number: contentVersionLabel(item),
file_name: item.file_name,
},
owner: item.owner
@@ -275,19 +278,17 @@ const tableItems = computed<ContentCardTableItem[]>(() =>
}
: undefined,
...(props.enableToggle ? { enabled: item.enabled } : {}),
locked: item.locked,
installing: item.installing === true,
toggleDisabled: props.actionDisabled,
toggleDisabledTooltip: props.actionDisabled ? props.actionDisabledTooltip : undefined,
isClientOnly:
isClientOnlyEnvironment(item.environment) ||
!!item.pack_client_retained ||
!!item.pack_client_depends,
clientWarning: getClientWarningType(item),
isClientOnly: getClientWarningType(item, props.showEnvironmentWarnings) !== null,
clientWarning: getClientWarningType(item, props.showEnvironmentWarnings),
disabled:
props.actionDisabled || disabledIds.value.has(item.file_name) || item.installing === true,
disabledTooltip: props.actionDisabled ? props.actionDisabledTooltip : undefined,
overflowOptions: [
...(props.switchVersion
...(props.switchVersion && !item.locked
? [
{
id: 'switch-version',
@@ -359,13 +360,13 @@ function handleEnabledChange(id: string, value: boolean) {
function bulkEnable() {
if (props.actionDisabled) return
emit('bulk:enable', [...selectedItems.value])
emit('bulk:enable', [...toggleableSelectedItems.value])
selectedIds.value = []
}
function bulkDisable() {
if (props.actionDisabled) return
emit('bulk:disable', [...selectedItems.value])
emit('bulk:disable', [...toggleableSelectedItems.value])
selectedIds.value = []
}
@@ -404,7 +405,7 @@ function handleHide() {
emit('hide')
}
function getState(): ModpackContentModalState | null {
function getState(): ManagedContentModalState | null {
if (!items.value.length) return null
return {
items: items.value,
@@ -414,7 +415,7 @@ function getState(): ModpackContentModalState | null {
}
}
async function restore(state: ModpackContentModalState) {
async function restore(state: ManagedContentModalState) {
items.value = state.items.map((item) => ({ ...item }))
searchQuery.value = state.searchQuery
selectedFilters.value = state.selectedFilters
@@ -466,10 +467,11 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems
>
<template #title>
<Avatar
v-if="props.modpackIconUrl"
:src="props.modpackIconUrl"
v-if="props.sourceIconUrl"
:src="props.sourceIconUrl"
:alt="props.sourceName"
size="3rem"
:tint-by="props.modpackName"
:tint-by="props.sourceName"
/>
<span class="text-lg font-extrabold text-contrast">
{{ props.header ?? formatMessage(messages.header) }}
@@ -484,43 +486,18 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems
clearable
/>
<!-- Filters -->
<div v-if="filterOptions.length > 0" class="flex items-center gap-2">
<FilterIcon class="size-5 text-secondary shrink-0" />
<div class="flex flex-wrap items-center gap-1.5">
<button
:aria-pressed="selectedFilters.length === 0"
class="rounded-full border border-solid px-3 py-1.5 text-base font-semibold leading-5 transition-colors"
:class="
selectedFilters.length === 0
? 'border-brand bg-brand-highlight text-brand'
: 'border-surface-5 bg-surface-4 text-primary hover:bg-surface-5'
"
@click="selectedFilters = []"
>
{{ formatMessage(commonMessages.allProjectType) }}
</button>
<button
v-for="option in filterOptions"
:key="option.id"
:aria-pressed="selectedFilters.includes(option.id)"
class="rounded-full border border-solid px-3 py-1.5 text-base font-semibold leading-5 transition-colors"
:class="
selectedFilters.includes(option.id)
? 'border-brand bg-brand-highlight text-brand'
: 'border-surface-5 bg-surface-4 text-primary hover:bg-surface-5'
"
@click="toggleFilter(option.id)"
>
{{ option.label }}
</button>
</div>
</div>
<FilterPills
v-if="filterOptions.length > 0"
v-model="selectedFilters"
:options="filterOptions"
>
<template #all>
{{ formatMessage(commonMessages.allProjectType) }}
</template>
</FilterPills>
</div>
<!-- Content area -->
<div class="flex-1 flex flex-col min-h-0 overflow-hidden">
<!-- Loading state -->
<div
v-if="loading"
class="flex flex-col items-center justify-center flex-1 gap-2 text-secondary"
@@ -529,7 +506,6 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems
<span class="text-sm">{{ formatMessage(messages.loading) }}</span>
</div>
<!-- Empty state -->
<div
v-else-if="items.length === 0"
class="flex flex-col items-center justify-center flex-1 gap-2 text-center p-8"
@@ -540,7 +516,6 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems
<span class="text-secondary">{{ formatMessage(messages.emptyDescription) }}</span>
</div>
<!-- No search results -->
<div
v-else-if="filteredItems.length === 0"
class="flex flex-col items-center justify-center flex-1 gap-2 text-center p-8"
@@ -548,7 +523,6 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems
<span class="text-secondary">{{ formatMessage(messages.noResults) }}</span>
</div>
<!-- Content table -->
<div v-else class="@container flex-1 min-h-0 flex flex-col">
<div
class="flex h-12 shrink-0 items-center justify-between gap-4 border-0 border-b border-solid border-surface-4 bg-surface-3 px-3"
@@ -630,11 +604,9 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems
</div>
</div>
<!-- Footer -->
<div
class="flex items-center justify-between px-6 py-4 border-t border-solid border-0 border-surface-4 shrink-0"
>
<!-- Stats -->
<div class="flex items-center gap-2">
<template v-for="(count, type, idx) in stats" :key="type">
<BulletDivider v-if="idx > 0" />
@@ -662,6 +634,7 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem, setItems
<ContentSelectionBar
v-if="props.enableToggle"
:selected-items="selectedItems"
:toggle-items="toggleableSelectedItems"
:is-busy="props.actionDisabled"
:busy-tooltip="props.actionDisabledTooltip"
:hide-when-modal-open="false"
@@ -13,10 +13,13 @@ export function isClientOnlyEnvironment(env?: string | null): boolean {
return !!env && CLIENT_ONLY_ENVIRONMENTS.has(env)
}
export function getClientWarningType(item: ContentItem): ClientWarningType | null {
export function getClientWarningType(
item: ContentItem,
showEnvironmentWarnings = false,
): ClientWarningType | null {
if (item.pack_client_retained) return 'retained'
if (item.pack_client_depends) return 'depends'
if (isClientOnlyEnvironment(item.environment)) return 'environment'
if (showEnvironmentWarnings && isClientOnlyEnvironment(item.environment)) return 'environment'
return null
}
@@ -29,6 +32,8 @@ export interface ContentFilterConfig {
showTypeFilters?: boolean
showUpdateFilter?: boolean
showWarningsFilter?: boolean
showStatusFilters?: boolean
showEnvironmentWarnings?: boolean
isPackLocked?: Ref<boolean>
persistKey?: string
}
@@ -88,15 +93,22 @@ export function useContentFilters(items: Ref<ContentItem[]>, config?: ContentFil
options.push({ id: 'updates', label: formatMessage(messages.updates) })
}
if (config?.showWarningsFilter && items.value.some((m) => getClientWarningType(m) !== null)) {
if (
config?.showWarningsFilter &&
items.value.some(
(item) => getClientWarningType(item, config.showEnvironmentWarnings) !== null,
)
) {
options.push({ id: 'warnings', label: formatMessage(messages.warnings) })
}
for (const status of availableStatusFilters.value) {
options.push({
id: status,
label: formatMessage(status === 'enabled' ? messages.enabled : messages.disabled),
})
if (config?.showStatusFilters !== false) {
for (const status of availableStatusFilters.value) {
options.push({
id: status,
label: formatMessage(status === 'enabled' ? messages.enabled : messages.disabled),
})
}
}
return options
@@ -154,7 +166,11 @@ export function useContentFilters(items: Ref<ContentItem[]>, config?: ContentFil
if (filter === 'updates' && !item.has_update) return false
if (filter === 'enabled' && !item.enabled) return false
if (filter === 'disabled' && item.enabled) return false
if (filter === 'warnings' && getClientWarningType(item) === null) return false
if (
filter === 'warnings' &&
getClientWarningType(item, config?.showEnvironmentWarnings) === null
)
return false
}
return true
@@ -3,4 +3,5 @@ export * from './changing-items'
export * from './content-filtering'
export * from './content-search'
export * from './content-selection'
export * from './use-content-metadata-filters'
export * from './use-inline-backup'
@@ -0,0 +1,378 @@
import { useSessionStorage } from '@vueuse/core'
import type { Ref } from 'vue'
import { computed, ref, watch } from 'vue'
import type {
DropdownFilterBarCategory,
DropdownFilterBarOption,
} from '#ui/components/base/DropdownFilterBar.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import type { ContentItem } from '../types'
import { getClientWarningType } from './content-filtering'
export type ContentMetadataFilterValue = Record<string, string[]>
interface MetadataFilterDefinition {
key: string
label: string
searchable?: boolean
direct?: boolean
options?: DropdownFilterBarOption[]
values: (item: ContentItem) => DropdownFilterBarOption[]
}
interface ContentMetadataFilterConfig {
showSharedContent?: Ref<boolean> | Readonly<Ref<boolean>>
showEnvironmentWarnings?: boolean
}
const openSourceLicenseIds = new Set([
'0BSD',
'AFL-3.0',
'AGPL-3.0',
'Apache-2.0',
'Artistic-2.0',
'BSD-2-Clause',
'BSD-3-Clause',
'BSL-1.0',
'CDDL-1.0',
'ECL-2.0',
'EPL-1.0',
'EPL-2.0',
'EUPL-1.1',
'EUPL-1.2',
'GPL-2.0',
'GPL-3.0',
'ISC',
'LGPL-2.1',
'LGPL-3.0',
'MIT',
'MPL-2.0',
'NCSA',
'OSL-3.0',
'PostgreSQL',
'Python-2.0',
'Unlicense',
'UPL-1.0',
'Zlib',
])
type EnvironmentFilterValue = 'client' | 'server' | 'client_and_server' | 'singleplayer'
function getEnvironmentFilterValue(
environment?: ContentItem['environment'],
): EnvironmentFilterValue | undefined {
switch (environment) {
case 'client_only':
return 'client'
case 'server_only':
case 'dedicated_server_only':
return 'server'
case 'client_and_server':
case 'client_only_server_optional':
case 'server_only_client_optional':
case 'client_or_server':
case 'client_or_server_prefers_both':
return 'client_and_server'
case 'singleplayer_only':
return 'singleplayer'
default:
return undefined
}
}
const messages = defineMessages({
author: {
id: 'content.metadata-filter.author',
defaultMessage: 'Author',
},
openSource: {
id: 'content.metadata-filter.open-source',
defaultMessage: 'Open source',
},
environment: {
id: 'content.metadata-filter.environment',
defaultMessage: 'Environment',
},
clientSideOnly: {
id: 'project.settings.environment.client_only.title',
defaultMessage: 'Client-side only',
},
serverSideOnly: {
id: 'project.settings.environment.server_only.title',
defaultMessage: 'Server-side only',
},
clientAndServer: {
id: 'project.settings.environment.client_and_server.title',
defaultMessage: 'Client and server',
},
singleplayerOnly: {
id: 'project.settings.environment.singleplayer.title',
defaultMessage: 'Singleplayer only',
},
state: {
id: 'content.metadata-filter.state',
defaultMessage: 'State',
},
updates: {
id: 'content.metadata-filter.updates',
defaultMessage: 'Updates',
},
warnings: {
id: 'content.metadata-filter.warnings',
defaultMessage: 'Warnings',
},
enabled: {
id: 'content.metadata-filter.state.enabled',
defaultMessage: 'Enabled',
},
disabled: {
id: 'content.metadata-filter.state.disabled',
defaultMessage: 'Disabled',
},
updateAvailable: {
id: 'content.metadata-filter.update.available',
defaultMessage: 'Update available',
},
upToDate: {
id: 'content.metadata-filter.update.up-to-date',
defaultMessage: 'Up to date',
},
clientRetained: {
id: 'content.metadata-filter.warning.client-retained',
defaultMessage: 'Client file retained',
},
clientDepends: {
id: 'content.metadata-filter.warning.client-depends',
defaultMessage: 'Client depends on file',
},
clientOnly: {
id: 'content.metadata-filter.warning.client-only',
defaultMessage: 'Client-only content',
},
noWarnings: {
id: 'content.metadata-filter.warning.none',
defaultMessage: 'No warnings',
},
external: {
id: 'content.metadata-filter.source.external',
defaultMessage: 'External',
},
sharedContent: {
id: 'content.metadata-filter.shared-content',
defaultMessage: 'Shared content',
},
})
export function useContentMetadataFilters(
items: Ref<ContentItem[]>,
persistKey?: string,
config?: ContentMetadataFilterConfig,
) {
const { formatMessage } = useVIntl()
const selectedMetadataFilters = persistKey
? useSessionStorage<ContentMetadataFilterValue>(`content-metadata-filters:${persistKey}`, {})
: ref<ContentMetadataFilterValue>({})
function option(value: string, label: string, searchTerms?: string[]): DropdownFilterBarOption {
return { value, label, searchTerms }
}
function isOpenSource(item: ContentItem) {
const licenseId = item.project?.license?.id.replace(/-(?:only|or-later)$/, '')
return !!licenseId && openSourceLicenseIds.has(licenseId)
}
function isExternal(item: ContentItem) {
return item.external || !item.project?.license
}
function getEnvironmentFilterLabel(value: EnvironmentFilterValue) {
switch (value) {
case 'client':
return formatMessage(messages.clientSideOnly)
case 'server':
return formatMessage(messages.serverSideOnly)
case 'client_and_server':
return formatMessage(messages.clientAndServer)
case 'singleplayer':
return formatMessage(messages.singleplayerOnly)
}
}
const definitions = computed<MetadataFilterDefinition[]>(() => [
{
key: 'author',
label: formatMessage(messages.author),
searchable: true,
values: (item) =>
item.owner
? [option(`${item.owner.type}:${item.owner.id}`, item.owner.name, [item.owner.id])]
: [],
},
{
key: 'environment',
label: formatMessage(messages.environment),
options: [
option('client', getEnvironmentFilterLabel('client')),
option('server', getEnvironmentFilterLabel('server')),
option('client_and_server', getEnvironmentFilterLabel('client_and_server')),
option('singleplayer', getEnvironmentFilterLabel('singleplayer')),
],
values: (item) => {
const value = getEnvironmentFilterValue(item.environment)
return value ? [option(value, getEnvironmentFilterLabel(value))] : []
},
},
{
key: 'state',
label: formatMessage(messages.state),
values: (item) =>
item.enabled === undefined
? []
: [
item.enabled
? option('enabled', formatMessage(messages.enabled))
: option('disabled', formatMessage(messages.disabled)),
],
},
{
key: 'updates',
label: formatMessage(messages.updates),
values: (item) => [
item.has_update
? option('available', formatMessage(messages.updateAvailable))
: option('current', formatMessage(messages.upToDate)),
],
},
{
key: 'warnings',
label: formatMessage(messages.warnings),
values: (item) => {
const warning = getClientWarningType(item, config?.showEnvironmentWarnings)
switch (warning) {
case 'retained':
return [option(warning, formatMessage(messages.clientRetained))]
case 'depends':
return [option(warning, formatMessage(messages.clientDepends))]
case 'environment':
return [option(warning, formatMessage(messages.clientOnly))]
default:
return [option('none', formatMessage(messages.noWarnings))]
}
},
},
{
key: 'open_source',
label: formatMessage(messages.openSource),
direct: true,
values: (item) =>
isOpenSource(item) ? [option('open_source', formatMessage(messages.openSource))] : [],
},
{
key: 'external',
label: formatMessage(messages.external),
direct: true,
values: (item) =>
isExternal(item) ? [option('external', formatMessage(messages.external))] : [],
},
...(config?.showSharedContent?.value
? [
{
key: 'shared_content',
label: formatMessage(messages.sharedContent),
direct: true,
values: (item: ContentItem) =>
['server_project', 'shared_instance'].includes(item.source_kind ?? '')
? [option('shared_content', formatMessage(messages.sharedContent))]
: [],
},
]
: []),
])
const metadataFilterCategories = computed<DropdownFilterBarCategory[]>(() =>
definitions.value
.map((definition) => {
let visibleOptions = definition.options
if (!visibleOptions) {
const options = new Map<string, DropdownFilterBarOption>()
const optionMatchCounts = new Map<string, number>()
for (const item of items.value) {
const itemValues = new Map(
definition.values(item).map((value) => [value.value, value] as const),
)
for (const value of itemValues.values()) {
if (!options.has(value.value)) options.set(value.value, value)
optionMatchCounts.set(value.value, (optionMatchCounts.get(value.value) ?? 0) + 1)
}
}
visibleOptions = [...options.values()]
.filter((option) => optionMatchCounts.get(option.value) !== items.value.length)
.sort((a, b) => a.label.localeCompare(b.label, undefined, { numeric: true }))
}
return {
key: definition.key,
label: definition.label,
direct: definition.direct,
searchable: definition.searchable,
options: visibleOptions,
}
})
.filter((category) => category.options.some((option) => !('type' in option))),
)
watch(
metadataFilterCategories,
(categories) => {
if (items.value.length === 0) return
const availableValues = new Map(
categories.map((category) => [
category.key,
new Set(
category.options
.filter((item): item is DropdownFilterBarOption => !('type' in item))
.map((item) => item.value),
),
]),
)
const nextFilters: ContentMetadataFilterValue = {}
for (const [key, values] of Object.entries(selectedMetadataFilters.value)) {
const validValues = values.filter((value) => availableValues.get(key)?.has(value))
if (validValues.length > 0) nextFilters[key] = validValues
}
if (JSON.stringify(nextFilters) !== JSON.stringify(selectedMetadataFilters.value)) {
selectedMetadataFilters.value = nextFilters
}
},
{ immediate: true },
)
function applyMetadataFilters(source: ContentItem[]) {
const activeFilters = Object.entries(selectedMetadataFilters.value).filter(
([, values]) => values.length > 0,
)
if (activeFilters.length === 0) return source
const definitionsByKey = new Map(
definitions.value.map((definition) => [definition.key, definition]),
)
return source.filter((item) =>
activeFilters.every(([key, selectedValues]) => {
const definition = definitionsByKey.get(key)
if (!definition) return true
const itemValues = definition.values(item).map((value) => value.value)
return itemValues.some((value) => selectedValues.includes(value))
}),
)
}
return {
selectedMetadataFilters,
metadataFilterCategories,
applyMetadataFilters,
}
}
@@ -1,7 +1,9 @@
export { default as ContentCardItem } from './components/ContentCardItem.vue'
export { default as ContentCard } from './components/ContentCardItem.vue'
export { default as ContentCardTable } from './components/ContentCardTable.vue'
export { default as ContentModpackCard } from './components/ContentModpackCard.vue'
export { default as ManagedContentCard } from './components/managed-content-card/index.vue'
export type { ManagedContentModalState } from './components/managed-content-modal/index.vue'
export { default as ManagedContentModal } from './components/managed-content-modal/index.vue'
export { default as ConfirmBulkUpdateModal } from './components/modals/ConfirmBulkUpdateModal.vue'
export { default as ConfirmDeletionModal } from './components/modals/ConfirmDeletionModal.vue'
export { default as ConfirmDisableModal } from './components/modals/ConfirmDisableModal.vue'
@@ -18,11 +20,10 @@ export type {
} from './components/modals/ContentInstallModal.vue'
export { default as ContentInstallModal } from './components/modals/ContentInstallModal.vue'
export { default as InlineBackupCreator } from './components/modals/InlineBackupCreator.vue'
export type { ModpackContentModalState } from './components/modals/ModpackContentModal.vue'
export { default as ModpackContentModal } from './components/modals/ModpackContentModal.vue'
export { default as ContentCardLayout } from './layout.vue'
export { default as ContentPageLayout } from './layout.vue'
export * from './providers'
export * from './types'
export * from './utils/managed-content'
export * from './utils/update-channels'
export { default as ConfirmLeaveModal } from '#ui/components/modal/ConfirmLeaveModal.vue'
@@ -9,27 +9,31 @@ import {
DownloadIcon,
DropdownIcon,
FileIcon,
FilterIcon,
FolderOpenIcon,
LinkIcon,
OrganizationIcon,
RefreshCwIcon,
SearchIcon,
ShareIcon,
TextCursorInputIcon,
TrashIcon,
UserIcon,
} from '@modrinth/assets'
import { computed, nextTick, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { Button, TeleportOverflowMenu } from '#ui/components/base/buttons'
import Avatar from '#ui/components/base/Avatar.vue'
import { Button, type OverflowMenuOption, TeleportOverflowMenu } from '#ui/components/base/buttons'
import DropdownFilterBar from '#ui/components/base/DropdownFilterBar.vue'
import EmptyState from '#ui/components/base/EmptyState.vue'
import FilterPills from '#ui/components/base/FilterPills.vue'
import StyledInput from '#ui/components/base/StyledInput.vue'
import { useDebugLogger } from '#ui/composables/debug-logger'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages, formatContentTypeSentence } from '#ui/utils/common-messages'
import ContentCardTable from './components/ContentCardTable.vue'
import ContentModpackCard from './components/ContentModpackCard.vue'
import ContentSelectionBar from './components/ContentSelectionBar.vue'
import ManagedContentCard from './components/managed-content-card/index.vue'
import ConfirmBulkUpdateModal from './components/modals/ConfirmBulkUpdateModal.vue'
import ConfirmDeletionModal from './components/modals/ConfirmDeletionModal.vue'
import ConfirmDisableModal from './components/modals/ConfirmDisableModal.vue'
@@ -37,10 +41,10 @@ import ConfirmUnlinkModal from './components/modals/ConfirmUnlinkModal.vue'
import ContentDependencyWarningModal from './components/modals/ContentDependencyWarningModal.vue'
import {
getClientWarningType,
isClientOnlyEnvironment,
useBulkOperation,
useChangingItems,
useContentFilters,
useContentMetadataFilters,
useContentSearch,
useContentSelection,
} from './composables'
@@ -89,9 +93,13 @@ const messages = defineMessages({
id: 'content.page-layout.upload-files',
defaultMessage: 'Upload files',
},
sortAlphabetical: {
id: 'content.page-layout.sort.alphabetical',
defaultMessage: 'Alphabetical',
sortAlphabeticalAscending: {
id: 'content.page-layout.sort.alphabetical-ascending',
defaultMessage: 'Name (A-Z)',
},
sortAlphabeticalDescending: {
id: 'content.page-layout.sort.alphabetical-descending',
defaultMessage: 'Name (Z-A)',
},
sortDateAddedNewest: {
id: 'content.page-layout.sort.date-added-newest',
@@ -101,6 +109,14 @@ const messages = defineMessages({
id: 'content.page-layout.sort.date-added-oldest',
defaultMessage: 'Oldest first',
},
filter: {
id: 'content.page-layout.filter.add',
defaultMessage: 'Filter',
},
authorCount: {
id: 'content.page-layout.filter.author-count',
defaultMessage: '{count, plural, one {# author} other {# authors}}',
},
updateAll: {
id: 'content.page-layout.update-all',
defaultMessage: 'Update all',
@@ -166,22 +182,38 @@ type SortMode = 'alphabetical-asc' | 'alphabetical-desc' | 'date-added-newest' |
const sortMode = ref<SortMode>('alphabetical-asc')
const sortLabels: Record<SortMode, () => string> = {
'alphabetical-asc': () => formatMessage(messages.sortAlphabetical),
'alphabetical-desc': () => formatMessage(messages.sortAlphabetical),
'alphabetical-asc': () => formatMessage(messages.sortAlphabeticalAscending),
'alphabetical-desc': () => formatMessage(messages.sortAlphabeticalDescending),
'date-added-newest': () => formatMessage(messages.sortDateAddedNewest),
'date-added-oldest': () => formatMessage(messages.sortDateAddedOldest),
}
function cycleSortMode() {
const modes: SortMode[] = [
'alphabetical-asc',
'alphabetical-desc',
'date-added-newest',
'date-added-oldest',
]
const idx = modes.indexOf(sortMode.value)
sortMode.value = modes[(idx + 1) % modes.length]
}
const sortOptions = computed<OverflowMenuOption[]>(() => [
{
id: 'alphabetical-asc',
label: formatMessage(messages.sortAlphabeticalAscending),
icon: ArrowDownAZIcon,
action: () => (sortMode.value = 'alphabetical-asc'),
},
{
id: 'alphabetical-desc',
label: formatMessage(messages.sortAlphabeticalDescending),
icon: ArrowUpZAIcon,
action: () => (sortMode.value = 'alphabetical-desc'),
},
{
id: 'date-added-newest',
label: formatMessage(messages.sortDateAddedNewest),
icon: ClockArrowDownIcon,
action: () => (sortMode.value = 'date-added-newest'),
},
{
id: 'date-added-oldest',
label: formatMessage(messages.sortDateAddedOldest),
icon: ClockArrowUpIcon,
action: () => (sortMode.value = 'date-added-oldest'),
},
])
const sortedItems = computed(() => {
const items = [...ctx.items.value]
@@ -229,13 +261,125 @@ const { selectedFilters, filterOptions, toggleFilter, applyFilters } = useConten
ctx.items,
{
showTypeFilters: true,
showUpdateFilter: ctx.hasUpdateSupport,
showWarningsFilter: true,
showUpdateFilter: false,
showWarningsFilter: false,
showStatusFilters: false,
showEnvironmentWarnings: ctx.showEnvironmentWarnings,
isPackLocked: ctx.isPackLocked,
persistKey: ctx.filterPersistKey,
},
)
const { selectedMetadataFilters, metadataFilterCategories, applyMetadataFilters } =
useContentMetadataFilters(ctx.items, ctx.filterPersistKey, {
showSharedContent: ctx.showSharedContentFilter,
showEnvironmentWarnings: ctx.showEnvironmentWarnings,
})
const metadataFilterAuthors = computed(() => {
const authors = new Map<string, NonNullable<ContentItem['owner']>>()
for (const item of ctx.items.value) {
if (!item.owner) continue
authors.set(`${item.owner.type}:${item.owner.id}`, item.owner)
}
return authors
})
function getMetadataFilterAuthor(value: string) {
return metadataFilterAuthors.value.get(value)
}
function getMetadataFilterPreviewAuthor(selectedValues: string[]) {
const [selectedValue] = selectedValues
return selectedValues.length === 1 && selectedValue
? getMetadataFilterAuthor(selectedValue)
: undefined
}
const metadataFilterPreviewAuthorLimit = 3
const metadataFilterPreviewAuthorSize = 20
const metadataFilterPreviewAuthorOffset = 14
function getMetadataFilterPreviewAuthorValues(selectedValues: string[]) {
return selectedValues.slice(0, metadataFilterPreviewAuthorLimit)
}
function getMetadataFilterPreviewAuthorOverflow(selectedValues: string[]) {
return Math.max(0, selectedValues.length - metadataFilterPreviewAuthorLimit)
}
function getMetadataFilterPreviewAuthorStackWidth(selectedValues: string[]) {
const visibleCount = Math.min(selectedValues.length, metadataFilterPreviewAuthorLimit)
if (visibleCount === 0) return 0
return (
metadataFilterPreviewAuthorSize +
(visibleCount - 1 + (selectedValues.length > metadataFilterPreviewAuthorLimit ? 1 : 0)) *
metadataFilterPreviewAuthorOffset
)
}
function isMetadataFilterOrganization(value: string) {
return (
getMetadataFilterAuthor(value)?.type === 'organization' || value.startsWith('organization:')
)
}
const metadataFilterTriggerClass =
'!h-[34px] !rounded-xl !border !border-solid !border-surface-5 !bg-transparent !px-3 !text-sm !font-medium !text-primary !shadow-[0_1px_1.5px_rgba(0,0,0,0.15)] transition-all duration-100 active:scale-[0.97] hover:!bg-surface-3 focus-visible:!outline-none focus-visible:!ring-4 focus-visible:!ring-brand-shadow [&>svg]:!size-5'
const metadataFilterPreviewTriggerClass =
'!h-[34px] !rounded-xl !border !border-solid !border-brand !bg-brand-highlight !px-3 !text-sm !font-medium !text-brand !shadow-[0_1px_1.5px_rgba(0,0,0,0.15)] transition-all duration-100 active:scale-[0.97] hover:!bg-brand-highlight focus-visible:!outline-none focus-visible:!ring-4 focus-visible:!ring-brand-shadow [&>svg]:!size-5 [&>svg]:!text-brand'
const filterControlsRef = ref<HTMLElement | null>(null)
const projectTypeFiltersRef = ref<HTMLElement | null>(null)
const metadataFiltersRef = ref<HTMLElement | null>(null)
const metadataFiltersWrapped = ref(false)
let filterControlsResizeObserver: ResizeObserver | null = null
function updateMetadataFiltersWrapped() {
metadataFiltersWrapped.value =
!!projectTypeFiltersRef.value &&
!!metadataFiltersRef.value &&
metadataFiltersRef.value.offsetTop > projectTypeFiltersRef.value.offsetTop
}
function observeFilterControls() {
filterControlsResizeObserver?.disconnect()
for (const element of [
filterControlsRef.value,
projectTypeFiltersRef.value,
metadataFiltersRef.value,
]) {
if (element) filterControlsResizeObserver?.observe(element)
}
updateMetadataFiltersWrapped()
}
onMounted(() => {
if (typeof ResizeObserver === 'undefined') return
filterControlsResizeObserver = new ResizeObserver(updateMetadataFiltersWrapped)
observeFilterControls()
})
watch([filterControlsRef, projectTypeFiltersRef, metadataFiltersRef], observeFilterControls, {
flush: 'post',
})
onBeforeUnmount(() => {
filterControlsResizeObserver?.disconnect()
})
function updateFilterChips(nextFilters: string[]) {
if (nextFilters.length === 0) {
selectedFilters.value = []
return
}
const changedFilter =
nextFilters.find((filter) => !selectedFilters.value.includes(filter)) ??
selectedFilters.value.find((filter) => !nextFilters.includes(filter))
if (changedFilter) toggleFilter(changedFilter)
}
const { selectedIds, selectedItems, clearSelection, removeFromSelection } = useContentSelection(
ctx.items,
getItemId,
@@ -270,27 +414,29 @@ async function handleRefresh() {
const filteredItems = computed(() => {
const sorted = sortedItems.value
const searched = search(sorted)
return applyFilters(searched)
return applyMetadataFilters(applyFilters(searched))
})
const tableItems = computed<ContentCardTableItem[]>(() => {
const items = filteredItems.value.map((item) => {
const base = ctx.mapToTableItem(item)
const id = getItemId(item)
const locked = base.locked ?? item.locked ?? false
const clientWarning = getClientWarningType(item, ctx.showEnvironmentWarnings)
return {
...base,
id,
locked,
disabled:
isChanging(id) || ctx.isBusy.value || isBulkOperating.value || item.installing === true,
disabledTooltip: ctx.isBusy.value ? (ctx.busyMessage?.value ?? null) : null,
toggleDisabled: ctx.isBusy.value,
toggleDisabledTooltip: ctx.isBusy.value ? (ctx.busyMessage?.value ?? null) : null,
toggleDisabled: ctx.isBusy.value || base.toggleDisabled,
toggleDisabledTooltip: ctx.isBusy.value
? (ctx.busyMessage?.value ?? null)
: base.toggleDisabledTooltip,
installing: item.installing === true,
hasUpdate: base.hasUpdate ?? item.has_update,
isClientOnly:
isClientOnlyEnvironment(item.environment) ||
!!item.pack_client_retained ||
!!item.pack_client_depends,
clientWarning: getClientWarningType(item),
isClientOnly: clientWarning !== null,
clientWarning,
hideDelete: base.hideDelete,
hideSwitchVersion: base.hideSwitchVersion ?? !base.versionLink,
overflowOptions: ctx.getOverflowOptions?.(item),
@@ -310,7 +456,7 @@ const tableItems = computed<ContentCardTableItem[]>(() => {
})
const hasOutdatedProjects = computed(() => {
const outdated = ctx.items.value.filter((p) => p.has_update)
const outdated = ctx.items.value.filter((p) => p.has_update && !p.locked)
if (outdated.length > 0) {
debug('hasOutdatedProjects: raw items with has_update=true', {
count: outdated.length,
@@ -533,9 +679,10 @@ async function confirmDelete() {
}
async function promptDisableItems(items: ContentItem[]) {
if (items.length === 0) return
pendingDisableItems.value = items
const warning = ctx.getDisableWarning?.(items) ?? null
const toggleableItems = items.filter(canToggleItem)
if (toggleableItems.length === 0) return
pendingDisableItems.value = toggleableItems
const warning = ctx.getDisableWarning?.(toggleableItems) ?? null
if (warning) {
pendingDisableWarning.value = warning
confirmDisableModal.value?.show()
@@ -642,12 +789,14 @@ async function bulkDisable() {
}
function handleUpdateById(id: string) {
const item = ctx.items.value.find((item) => getItemId(item) === id)
if (item?.locked) return
ctx.updateItem?.(id)
}
function handleSwitchVersionById(id: string) {
const item = ctx.items.value.find((i) => getItemId(i) === id)
if (item) {
if (item && !item.locked) {
ctx.switchVersion?.(item)
}
}
@@ -663,7 +812,7 @@ const hasBulkUpdateSupport = computed(
function promptUpdateAll(event?: MouseEvent) {
if (!hasBulkUpdateSupport.value) return
const items = ctx.items.value.filter((item) => item.has_update)
const items = ctx.items.value.filter((item) => item.has_update && !item.locked)
if (items.length === 0) return
pendingBulkUpdateItems.value = items
pendingBulkUpdateAll.value = true
@@ -676,7 +825,7 @@ function promptUpdateAll(event?: MouseEvent) {
function promptUpdateSelected(event?: MouseEvent) {
if (!hasBulkUpdateSupport.value) return
const items = selectedItems.value.filter((item) => item.has_update)
const items = selectedItems.value.filter((item) => item.has_update && !item.locked)
if (items.length === 0) return
pendingBulkUpdateItems.value = items
pendingBulkUpdateAll.value = false
@@ -769,28 +918,22 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
</div>
<template v-else>
<ContentModpackCard
v-if="ctx.modpack.value"
:project="ctx.modpack.value.project"
:project-link="ctx.modpack.value.projectLink"
:version="ctx.modpack.value.version"
:version-link="ctx.modpack.value.versionLink"
:owner="ctx.modpack.value.owner"
:categories="ctx.modpack.value.categories"
:has-update="ctx.modpack.value.hasUpdate"
:disabled="ctx.modpack.value.disabled"
:disabled-text="ctx.modpack.value.disabledText"
v-on="{
...(ctx.updateModpack ? { update: () => ctx.updateModpack?.() } : {}),
...(ctx.viewModpackContent ? { content: () => ctx.viewModpackContent?.() } : {}),
...(ctx.unlinkModpack ? { unlink: () => confirmUnlinkModal?.show() } : {}),
...(ctx.openSettings ? { settings: () => ctx.openSettings?.() } : {}),
}"
<ManagedContentCard
v-if="ctx.managedContent.value"
:data="ctx.managedContent.value.card"
:disabled="ctx.managedContent.value.disabled"
:disabled-text="ctx.managedContent.value.disabledText"
:show-view-content="!!ctx.viewManagedContent"
:show-settings="!!ctx.openManagedContentSettings"
:show-primary-action="!!ctx.runManagedContentPrimaryAction"
@view-content="ctx.viewManagedContent?.()"
@settings="ctx.openManagedContentSettings?.()"
@primary-action="ctx.runManagedContentPrimaryAction?.($event)"
/>
<template v-if="ctx.items.value.length > 0">
<div class="flex flex-col gap-4">
<span v-if="ctx.modpack.value" class="text-xl font-semibold text-contrast">
<span v-if="ctx.managedContent.value" class="text-xl font-semibold text-contrast">
{{ formatMessage(messages.additionalContent) }}
</span>
@@ -847,85 +990,224 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
</div>
</div>
<div class="@container flex flex-wrap items-center justify-between gap-2">
<div class="flex flex-wrap items-center gap-1.5">
<FilterIcon class="size-5 text-secondary" />
<button
class="cursor-pointer rounded-full border border-solid px-3 py-1.5 text-base font-semibold leading-5 transition-all duration-100 active:scale-[0.97]"
:class="
selectedFilters.length === 0
? 'border-brand bg-brand-highlight text-brand'
: 'border-surface-5 bg-surface-4 text-primary hover:bg-surface-5'
"
:aria-pressed="selectedFilters.length === 0"
@click="selectedFilters = []"
>
{{ formatMessage(commonMessages.allProjectType) }}
</button>
<button
v-for="option in filterOptions"
:key="option.id"
class="cursor-pointer rounded-full border border-solid px-3 py-1.5 text-base font-semibold leading-5 transition-all duration-100 active:scale-[0.97]"
:class="
selectedFilters.includes(option.id)
? 'border-brand bg-brand-highlight text-brand'
: 'border-surface-5 bg-surface-4 text-primary hover:bg-surface-5'
"
:aria-pressed="selectedFilters.includes(option.id)"
@click="toggleFilter(option.id)"
>
{{ option.label }}
</button>
<div class="hidden @[900px]:block">
<Button
type="quiet"
:aria-label="
formatMessage(messages.sortByLabel, { mode: sortLabels[sortMode]() })
"
@click="cycleSortMode"
<div class="@container flex items-start gap-2">
<div ref="filterControlsRef" class="flex min-w-0 flex-1 flex-wrap items-center gap-2">
<div ref="projectTypeFiltersRef" class="flex items-center gap-2">
<TeleportOverflowMenu
class="!h-[34px] !text-sm !font-medium"
:icon-only="false"
:label="formatMessage(messages.sortByLabel, { mode: sortLabels[sortMode]() })"
:options="sortOptions"
>
<ArrowUpZAIcon v-if="sortMode === 'alphabetical-desc'" /><ClockArrowDownIcon
v-else-if="sortMode === 'date-added-newest'"
/><ClockArrowUpIcon
v-else-if="sortMode === 'date-added-oldest'"
/><ArrowDownAZIcon v-else />
<ArrowUpZAIcon v-if="sortMode === 'alphabetical-desc'" />
<ClockArrowDownIcon v-else-if="sortMode === 'date-added-newest'" />
<ClockArrowUpIcon v-else-if="sortMode === 'date-added-oldest'" />
<ArrowDownAZIcon v-else />
{{ sortLabels[sortMode]() }}
</Button>
<DropdownIcon />
</TeleportOverflowMenu>
<div class="h-6 w-px shrink-0 bg-surface-5" />
<FilterPills
:model-value="selectedFilters"
:options="filterOptions"
@update:model-value="updateFilterChips"
>
<template #all>
{{ formatMessage(commonMessages.allProjectType) }}
</template>
</FilterPills>
</div>
<div
v-if="metadataFilterCategories.length > 0"
ref="metadataFiltersRef"
class="flex flex-wrap items-center gap-1.5 [&>div:last-of-type]:!h-[34px] [&>div:last-of-type]:!gap-1.5 [&_[data-button]]:!h-[34px]"
>
<div
class="h-6 w-px shrink-0 bg-surface-5"
:class="{
hidden: metadataFiltersWrapped,
'mr-0.5': !metadataFiltersWrapped,
}"
/>
<DropdownFilterBar
v-model="selectedMetadataFilters"
:categories="metadataFilterCategories"
:show-label="false"
:add-label="formatMessage(messages.filter)"
:add-button-class="metadataFilterTriggerClass"
:preview-trigger-class="metadataFilterPreviewTriggerClass"
add-button-size="sm"
checkbox-position="right"
apply-immediately
>
<template #preview-content="{ category, selectedValues, label, summary }">
<div
v-if="category.key === 'author'"
class="flex min-w-0 flex-1 items-center gap-2"
>
<template v-if="selectedValues.length === 1">
<span
class="flex size-5 shrink-0 items-center justify-center overflow-hidden bg-surface-2 text-brand"
:class="
getMetadataFilterPreviewAuthor(selectedValues)?.type ===
'organization'
? 'rounded'
: 'rounded-full'
"
>
<Avatar
v-if="getMetadataFilterPreviewAuthor(selectedValues)?.avatar_url"
:src="getMetadataFilterPreviewAuthor(selectedValues)?.avatar_url"
size="100%"
:circle="
getMetadataFilterPreviewAuthor(selectedValues)?.type !==
'organization'
"
no-shadow
class="!border-0"
/>
<OrganizationIcon
v-else-if="
getMetadataFilterPreviewAuthor(selectedValues)?.type ===
'organization'
"
class="size-4"
/>
<UserIcon v-else class="size-4" />
</span>
<span class="min-w-0 truncate font-semibold text-contrast">
{{ getMetadataFilterPreviewAuthor(selectedValues)?.name ?? summary }}
</span>
</template>
<template v-else>
<span class="font-medium">{{ label }}:</span>
<div
class="relative h-5 shrink-0"
:style="{
width: `${getMetadataFilterPreviewAuthorStackWidth(selectedValues)}px`,
}"
aria-hidden="true"
>
<div
v-for="(value, index) in getMetadataFilterPreviewAuthorValues(
selectedValues,
)"
:key="value"
class="absolute top-0 flex size-5 items-center justify-center overflow-hidden border border-solid border-surface-3 bg-surface-4 text-brand"
:class="
isMetadataFilterOrganization(value) ? 'rounded' : 'rounded-full'
"
:style="{
left: `${index * metadataFilterPreviewAuthorOffset}px`,
zIndex:
getMetadataFilterPreviewAuthorValues(selectedValues).length -
index,
}"
>
<Avatar
v-if="getMetadataFilterAuthor(value)?.avatar_url"
:src="getMetadataFilterAuthor(value)?.avatar_url"
size="100%"
:circle="!isMetadataFilterOrganization(value)"
no-shadow
class="!border-0"
/>
<OrganizationIcon
v-else-if="isMetadataFilterOrganization(value)"
class="size-3.5"
/>
<UserIcon v-else class="size-3.5" />
</div>
<div
v-if="getMetadataFilterPreviewAuthorOverflow(selectedValues) > 0"
class="absolute top-0 flex size-5 items-center justify-center rounded-full border border-solid border-surface-3 bg-surface-4 text-[10px] font-bold text-contrast"
:style="{
left: `${getMetadataFilterPreviewAuthorValues(selectedValues).length * metadataFilterPreviewAuthorOffset}px`,
}"
>
+{{ getMetadataFilterPreviewAuthorOverflow(selectedValues) }}
</div>
</div>
<span class="min-w-0 truncate font-semibold text-contrast">
{{
formatMessage(messages.authorCount, { count: selectedValues.length })
}}
</span>
</template>
</div>
<span v-else class="min-w-0 flex-1 truncate">
<span class="font-medium">{{ label }}:</span>
<span class="ml-1 font-semibold text-contrast">{{ summary }}</span>
</span>
</template>
<template #option="{ category, option, selected }">
<div
v-if="category.key === 'author'"
class="flex min-w-0 flex-1 items-center gap-2"
>
<span
v-tooltip="option.label"
class="flex size-6 shrink-0 items-center justify-center overflow-hidden bg-surface-2 text-secondary"
:class="
getMetadataFilterAuthor(option.value)?.type === 'organization'
? 'rounded'
: 'rounded-full'
"
>
<img
v-if="getMetadataFilterAuthor(option.value)?.avatar_url"
:src="getMetadataFilterAuthor(option.value)?.avatar_url"
:alt="option.label"
class="size-full object-cover"
/>
<OrganizationIcon
v-else-if="
getMetadataFilterAuthor(option.value)?.type === 'organization'
"
class="size-5"
/>
<UserIcon v-else class="size-5" />
</span>
<span
v-tooltip="option.label"
class="min-w-0 truncate font-semibold leading-tight"
:class="selected ? 'text-contrast' : 'text-primary'"
>
{{ option.label }}
</span>
</div>
<span
v-else
class="min-w-0 truncate font-semibold leading-tight"
:class="selected ? 'text-contrast' : 'text-primary'"
>
{{ option.label }}
</span>
</template>
</DropdownFilterBar>
</div>
</div>
<div class="flex items-center gap-2">
<div class="@[900px]:hidden">
<Button
type="quiet"
:aria-label="
formatMessage(messages.sortByLabel, { mode: sortLabels[sortMode]() })
"
@click="cycleSortMode"
>
<ArrowUpZAIcon v-if="sortMode === 'alphabetical-desc'" /><ClockArrowDownIcon
v-else-if="sortMode === 'date-added-newest'"
/><ClockArrowUpIcon
v-else-if="sortMode === 'date-added-oldest'"
/><ArrowDownAZIcon v-else />
{{ sortLabels[sortMode]() }}
</Button>
</div>
<div class="flex shrink-0 items-center gap-2">
<Button
v-if="hasBulkUpdateSupport && hasOutdatedProjects"
v-tooltip="formatMessage(messages.updateAll)"
type="quiet"
color="green"
:disabled="isBulkOperating"
class="hover:!bg-green focus-visible:!bg-green hover:!text-[var(--color-accent-contrast)] focus-visible:!text-[var(--color-accent-contrast)]"
class="!text-sm !font-medium hover:!bg-green focus-visible:!bg-green hover:!text-[var(--color-accent-contrast)] focus-visible:!text-[var(--color-accent-contrast)]"
@click="promptUpdateAll"
>
<DownloadIcon />
{{ formatMessage(messages.updateAll) }}
</Button>
<Button type="quiet" :disabled="refreshing" @click="handleRefresh">
<Button
type="quiet"
:disabled="refreshing"
class="!text-sm !font-medium"
@click="handleRefresh"
>
<RefreshCwIcon :class="refreshing ? 'animate-spin' : ''" />
{{ formatMessage(commonMessages.refreshButton) }}
</Button>
@@ -952,13 +1234,15 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
<template #heading>
{{
formatMessage(
ctx.modpack.value ? messages.noExtraContentInstalled : messages.noContentInstalled,
ctx.managedContent.value
? messages.noExtraContentInstalled
: messages.noContentInstalled,
)
}}
</template>
<template #description>
{{
ctx.modpack.value
ctx.managedContent.value
? formatMessage(messages.emptyModpackHint)
: formatMessage(messages.emptyHint, {
contentType: formatContentTypeSentence(
@@ -1024,7 +1308,7 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
>
<template #actions>
<Button
v-if="hasBulkUpdateSupport && selectedItems.some((m) => m.has_update)"
v-if="hasBulkUpdateSupport && selectedItems.some((m) => m.has_update && !m.locked)"
v-tooltip="formatMessage(commonMessages.updateButton)"
type="quiet"
color="green"
@@ -1146,7 +1430,7 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
v-if="ctx.unlinkModpack"
ref="confirmUnlinkModal"
:server="ctx.deletionContext === 'server'"
:backup-tip="ctx.modpack.value?.project.title"
:backup-tip="ctx.managedContent.value?.card.manager.name"
:action-disabled="ctx.isBusy.value"
:action-disabled-tooltip="ctx.busyMessage?.value ?? undefined"
@unlink="ctx.unlinkModpack!()"
@@ -1,5 +1,4 @@
import type { ComputedRef, Ref } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import type { OverflowMenuOption } from '#ui/components/base/buttons'
import { createContext } from '#ui/providers/create-context'
@@ -9,20 +8,11 @@ import type {
ContentActionWarning,
ContentCardTableItem,
ContentItem,
ContentModpackCardCategory,
ContentModpackCardProject,
ContentModpackCardVersion,
ContentOwner,
ManagedContentCardData,
} from '../types'
export interface ContentModpackData {
project: ContentModpackCardProject
projectLink?: string | RouteLocationRaw
version?: ContentModpackCardVersion
versionLink?: string | RouteLocationRaw
owner?: ContentOwner
categories: ContentModpackCardCategory[]
hasUpdate: boolean
export interface ManagedContentData {
card: ManagedContentCardData
disabled?: boolean
disabledText?: string
}
@@ -41,8 +31,8 @@ export interface ContentManagerContext {
loading: Ref<boolean>
error: Ref<Error | null>
// Modpack
modpack: Ref<ContentModpackData | null> | ComputedRef<ContentModpackData | null>
// Managed content
managedContent: Ref<ManagedContentData | null> | ComputedRef<ManagedContentData | null>
isPackLocked: Ref<boolean> | ComputedRef<boolean>
// Guards
@@ -81,11 +71,11 @@ export interface ContentManagerContext {
bulkUpdateItem?: (item: ContentItem) => Promise<void>
bulkUpdateItems?: (items: ContentItem[]) => Promise<void>
// Modpack actions (optional)
updateModpack?: () => void
viewModpackContent?: () => void
// Managed-content actions (optional)
runManagedContentPrimaryAction?: (event?: MouseEvent) => void
viewManagedContent?: () => void
unlinkModpack?: () => void
openSettings?: () => void
openManagedContentSettings?: () => void
// Switch version (optional)
switchVersion?: (item: ContentItem) => void
@@ -104,12 +94,14 @@ export interface ContentManagerContext {
// Deletion context (controls modal variant)
deletionContext?: 'instance' | 'server'
showEnvironmentWarnings?: boolean
// Table item mapping (link generation differs per platform)
mapToTableItem: (item: ContentItem) => ContentCardTableItem
// Filter persistence key — when set, selected filters are saved/restored via sessionStorage
filterPersistKey?: string
showSharedContentFilter?: Ref<boolean> | ComputedRef<boolean>
}
export const [injectContentManager, provideContentManager] = createContext<ContentManagerContext>(
@@ -6,7 +6,8 @@ import type { OverflowMenuOption } from '#ui/components/base/buttons'
export type ContentCardProject = Pick<
Labrinth.Projects.v2.Project,
'id' | 'slug' | 'title' | 'icon_url'
>
> &
Partial<Pick<Labrinth.Projects.v2.Project, 'license' | 'categories' | 'additional_categories'>>
export type ContentCardVersion = Pick<Labrinth.Versions.v2.Version, 'id' | 'version_number'> & {
file_name: string
@@ -42,6 +43,13 @@ export interface ContentActionWarning {
actionLabel: string
}
export interface EmbeddedContentMetadata {
name?: string | null
version?: string | null
icon_path?: string | null
icon_url?: string | null
}
export interface ContentCardTableItem {
id: string
project: ContentCardProject
@@ -50,11 +58,14 @@ export interface ContentCardTableItem {
versionLink?: string | RouteLocationRaw
owner?: ContentOwner
source?: ContentSource
external?: boolean
enabled?: boolean
locked?: boolean
disabled?: boolean
disabledTooltip?: string | null
toggleDisabled?: boolean
toggleDisabledTooltip?: string | null
hideToggle?: boolean
installing?: boolean
hasUpdate?: boolean
isClientOnly?: boolean
@@ -87,29 +98,56 @@ export interface ContentItem extends Omit<
has_update: boolean
update_version_id: string | null
date_added?: string
environment?: string
environment?: Labrinth.Projects.v3.Environment
pack_client_retained?: boolean
pack_client_depends?: boolean
installing?: boolean
source_kind?: ContentSourceKind | null
external?: boolean
external_url?: string
embedded_metadata?: EmbeddedContentMetadata | null
}
export type ContentModpackCardProject = Pick<
export type ManagedContentProject = Pick<
Labrinth.Projects.v2.Project,
'id' | 'slug' | 'title' | 'icon_url' | 'description'
'id' | 'slug' | 'title' | 'icon_url'
> & {
downloads?: number | null
followers?: number | null
filename?: string | null
}
export type ContentModpackCardVersion = Pick<
export type ManagedContentVersion = Pick<
Labrinth.Versions.v2.Version,
'id' | 'version_number' | 'date_published'
>
export type ContentModpackCardCategory = Labrinth.Tags.v2.Category & {
action?: (event: MouseEvent) => void
export type ManagedContentSummaryType = 'mod' | 'plugin' | 'datapack' | 'resourcepack' | 'shader'
export interface ManagedContentManager {
name: string
iconUrl?: string
link?: string | RouteLocationRaw
}
export interface ManagedContentSummaryItem {
type: ManagedContentSummaryType
count: number
}
interface ManagedContentCardBase {
manager: ManagedContentManager
summary?: ManagedContentSummaryItem[]
installing?: boolean
}
export type ManagedContentCardData =
| (ManagedContentCardBase & {
kind: 'modpack'
versionNumber?: string
versionLink?: string | RouteLocationRaw
updatedAt?: string
})
| (ManagedContentCardBase & {
kind: 'server' | 'shared-instance'
syncedAt?: number
updateAvailable: boolean
})
@@ -0,0 +1,53 @@
import { normalizeProjectType } from '#ui/utils/common-messages'
import type { ContentItem, ManagedContentSummaryItem, ManagedContentSummaryType } from '../types'
const summaryOrder: ManagedContentSummaryType[] = [
'mod',
'plugin',
'datapack',
'resourcepack',
'shader',
]
function normalizeSummaryType(type: string): ManagedContentSummaryType | null {
const normalized = normalizeProjectType(type)
switch (normalized) {
case 'mod':
case 'plugin':
case 'datapack':
case 'resourcepack':
case 'shader':
return normalized
default:
return null
}
}
export function dedupeManagedContentItems(items: ContentItem[]): ContentItem[] {
const seenKeys = new Set<string>()
return items.filter((item) => {
const key = item.file_path
? `path:${item.file_path}`
: item.file_name
? `name:${item.file_name}`
: `id:${item.id}`
if (seenKeys.has(key)) return false
seenKeys.add(key)
return true
})
}
export function summarizeManagedContent(items: ContentItem[]): ManagedContentSummaryItem[] {
const counts = new Map<ManagedContentSummaryType, number>()
for (const item of dedupeManagedContentItems(items)) {
const type = normalizeSummaryType(item.project_type)
if (type) counts.set(type, (counts.get(type) ?? 0) + 1)
}
return summaryOrder.flatMap((type) => {
const count = counts.get(type) ?? 0
return count > 0 ? [{ type, count }] : []
})
}
@@ -33,19 +33,15 @@ import {
getStoredServerAddonInstallQueue,
getTargetInstallPreferences,
} from '../../../shared/browse-tab/composables/install-logic'
import ManagedContentModal from '../../../shared/content-tab/components/managed-content-modal/index.vue'
import ConfirmModpackUpdateModal from '../../../shared/content-tab/components/modals/ConfirmModpackUpdateModal.vue'
import ConfirmUnlinkModal from '../../../shared/content-tab/components/modals/ConfirmUnlinkModal.vue'
import ContentUpdaterModal from '../../../shared/content-tab/components/modals/content-updater-modal/index.vue'
import ModpackContentModal from '../../../shared/content-tab/components/modals/ModpackContentModal.vue'
import ContentPageLayout from '../../../shared/content-tab/layout.vue'
import type { ContentModpackData } from '../../../shared/content-tab/providers/content-manager'
import type { ManagedContentData } from '../../../shared/content-tab/providers/content-manager'
import { provideContentManager } from '../../../shared/content-tab/providers/content-manager'
import type {
ContentItem,
ContentModpackCardCategory,
ContentModpackCardProject,
ContentModpackCardVersion,
} from '../../../shared/content-tab/types'
import type { ContentItem } from '../../../shared/content-tab/types'
import { summarizeManagedContent } from '../../../shared/content-tab/utils/managed-content'
type AddonWithUiState = Archon.Content.v1.Addon & { installing?: boolean }
type ContentOwnerAvatarSource = {
@@ -65,6 +61,10 @@ const props = withDefaults(
const { formatMessage } = useVIntl()
const messages = defineMessages({
modpackContent: {
id: 'hosting.content.managed-content.modpack-header',
defaultMessage: 'Modpack content',
},
failedToRemoveContent: {
id: 'hosting.content.failed-to-remove',
defaultMessage: 'Failed to remove content',
@@ -172,19 +172,21 @@ const modpackContentQuery = useQuery({
})
const setupActionDisabled = computed(() => !canSetup.value || busyReasons.value.length > 0)
const setupActionBusyMessage = computed(() => {
if (!canSetup.value) return permissionDeniedMessage.value
const bannerCoversInstalling =
const isInstallingContent = computed(
() =>
server.value?.status === 'installing' ||
isSyncingContent.value ||
busyReasons.value.some(
(r) =>
r.reason.id === 'servers.busy.installing' || r.reason.id === 'servers.busy.syncing-content',
)
),
)
const setupActionBusyMessage = computed(() => {
if (!canSetup.value) return permissionDeniedMessage.value
const filteredReasons = busyReasons.value.filter((r) => {
if (
bannerCoversInstalling &&
isInstallingContent.value &&
(r.reason.id === 'servers.busy.installing' || r.reason.id === 'servers.busy.syncing-content')
)
return false
@@ -256,54 +258,38 @@ const newestModpackUpdateVersion = computed(() => {
)
})
const modpack = computed<ContentModpackData | null>(() => {
const managedContent = computed<ManagedContentData | null>(() => {
const mp = contentQuery.data.value?.modpack
if (!mp) return null
const isLocal = mp.spec.platform === 'local_file'
const project = projectQuery.data.value
const projectId = isLocal ? null : mp.spec.project_id
return {
project: {
id: projectId ?? mp.title ?? '',
slug: project?.slug ?? projectId ?? '',
title: mp.title ?? (isLocal ? mp.spec.name : projectId) ?? '',
icon_url: mp.icon_url ?? undefined,
description: mp.description ?? '',
downloads: mp.downloads,
followers: mp.followers,
filename: isLocal ? mp.spec.filename : undefined,
} as ContentModpackCardProject,
projectLink: projectId ? `/project/${project?.slug ?? projectId}` : undefined,
version: isLocal
const addons = modpackContentQuery.data.value?.addons
const summary = addons
? summarizeManagedContent(addons.map(addonToContentItem))
: modpackContentQuery.isLoading.value
? undefined
: ({
id: mp.spec.version_id,
version_number: mp.version_number ?? '',
date_published: mp.date_published ?? '',
} as ContentModpackCardVersion),
versionLink:
projectId && !isLocal
? `/project/${project?.slug ?? projectId}/version/${mp.spec.version_id}`
: undefined,
owner: mp.owner
? {
id: mp.owner.id,
name: mp.owner.name,
type: mp.owner.type,
avatar_url: getContentOwnerAvatarUrl(mp.owner),
link:
mp.owner.type === 'organization'
? `/organization/${mp.owner.id}`
: `/user/${mp.owner.id}`,
}
: undefined,
categories: (project?.categories ?? []).map((name) => ({
name,
icon: name,
project_type: 'modpack',
header: 'categories',
})) as ContentModpackCardCategory[],
hasUpdate: !!mp.has_update || !!newestModpackUpdateVersion.value,
: []
const title = isLocal
? (mp.title ?? mp.spec.name)
: (project?.title ?? mp.title ?? projectId ?? '')
return {
card: {
kind: 'modpack',
installing: isInstallingContent.value,
manager: {
name: title,
iconUrl: (isLocal ? mp.icon_url : (project?.icon_url ?? mp.icon_url)) ?? undefined,
link: projectId ? `/project/${project?.slug ?? projectId}` : undefined,
},
summary,
versionNumber: isLocal ? undefined : (mp.version_number ?? undefined),
versionLink:
projectId && mp.spec.platform === 'modrinth'
? `/project/${project?.slug ?? projectId}/version/${mp.spec.version_id}`
: undefined,
updatedAt: isLocal ? undefined : (mp.date_published ?? undefined),
},
}
})
@@ -329,6 +315,31 @@ const addonLookup = computed(() => {
})
const pendingServerContentInstalls = ref<PendingServerContentInstall[]>([])
const projectMetadataBatchSize = 800
const contentProjectIds = computed(() =>
[...(contentQuery.data.value?.addons ?? []), ...modpackAddons.value]
.map((addon) => addon.project_id)
.concat(pendingServerContentInstalls.value.map((item) => item.projectId))
.filter((id): id is string => !!id)
.filter((id, index, ids) => ids.indexOf(id) === index)
.sort(),
)
const contentProjectsQuery = useQuery({
queryKey: computed(() => ['labrinth', 'projects', 'v2', contentProjectIds.value]),
queryFn: async () => {
const batches = []
for (let index = 0; index < contentProjectIds.value.length; index += projectMetadataBatchSize) {
batches.push(contentProjectIds.value.slice(index, index + projectMetadataBatchSize))
}
return (
await Promise.all(batches.map((ids) => client.labrinth.projects_v2.getMultiple(ids)))
).flat()
},
enabled: computed(() => contentProjectIds.value.length > 0),
})
const contentProjectsById = computed(
() => new Map((contentProjectsQuery.data.value ?? []).map((project) => [project.id, project])),
)
const lastStableContentKeys = ref<Set<string>>(new Set())
const contentInstallBaselineKeys = ref<Set<string> | null>(null)
const contentInstallAddedKeys = ref<Set<string>>(new Set())
@@ -506,12 +517,14 @@ async function flushStoredServerInstalls() {
}
function pendingInstallToContentItem(item: PendingServerContentInstall): ContentItem {
const projectMetadata = contentProjectsById.value.get(item.projectId)
return {
project: {
...(projectMetadata ?? {}),
id: item.projectId,
slug: item.slug ?? item.projectId,
title: item.title,
icon_url: item.iconUrl ?? undefined,
slug: item.slug ?? projectMetadata?.slug ?? item.projectId,
title: projectMetadata?.title ?? item.title,
icon_url: item.iconUrl ?? projectMetadata?.icon_url ?? undefined,
},
version: {
id: item.versionId,
@@ -854,7 +867,7 @@ async function handleBulkDisable(items: ContentItem[]) {
}
const modpackUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
const modpackContentModal = ref<InstanceType<typeof ModpackContentModal>>()
const modpackContentModal = ref<InstanceType<typeof ManagedContentModal>>()
const contentUpdaterModal = ref<InstanceType<typeof ContentUpdaterModal>>()
const updatingProject = ref<ContentItem | null>(null)
@@ -1002,12 +1015,16 @@ function handleUnknownFileContinue(dontShowAgain: boolean) {
}
function addonToContentItem(addon: AddonWithUiState): ContentItem {
const projectMetadata = addon.project_id
? contentProjectsById.value.get(addon.project_id)
: undefined
return {
project: {
...(projectMetadata ?? {}),
id: addon.project_id ?? addon.filename,
slug: addon.project_id ?? addon.filename,
title: friendlyAddonName(addon),
icon_url: addon.icon_url ?? undefined,
slug: projectMetadata?.slug ?? addon.project_id ?? addon.filename,
title: projectMetadata?.title ?? friendlyAddonName(addon),
icon_url: addon.icon_url ?? projectMetadata?.icon_url ?? undefined,
},
version: {
id: addon.version?.id ?? addon.filename,
@@ -1378,7 +1395,7 @@ provideContentManager({
items: contentItems,
loading: computed(() => contentQuery.isLoading.value),
error: computed(() => contentQuery.error.value ?? null),
modpack,
managedContent,
isPackLocked: ref(false),
isBusy: setupActionDisabled,
busyMessage: setupActionBusyMessage,
@@ -1396,13 +1413,14 @@ provideContentManager({
browse: handleBrowseContent,
uploadFiles: handleUploadFiles,
deletionContext: 'server',
showEnvironmentWarnings: true,
hasUpdateSupport: true,
updateItem: handleUpdateItem,
bulkUpdateItems: handleBulkUpdate,
updateModpack: handleModpackUpdate,
viewModpackContent: handleViewModpackContent,
runManagedContentPrimaryAction: handleModpackUpdate,
viewManagedContent: handleViewModpackContent,
unlinkModpack: handleModpackUnlink,
openSettings: () => openServerSettings({ tabId: 'installation' }),
openManagedContentSettings: () => openServerSettings({ tabId: 'installation' }),
switchVersion: handleSwitchVersion,
getOverflowOptions,
getItemId: getContentItemId,
@@ -1423,6 +1441,7 @@ provideContentManager({
owner: item.owner
? { ...item.owner, link: item.owner.link ?? `/${item.owner.type}/${item.owner.id}` }
: undefined,
external: item.external ?? !hasModrinthProject,
enabled: item.enabled,
}
},
@@ -1448,11 +1467,13 @@ provideContentManager({
:action-disabled-tooltip="setupActionBusyMessage ?? undefined"
@unlink="handleModpackUnlinkConfirm"
/>
<ModpackContentModal
<ManagedContentModal
ref="modpackContentModal"
:modpack-name="modpack?.project.title"
:modpack-icon-url="modpack?.project.icon_url"
:source-name="managedContent?.card.manager.name"
:source-icon-url="managedContent?.card.manager.iconUrl"
:header="formatMessage(messages.modpackContent)"
enable-toggle
show-environment-warnings
:action-disabled="setupActionDisabled"
:action-disabled-tooltip="setupActionBusyMessage ?? undefined"
@update:enabled="handleModpackContentToggle"
@@ -1476,11 +1497,13 @@ provideContentManager({
:is-app="false"
:project-type="updatingModpack ? 'modpack' : updatingProject?.project_type"
:project-icon-url="
updatingModpack ? modpack?.project.icon_url : updatingProject?.project?.icon_url
updatingModpack
? managedContent?.card.manager.iconUrl
: updatingProject?.project?.icon_url
"
:project-name="
updatingModpack
? (modpack?.project.title ?? formatMessage(commonMessages.modpackLabel))
? (managedContent?.card.manager.name ?? formatMessage(commonMessages.modpackLabel))
: (updatingProject?.project?.title ?? updatingProject?.file_name)
"
:loading="loadingVersions"
@@ -1499,7 +1522,7 @@ provideContentManager({
ref="modpackUpdateModal"
:downgrade="isModpackUpdateDowngrade"
:backup-tip="
[modpack?.project.title, pendingModpackUpdateVersion?.version_number]
[managedContent?.card.manager.name, pendingModpackUpdateVersion?.version_number]
.filter(Boolean)
.join(' ')
"
-25
View File
@@ -548,18 +548,6 @@
"content.page-layout.share.project-names": {
"defaultMessage": "Názvy projektů"
},
"content.page-layout.sort.alphabetical": {
"defaultMessage": "Abecedně"
},
"content.page-layout.sort.date-added-newest": {
"defaultMessage": "Od nejnovějších"
},
"content.page-layout.sort.date-added-oldest": {
"defaultMessage": "Od nejstarších"
},
"content.page-layout.sort.label": {
"defaultMessage": "Seřadit {mode}"
},
"content.page-layout.update-all": {
"defaultMessage": "Aktualizovat vše"
},
@@ -1832,18 +1820,6 @@
"instances.content-install.select-icon": {
"defaultMessage": "Vybrat ikonu"
},
"instances.modpack-content-modal.empty-title": {
"defaultMessage": "Nebyl nalezen obsah"
},
"instances.modpack-content-modal.header": {
"defaultMessage": "Obsah modpacku"
},
"instances.modpack-content-modal.loading": {
"defaultMessage": "Načítání obsahu..."
},
"instances.modpack-content-modal.no-results": {
"defaultMessage": "Žádné projekty neodpovídají tvému vyhledávání."
},
"instances.updater-modal.badge.current": {
"defaultMessage": "Mometálně"
},
@@ -4554,4 +4530,3 @@
"defaultMessage": "Typ"
}
}
-10
View File
@@ -395,12 +395,6 @@
"content.page-layout.share.label": {
"defaultMessage": "Del"
},
"content.page-layout.sort.date-added-oldest": {
"defaultMessage": "Ældst først"
},
"content.page-layout.sort.label": {
"defaultMessage": "Sorter efter {mode}"
},
"content.page-layout.update-all": {
"defaultMessage": "Opdater alle"
},
@@ -806,9 +800,6 @@
"instances.content-install.select-icon": {
"defaultMessage": "Vælg ikon"
},
"instances.modpack-content-modal.external-content": {
"defaultMessage": "Ekstern"
},
"instances.updater-modal.badge.current": {
"defaultMessage": "Nuværrende"
},
@@ -2250,4 +2241,3 @@
"defaultMessage": "Vanilla Shader"
}
}
-43
View File
@@ -566,9 +566,6 @@
"content.inline-backup.world-label": {
"defaultMessage": "Welt"
},
"content.modpack-card.installation-settings": {
"defaultMessage": "Installationseinstellungen"
},
"content.page-layout.additional-content": {
"defaultMessage": "Zusätzliche Inhalte"
},
@@ -620,18 +617,6 @@
"content.page-layout.share.project-names": {
"defaultMessage": "Projektnamen"
},
"content.page-layout.sort.alphabetical": {
"defaultMessage": "Alphabetisch"
},
"content.page-layout.sort.date-added-newest": {
"defaultMessage": "Neuste zuerst"
},
"content.page-layout.sort.date-added-oldest": {
"defaultMessage": "Älteste zuerst"
},
"content.page-layout.sort.label": {
"defaultMessage": "Sortieren nach {mode}"
},
"content.page-layout.update-all": {
"defaultMessage": "Alle aktualisieren"
},
@@ -2081,33 +2066,6 @@
"instances.content-install.select-icon": {
"defaultMessage": "Icon auswählen"
},
"instances.modpack-content-modal.empty-description": {
"defaultMessage": "Dieses Modpack enthält keine zusätzlichen inhalte."
},
"instances.modpack-content-modal.empty-title": {
"defaultMessage": "Keine Inhalte gefunden"
},
"instances.modpack-content-modal.external-content": {
"defaultMessage": "Extern"
},
"instances.modpack-content-modal.external-content-description": {
"defaultMessage": "Diese Datei ist nicht auf Modrinth veröffentlicht."
},
"instances.modpack-content-modal.header": {
"defaultMessage": "Modpack Inhalt"
},
"instances.modpack-content-modal.loading": {
"defaultMessage": "Lade Inhalte..."
},
"instances.modpack-content-modal.no-results": {
"defaultMessage": "Keine Projekte entsprechen deiner Suche."
},
"instances.modpack-content-modal.open-in-slicer": {
"defaultMessage": "In Slicer öffnen"
},
"instances.modpack-content-modal.search-placeholder": {
"defaultMessage": "Durchsuche {count, number} {count, plural, one {Projekt} other {Projekte}}"
},
"instances.updater-modal.badge.current": {
"defaultMessage": "Aktuell"
},
@@ -6231,4 +6189,3 @@
"defaultMessage": "Typ"
}
}
-43
View File
@@ -566,9 +566,6 @@
"content.inline-backup.world-label": {
"defaultMessage": "Welt"
},
"content.modpack-card.installation-settings": {
"defaultMessage": "Installationseinstellungen"
},
"content.page-layout.additional-content": {
"defaultMessage": "Zusätzliche Inhalte"
},
@@ -620,18 +617,6 @@
"content.page-layout.share.project-names": {
"defaultMessage": "Projektnamen"
},
"content.page-layout.sort.alphabetical": {
"defaultMessage": "Alphabetisch"
},
"content.page-layout.sort.date-added-newest": {
"defaultMessage": "Neuste zuerst"
},
"content.page-layout.sort.date-added-oldest": {
"defaultMessage": "Älteste zuerst"
},
"content.page-layout.sort.label": {
"defaultMessage": "Sortieren nach {mode}"
},
"content.page-layout.update-all": {
"defaultMessage": "Alle aktualisieren"
},
@@ -2081,33 +2066,6 @@
"instances.content-install.select-icon": {
"defaultMessage": "Icon auswählen"
},
"instances.modpack-content-modal.empty-description": {
"defaultMessage": "Dieses Modpack enthält keine zusätzlichen Inhalte."
},
"instances.modpack-content-modal.empty-title": {
"defaultMessage": "Keine Inhalte gefunden"
},
"instances.modpack-content-modal.external-content": {
"defaultMessage": "Extern"
},
"instances.modpack-content-modal.external-content-description": {
"defaultMessage": "Diese Datei ist nicht auf Modrinth veröffentlicht."
},
"instances.modpack-content-modal.header": {
"defaultMessage": "Modpack-Inhalt"
},
"instances.modpack-content-modal.loading": {
"defaultMessage": "Lade Inhalte..."
},
"instances.modpack-content-modal.no-results": {
"defaultMessage": "Keine Projekte entsprechen deiner Suche."
},
"instances.modpack-content-modal.open-in-slicer": {
"defaultMessage": "In Slicer öffnen"
},
"instances.modpack-content-modal.search-placeholder": {
"defaultMessage": "Durchsuche {count, number} {count, plural, one {Projekt} other {Projekte}}"
},
"instances.updater-modal.badge.current": {
"defaultMessage": "Aktuell"
},
@@ -6231,4 +6189,3 @@
"defaultMessage": "Typ"
}
}
+119 -14
View File
@@ -347,9 +347,15 @@
"content-type.item.lowercase": {
"defaultMessage": "{count, plural, one {item} other {items}}"
},
"content.card.frozen": {
"defaultMessage": "This project is locked to its current version until unfrozen."
},
"content.card.select-project": {
"defaultMessage": "Select {project}"
},
"content.card.uploaded": {
"defaultMessage": "Uploaded"
},
"content.confirm-bulk-update.admonition-body": {
"defaultMessage": "Are you sure you want to update {count, plural, one {# project} other {# projects}} to their latest compatible version? It's recommended to update content one-by-one."
},
@@ -566,9 +572,96 @@
"content.inline-backup.world-label": {
"defaultMessage": "world"
},
"content.modpack-card.installation-settings": {
"content.managed-card.managed-by": {
"defaultMessage": "Managed by"
},
"content.managed-card.modpack-content": {
"defaultMessage": "Modpack content"
},
"content.managed-card.server-suffix": {
"defaultMessage": "server"
},
"content.managed-card.settings": {
"defaultMessage": "Installation settings"
},
"content.managed-card.shared-content": {
"defaultMessage": "Shared content"
},
"content.managed-card.summary.empty": {
"defaultMessage": "No managed content"
},
"content.managed-card.summary.installing": {
"defaultMessage": "Installing content..."
},
"content.managed-card.summary.loading": {
"defaultMessage": "Loading managed content summary"
},
"content.managed-card.switch-version": {
"defaultMessage": "Switch version"
},
"content.managed-card.synced": {
"defaultMessage": "Synced {time}"
},
"content.managed-card.update-available": {
"defaultMessage": "Update available"
},
"content.managed-card.updated": {
"defaultMessage": "Updated {time}"
},
"content.managed-card.updating": {
"defaultMessage": "Updating..."
},
"content.managed-card.view-content": {
"defaultMessage": "View content"
},
"content.metadata-filter.author": {
"defaultMessage": "Author"
},
"content.metadata-filter.environment": {
"defaultMessage": "Environment"
},
"content.metadata-filter.open-source": {
"defaultMessage": "Open source"
},
"content.metadata-filter.shared-content": {
"defaultMessage": "Shared content"
},
"content.metadata-filter.source.external": {
"defaultMessage": "External"
},
"content.metadata-filter.state": {
"defaultMessage": "State"
},
"content.metadata-filter.state.disabled": {
"defaultMessage": "Disabled"
},
"content.metadata-filter.state.enabled": {
"defaultMessage": "Enabled"
},
"content.metadata-filter.update.available": {
"defaultMessage": "Update available"
},
"content.metadata-filter.update.up-to-date": {
"defaultMessage": "Up to date"
},
"content.metadata-filter.updates": {
"defaultMessage": "Updates"
},
"content.metadata-filter.warning.client-depends": {
"defaultMessage": "Client depends on file"
},
"content.metadata-filter.warning.client-only": {
"defaultMessage": "Client-only content"
},
"content.metadata-filter.warning.client-retained": {
"defaultMessage": "Client file retained"
},
"content.metadata-filter.warning.none": {
"defaultMessage": "No warnings"
},
"content.metadata-filter.warnings": {
"defaultMessage": "Warnings"
},
"content.page-layout.additional-content": {
"defaultMessage": "Additional content"
},
@@ -593,6 +686,12 @@
"content.page-layout.failed-to-load": {
"defaultMessage": "Failed to load content"
},
"content.page-layout.filter.add": {
"defaultMessage": "Filter"
},
"content.page-layout.filter.author-count": {
"defaultMessage": "{count, plural, one {# author} other {# authors}}"
},
"content.page-layout.loading": {
"defaultMessage": "Loading content..."
},
@@ -620,8 +719,11 @@
"content.page-layout.share.project-names": {
"defaultMessage": "Project names"
},
"content.page-layout.sort.alphabetical": {
"defaultMessage": "Alphabetical"
"content.page-layout.sort.alphabetical-ascending": {
"defaultMessage": "Name (A-Z)"
},
"content.page-layout.sort.alphabetical-descending": {
"defaultMessage": "Name (Z-A)"
},
"content.page-layout.sort.date-added-newest": {
"defaultMessage": "Newest first"
@@ -1772,6 +1874,9 @@
"hosting.content.failed-to-upload": {
"defaultMessage": "Failed to upload file"
},
"hosting.content.managed-content.modpack-header": {
"defaultMessage": "Modpack content"
},
"hosting.loader.failed-to-change-version": {
"defaultMessage": "Failed to change modpack version"
},
@@ -2081,31 +2186,31 @@
"instances.content-install.select-icon": {
"defaultMessage": "Select icon"
},
"instances.modpack-content-modal.empty-description": {
"defaultMessage": "This modpack does not include any additional content."
"instances.managed-content-modal.empty-description": {
"defaultMessage": "This source does not include any managed content."
},
"instances.modpack-content-modal.empty-title": {
"instances.managed-content-modal.empty-title": {
"defaultMessage": "No content found"
},
"instances.modpack-content-modal.external-content": {
"instances.managed-content-modal.external-content": {
"defaultMessage": "External"
},
"instances.modpack-content-modal.external-content-description": {
"instances.managed-content-modal.external-content-description": {
"defaultMessage": "This file is not published on Modrinth."
},
"instances.modpack-content-modal.header": {
"defaultMessage": "Modpack content"
"instances.managed-content-modal.header": {
"defaultMessage": "Managed content"
},
"instances.modpack-content-modal.loading": {
"instances.managed-content-modal.loading": {
"defaultMessage": "Loading content..."
},
"instances.modpack-content-modal.no-results": {
"instances.managed-content-modal.no-results": {
"defaultMessage": "No projects match your search."
},
"instances.modpack-content-modal.open-in-slicer": {
"instances.managed-content-modal.open-in-slicer": {
"defaultMessage": "Open in Slicer"
},
"instances.modpack-content-modal.search-placeholder": {
"instances.managed-content-modal.search-placeholder": {
"defaultMessage": "Search {count, number} {count, plural, one {project} other {projects}}"
},
"instances.updater-modal.badge.current": {
-43
View File
@@ -566,9 +566,6 @@
"content.inline-backup.world-label": {
"defaultMessage": "mundo"
},
"content.modpack-card.installation-settings": {
"defaultMessage": "Ajustes de instalación"
},
"content.page-layout.additional-content": {
"defaultMessage": "Contenido adicional"
},
@@ -620,18 +617,6 @@
"content.page-layout.share.project-names": {
"defaultMessage": "Nombres de los proyectos"
},
"content.page-layout.sort.alphabetical": {
"defaultMessage": "Alfabético"
},
"content.page-layout.sort.date-added-newest": {
"defaultMessage": "Más recientes primero"
},
"content.page-layout.sort.date-added-oldest": {
"defaultMessage": "Más antiguo primero"
},
"content.page-layout.sort.label": {
"defaultMessage": "Ordenar por {mode}"
},
"content.page-layout.update-all": {
"defaultMessage": "Actualizar todo"
},
@@ -2081,33 +2066,6 @@
"instances.content-install.select-icon": {
"defaultMessage": "Seleccionar ícono"
},
"instances.modpack-content-modal.empty-description": {
"defaultMessage": "Este modpack no incluye ningún contenido adicional."
},
"instances.modpack-content-modal.empty-title": {
"defaultMessage": "No se encontró contenido"
},
"instances.modpack-content-modal.external-content": {
"defaultMessage": "Externo"
},
"instances.modpack-content-modal.external-content-description": {
"defaultMessage": "Este archivo no está publicado en Modrinth."
},
"instances.modpack-content-modal.header": {
"defaultMessage": "Contenido del modpack"
},
"instances.modpack-content-modal.loading": {
"defaultMessage": "Cargando contenido..."
},
"instances.modpack-content-modal.no-results": {
"defaultMessage": "Ningún proyecto coincide con tu búsqueda."
},
"instances.modpack-content-modal.open-in-slicer": {
"defaultMessage": "Abrir en Slicer"
},
"instances.modpack-content-modal.search-placeholder": {
"defaultMessage": "Buscar en {count, number} {count, plural, one {proyecto} other {proyectos}}"
},
"instances.updater-modal.badge.current": {
"defaultMessage": "Actual"
},
@@ -6231,4 +6189,3 @@
"defaultMessage": "Tipo"
}
}
-42
View File
@@ -566,9 +566,6 @@
"content.inline-backup.world-label": {
"defaultMessage": " mundo"
},
"content.modpack-card.installation-settings": {
"defaultMessage": "Configuración de la instalación"
},
"content.page-layout.additional-content": {
"defaultMessage": "Contenido adicional"
},
@@ -620,18 +617,6 @@
"content.page-layout.share.project-names": {
"defaultMessage": "Nombres de los proyectos"
},
"content.page-layout.sort.alphabetical": {
"defaultMessage": "Por orden alfabético"
},
"content.page-layout.sort.date-added-newest": {
"defaultMessage": "Más recientes primero"
},
"content.page-layout.sort.date-added-oldest": {
"defaultMessage": "Más antiguo primero"
},
"content.page-layout.sort.label": {
"defaultMessage": "Ordenar por {mode}"
},
"content.page-layout.update-all": {
"defaultMessage": "Actualizar todo"
},
@@ -2081,33 +2066,6 @@
"instances.content-install.select-icon": {
"defaultMessage": "Seleccionar icono"
},
"instances.modpack-content-modal.empty-description": {
"defaultMessage": "Este modpack no incluye contenido adicional."
},
"instances.modpack-content-modal.empty-title": {
"defaultMessage": "No se ha encontrado contenido"
},
"instances.modpack-content-modal.external-content": {
"defaultMessage": "Externo"
},
"instances.modpack-content-modal.external-content-description": {
"defaultMessage": "Este archivo no está publicado en Modrinth."
},
"instances.modpack-content-modal.header": {
"defaultMessage": "Contenido del modpack"
},
"instances.modpack-content-modal.loading": {
"defaultMessage": "Cargando contenido..."
},
"instances.modpack-content-modal.no-results": {
"defaultMessage": "No hay proyectos que coincidan con tu búsqueda."
},
"instances.modpack-content-modal.open-in-slicer": {
"defaultMessage": "Abrir en Slicer"
},
"instances.modpack-content-modal.search-placeholder": {
"defaultMessage": "Buscar {count, number} {count, plural, one {proyecto} other {proyectos}}"
},
"instances.updater-modal.badge.current": {
"defaultMessage": "Actual"
},
-28
View File
@@ -266,15 +266,6 @@
"content.page-layout.share.project-links": {
"defaultMessage": "Mga link ng proyekto"
},
"content.page-layout.sort.alphabetical": {
"defaultMessage": "Alpabetiko"
},
"content.page-layout.sort.date-added-newest": {
"defaultMessage": "Pinakabago ang mauuna"
},
"content.page-layout.sort.date-added-oldest": {
"defaultMessage": "Pinakaluma ang mauuna"
},
"content.selection-bar.all-already-disabled": {
"defaultMessage": "Nakapatay na ang lahat ng piniling konento"
},
@@ -662,24 +653,6 @@
"instances.content-install.no-instances": {
"defaultMessage": "Walang mahanap na magkatugmang instansiya"
},
"instances.modpack-content-modal.empty-description": {
"defaultMessage": "Hindi naglalaman ng karagdagang kontento ang modpack na ito."
},
"instances.modpack-content-modal.empty-title": {
"defaultMessage": "Walang mahanap na kontento"
},
"instances.modpack-content-modal.header": {
"defaultMessage": "Kontentong modpack"
},
"instances.modpack-content-modal.loading": {
"defaultMessage": "Inihahanda ang kontento..."
},
"instances.modpack-content-modal.no-results": {
"defaultMessage": "Walang proyektong tumugma sa iyong paghanap."
},
"instances.modpack-content-modal.search-placeholder": {
"defaultMessage": "Maghanap sa {count, number} {count, plural, one {proyekto} other {na proyekto}}"
},
"instances.updater-modal.badge.current": {
"defaultMessage": "Kasalukuyan"
},
@@ -2763,4 +2736,3 @@
"defaultMessage": "Uri"
}
}
-42
View File
@@ -566,9 +566,6 @@
"content.inline-backup.world-label": {
"defaultMessage": "monde"
},
"content.modpack-card.installation-settings": {
"defaultMessage": "Paramètres d'installation"
},
"content.page-layout.additional-content": {
"defaultMessage": "Contenu supplémentaire"
},
@@ -620,18 +617,6 @@
"content.page-layout.share.project-names": {
"defaultMessage": "Noms des projets"
},
"content.page-layout.sort.alphabetical": {
"defaultMessage": "Alphabétique"
},
"content.page-layout.sort.date-added-newest": {
"defaultMessage": "Plus récent en premier"
},
"content.page-layout.sort.date-added-oldest": {
"defaultMessage": "Plus ancien en premier"
},
"content.page-layout.sort.label": {
"defaultMessage": "Trier par {mode}"
},
"content.page-layout.update-all": {
"defaultMessage": "Tout mettre à jour"
},
@@ -2075,33 +2060,6 @@
"instances.content-install.select-icon": {
"defaultMessage": "Sélectionnez l'icône"
},
"instances.modpack-content-modal.empty-description": {
"defaultMessage": "Aucun contenu supplémentaire n'est inclus dans ce modpack."
},
"instances.modpack-content-modal.empty-title": {
"defaultMessage": "Aucun contenu trouvé"
},
"instances.modpack-content-modal.external-content": {
"defaultMessage": "Externe"
},
"instances.modpack-content-modal.external-content-description": {
"defaultMessage": "Ce fichier n'est pas publié sur Modrinth."
},
"instances.modpack-content-modal.header": {
"defaultMessage": "Contenu du modpack"
},
"instances.modpack-content-modal.loading": {
"defaultMessage": "Chargement du contenu..."
},
"instances.modpack-content-modal.no-results": {
"defaultMessage": "Aucun projet correspond à votre recherche."
},
"instances.modpack-content-modal.open-in-slicer": {
"defaultMessage": "Ouvrir dans Slicer"
},
"instances.modpack-content-modal.search-placeholder": {
"defaultMessage": "Rechercher {count, number} {count, plural, one {projet} other {projets}}"
},
"instances.updater-modal.badge.current": {
"defaultMessage": "Actuel"
},
-42
View File
@@ -494,9 +494,6 @@
"content.inline-backup.world-label": {
"defaultMessage": "világ"
},
"content.modpack-card.installation-settings": {
"defaultMessage": "Telepítési beállítások"
},
"content.page-layout.additional-content": {
"defaultMessage": "További tartalom"
},
@@ -548,18 +545,6 @@
"content.page-layout.share.project-names": {
"defaultMessage": "Projektnevek"
},
"content.page-layout.sort.alphabetical": {
"defaultMessage": "Ábécé sorrendben"
},
"content.page-layout.sort.date-added-newest": {
"defaultMessage": "Legújabbak elöl"
},
"content.page-layout.sort.date-added-oldest": {
"defaultMessage": "Legrégebbiek elöl"
},
"content.page-layout.sort.label": {
"defaultMessage": "Rendezés {mode} szerint"
},
"content.page-layout.update-all": {
"defaultMessage": "Összes frissítése"
},
@@ -1733,33 +1718,6 @@
"instances.content-install.select-icon": {
"defaultMessage": "Ikon feltöltése"
},
"instances.modpack-content-modal.empty-description": {
"defaultMessage": "Ez a modcsomag nem tartalmaz további tartalmakat."
},
"instances.modpack-content-modal.empty-title": {
"defaultMessage": "Nincsen tartalom"
},
"instances.modpack-content-modal.external-content": {
"defaultMessage": "Külső"
},
"instances.modpack-content-modal.external-content-description": {
"defaultMessage": "Ez a fájl nem szerepel a Modrinthon."
},
"instances.modpack-content-modal.header": {
"defaultMessage": "Modcsomag tartalma"
},
"instances.modpack-content-modal.loading": {
"defaultMessage": "Tartalmak betöltése..."
},
"instances.modpack-content-modal.no-results": {
"defaultMessage": "Nincsen olyan projekt ami keresnél."
},
"instances.modpack-content-modal.open-in-slicer": {
"defaultMessage": "Megnyitás a Slicer-ben"
},
"instances.modpack-content-modal.search-placeholder": {
"defaultMessage": "Keresés {count, number} projekt között"
},
"instances.updater-modal.badge.current": {
"defaultMessage": "Jelenlegi"
},
-31
View File
@@ -503,18 +503,6 @@
"content.page-layout.share.project-names": {
"defaultMessage": "Nama proyek"
},
"content.page-layout.sort.alphabetical": {
"defaultMessage": "Menurut abjad"
},
"content.page-layout.sort.date-added-newest": {
"defaultMessage": "Terbaru dahulu"
},
"content.page-layout.sort.date-added-oldest": {
"defaultMessage": "Tertua dahulu"
},
"content.page-layout.sort.label": {
"defaultMessage": "Urutkan berdasarkan {mode}"
},
"content.page-layout.update-all": {
"defaultMessage": "Perbarui semua"
},
@@ -1172,24 +1160,6 @@
"instance.worlds.game_mode.unknown": {
"defaultMessage": "Mode permainan tak dikenal"
},
"instances.modpack-content-modal.empty-description": {
"defaultMessage": "Paket mod ini tidak termasuk konten tambahan."
},
"instances.modpack-content-modal.empty-title": {
"defaultMessage": "Konten tidak ditemukan"
},
"instances.modpack-content-modal.header": {
"defaultMessage": "Konten paket mod"
},
"instances.modpack-content-modal.loading": {
"defaultMessage": "Sedang memuat konten..."
},
"instances.modpack-content-modal.no-results": {
"defaultMessage": "Tidak ada proyek yang cocok dengan pencarian Anda."
},
"instances.modpack-content-modal.search-placeholder": {
"defaultMessage": "Cari {count, number} {count, plural, other {proyek}}"
},
"instances.updater-modal.badge.current": {
"defaultMessage": "Saat ini"
},
@@ -2982,4 +2952,3 @@
"defaultMessage": "Anda memiliki perubahan yang belum tersimpan."
}
}
-42
View File
@@ -545,9 +545,6 @@
"content.inline-backup.world-label": {
"defaultMessage": "mondo"
},
"content.modpack-card.installation-settings": {
"defaultMessage": "Impostazioni installazione"
},
"content.page-layout.additional-content": {
"defaultMessage": "Contenuto aggiuntivo"
},
@@ -599,18 +596,6 @@
"content.page-layout.share.project-names": {
"defaultMessage": "Nomi dei progetti"
},
"content.page-layout.sort.alphabetical": {
"defaultMessage": "Alfabetico"
},
"content.page-layout.sort.date-added-newest": {
"defaultMessage": "Più recenti prima"
},
"content.page-layout.sort.date-added-oldest": {
"defaultMessage": "Meno recenti prima"
},
"content.page-layout.sort.label": {
"defaultMessage": "Ordina per {mode}"
},
"content.page-layout.update-all": {
"defaultMessage": "Aggiorna tutto"
},
@@ -2039,33 +2024,6 @@
"instances.content-install.select-icon": {
"defaultMessage": "Scegli un'icona"
},
"instances.modpack-content-modal.empty-description": {
"defaultMessage": "Questo pacchetto di mod non include alcun contenuto aggiuntivo."
},
"instances.modpack-content-modal.empty-title": {
"defaultMessage": "Nessun contenuto trovato"
},
"instances.modpack-content-modal.external-content": {
"defaultMessage": "Esterno"
},
"instances.modpack-content-modal.external-content-description": {
"defaultMessage": "Questo file non è su Modrinth."
},
"instances.modpack-content-modal.header": {
"defaultMessage": "Contenuto del pacchetto"
},
"instances.modpack-content-modal.loading": {
"defaultMessage": "Caricamento contenuto..."
},
"instances.modpack-content-modal.no-results": {
"defaultMessage": "Nessun progetto corrisponde alla tua ricerca."
},
"instances.modpack-content-modal.open-in-slicer": {
"defaultMessage": "Apri con Slicer"
},
"instances.modpack-content-modal.search-placeholder": {
"defaultMessage": "Cerca tra {count, number} {count, plural, one {progetto} other {progetti}}"
},
"instances.updater-modal.badge.current": {
"defaultMessage": "Attuale"
},
-42
View File
@@ -563,9 +563,6 @@
"content.inline-backup.world-label": {
"defaultMessage": "ワールド"
},
"content.modpack-card.installation-settings": {
"defaultMessage": "インストール設定"
},
"content.page-layout.additional-content": {
"defaultMessage": "追加のコンテンツ"
},
@@ -617,18 +614,6 @@
"content.page-layout.share.project-names": {
"defaultMessage": "プロジェクト名"
},
"content.page-layout.sort.alphabetical": {
"defaultMessage": "名前順"
},
"content.page-layout.sort.date-added-newest": {
"defaultMessage": "新しい順"
},
"content.page-layout.sort.date-added-oldest": {
"defaultMessage": "古い順"
},
"content.page-layout.sort.label": {
"defaultMessage": "{mode}"
},
"content.page-layout.update-all": {
"defaultMessage": "すべて更新"
},
@@ -2078,33 +2063,6 @@
"instances.content-install.select-icon": {
"defaultMessage": "アイコンを選択"
},
"instances.modpack-content-modal.empty-description": {
"defaultMessage": "このModパックに追加コンテンツは一切含まれていません。"
},
"instances.modpack-content-modal.empty-title": {
"defaultMessage": "コンテンツが見つかりません"
},
"instances.modpack-content-modal.external-content": {
"defaultMessage": "外部"
},
"instances.modpack-content-modal.external-content-description": {
"defaultMessage": "このファイルは Modrinth 上で公開されていません"
},
"instances.modpack-content-modal.header": {
"defaultMessage": "Modpackコンテンツ"
},
"instances.modpack-content-modal.loading": {
"defaultMessage": "コンテンツを読み込み中…"
},
"instances.modpack-content-modal.no-results": {
"defaultMessage": "該当するプロジェクトがありません。"
},
"instances.modpack-content-modal.open-in-slicer": {
"defaultMessage": "Slicer で開く"
},
"instances.modpack-content-modal.search-placeholder": {
"defaultMessage": "{count, number}件のプロジェクトを検索"
},
"instances.updater-modal.badge.current": {
"defaultMessage": "現在"
},
-42
View File
@@ -566,9 +566,6 @@
"content.inline-backup.world-label": {
"defaultMessage": "세계"
},
"content.modpack-card.installation-settings": {
"defaultMessage": "설치 설정"
},
"content.page-layout.additional-content": {
"defaultMessage": "추가 콘텐츠"
},
@@ -620,18 +617,6 @@
"content.page-layout.share.project-names": {
"defaultMessage": "프로젝트 이름"
},
"content.page-layout.sort.alphabetical": {
"defaultMessage": "알파벳순"
},
"content.page-layout.sort.date-added-newest": {
"defaultMessage": "최신순"
},
"content.page-layout.sort.date-added-oldest": {
"defaultMessage": "오래된순"
},
"content.page-layout.sort.label": {
"defaultMessage": "{mode}으로 정렬"
},
"content.page-layout.update-all": {
"defaultMessage": "모두 업데이트"
},
@@ -2081,33 +2066,6 @@
"instances.content-install.select-icon": {
"defaultMessage": "아이콘 선택"
},
"instances.modpack-content-modal.empty-description": {
"defaultMessage": "이 모드팩에는 추가 콘텐츠가 포함되어 있지 않습니다."
},
"instances.modpack-content-modal.empty-title": {
"defaultMessage": "콘텐츠를 찾을 수 없습니다"
},
"instances.modpack-content-modal.external-content": {
"defaultMessage": "외부"
},
"instances.modpack-content-modal.external-content-description": {
"defaultMessage": "이 파일은 Modrinth에 게시되지 않았습니다."
},
"instances.modpack-content-modal.header": {
"defaultMessage": "모드팩 콘텐츠"
},
"instances.modpack-content-modal.loading": {
"defaultMessage": "콘텐츠 불러오는 중..."
},
"instances.modpack-content-modal.no-results": {
"defaultMessage": "검색 결과와 일치하는 프로젝트가 없습니다."
},
"instances.modpack-content-modal.open-in-slicer": {
"defaultMessage": "Slicer에서 열기"
},
"instances.modpack-content-modal.search-placeholder": {
"defaultMessage": "{count, number}개의 프로젝트 검색"
},
"instances.updater-modal.badge.current": {
"defaultMessage": "현재"
},
-31
View File
@@ -488,18 +488,6 @@
"content.page-layout.share.project-names": {
"defaultMessage": "Nama projek"
},
"content.page-layout.sort.alphabetical": {
"defaultMessage": "Mengikut abjad"
},
"content.page-layout.sort.date-added-newest": {
"defaultMessage": "Paling baharu dahulu"
},
"content.page-layout.sort.date-added-oldest": {
"defaultMessage": "Paling lama dahulu"
},
"content.page-layout.sort.label": {
"defaultMessage": "Isih mengikut {mode}"
},
"content.page-layout.update-all": {
"defaultMessage": "Kemas kini semua"
},
@@ -1679,24 +1667,6 @@
"instances.content-install.select-icon": {
"defaultMessage": "Pilih ikon"
},
"instances.modpack-content-modal.empty-description": {
"defaultMessage": "Pek mod ini tidak menyertakan sebarang kandungan tambahan."
},
"instances.modpack-content-modal.empty-title": {
"defaultMessage": "Tiada kandungan dijumpai"
},
"instances.modpack-content-modal.header": {
"defaultMessage": "Kandungan pek mod"
},
"instances.modpack-content-modal.loading": {
"defaultMessage": "Sedang memuat kandungan..."
},
"instances.modpack-content-modal.no-results": {
"defaultMessage": "Tiada projek yang sepadan dengan carian anda."
},
"instances.modpack-content-modal.search-placeholder": {
"defaultMessage": "Cari {count, number} {count, plural, other {projek}}"
},
"instances.updater-modal.badge.current": {
"defaultMessage": "Semasa"
},
@@ -5055,4 +5025,3 @@
"defaultMessage": "Jenis"
}
}
-42
View File
@@ -566,9 +566,6 @@
"content.inline-backup.world-label": {
"defaultMessage": "wereld"
},
"content.modpack-card.installation-settings": {
"defaultMessage": "Installatie-instellingen"
},
"content.page-layout.additional-content": {
"defaultMessage": "Extra inhoud"
},
@@ -620,18 +617,6 @@
"content.page-layout.share.project-names": {
"defaultMessage": "Projectnamen"
},
"content.page-layout.sort.alphabetical": {
"defaultMessage": "Alfabetisch"
},
"content.page-layout.sort.date-added-newest": {
"defaultMessage": "Nieuwste eerst"
},
"content.page-layout.sort.date-added-oldest": {
"defaultMessage": "Oudste eerst"
},
"content.page-layout.sort.label": {
"defaultMessage": "Sorteer op {mode}"
},
"content.page-layout.update-all": {
"defaultMessage": "Werk alles bij"
},
@@ -2078,33 +2063,6 @@
"instances.content-install.select-icon": {
"defaultMessage": "Selecteer pictogram"
},
"instances.modpack-content-modal.empty-description": {
"defaultMessage": "Deze modpack bevat geen extra inhoud."
},
"instances.modpack-content-modal.empty-title": {
"defaultMessage": "Geen inhoud gevonden"
},
"instances.modpack-content-modal.external-content": {
"defaultMessage": "Extern"
},
"instances.modpack-content-modal.external-content-description": {
"defaultMessage": "Dit bestand is niet gepubliceerd op Modrinth."
},
"instances.modpack-content-modal.header": {
"defaultMessage": "Inhoud van modpack"
},
"instances.modpack-content-modal.loading": {
"defaultMessage": "Inhoud aan het laden..."
},
"instances.modpack-content-modal.no-results": {
"defaultMessage": "Geen projecten voldoen aan je zoekopdracht."
},
"instances.modpack-content-modal.open-in-slicer": {
"defaultMessage": "In Slicer openen"
},
"instances.modpack-content-modal.search-placeholder": {
"defaultMessage": "Zoek naar {count, number} {count, plural, one {project} other {projecten}}"
},
"instances.updater-modal.badge.current": {
"defaultMessage": "Huidige"
},
-25
View File
@@ -413,18 +413,6 @@
"content.page-layout.share.project-names": {
"defaultMessage": "Prosjektnavn"
},
"content.page-layout.sort.alphabetical": {
"defaultMessage": "Alfabetisk"
},
"content.page-layout.sort.date-added-newest": {
"defaultMessage": "Nyeste først"
},
"content.page-layout.sort.date-added-oldest": {
"defaultMessage": "Eldste først"
},
"content.page-layout.sort.label": {
"defaultMessage": "Sorter etter {mode}"
},
"content.page-layout.update-all": {
"defaultMessage": "Oppdater alle"
},
@@ -872,18 +860,6 @@
"instances.content-install.select-icon": {
"defaultMessage": "Velg ikon"
},
"instances.modpack-content-modal.empty-title": {
"defaultMessage": "Ingen innhold funnet"
},
"instances.modpack-content-modal.loading": {
"defaultMessage": "Laster inn innhold..."
},
"instances.modpack-content-modal.no-results": {
"defaultMessage": "Ingen prosjekter samsvarte med søket ditt."
},
"instances.modpack-content-modal.search-placeholder": {
"defaultMessage": "Søk etter {count, number} {count, plural, one {prosjekt} other {prosjekter}}"
},
"instances.updater-modal.badge.current": {
"defaultMessage": "Nåværende"
},
@@ -2778,4 +2754,3 @@
"defaultMessage": "Du har ulagrede endringer."
}
}
-39
View File
@@ -566,9 +566,6 @@
"content.inline-backup.world-label": {
"defaultMessage": "Twój świat"
},
"content.modpack-card.installation-settings": {
"defaultMessage": "Ustawienia instalacji"
},
"content.page-layout.additional-content": {
"defaultMessage": "Dodatkowa zawartość"
},
@@ -620,18 +617,6 @@
"content.page-layout.share.project-names": {
"defaultMessage": "Nazwy projektów"
},
"content.page-layout.sort.alphabetical": {
"defaultMessage": "Alfabetycznie"
},
"content.page-layout.sort.date-added-newest": {
"defaultMessage": "Od najnowszych"
},
"content.page-layout.sort.date-added-oldest": {
"defaultMessage": "Od najstarszych"
},
"content.page-layout.sort.label": {
"defaultMessage": "Sortuj według: {mode}"
},
"content.page-layout.update-all": {
"defaultMessage": "Zaktualizuj wszystko"
},
@@ -2072,30 +2057,6 @@
"instances.content-install.select-icon": {
"defaultMessage": "Wybierz ikonę"
},
"instances.modpack-content-modal.empty-description": {
"defaultMessage": "Ta paczka modów nie zawiera żadnych innych treści."
},
"instances.modpack-content-modal.empty-title": {
"defaultMessage": "Nie znaleziono treści"
},
"instances.modpack-content-modal.external-content": {
"defaultMessage": "Zewnętrzne"
},
"instances.modpack-content-modal.external-content-description": {
"defaultMessage": "Ten plik nie jest opublikowany na Modrinth."
},
"instances.modpack-content-modal.header": {
"defaultMessage": "Zawartość paczki modów"
},
"instances.modpack-content-modal.loading": {
"defaultMessage": "Ładowanie treści..."
},
"instances.modpack-content-modal.no-results": {
"defaultMessage": "Nie znaleziono żadnego projektu pasującego do Twojego wyszukiwania."
},
"instances.modpack-content-modal.search-placeholder": {
"defaultMessage": "Szukaj wśród {count, number} {count, plural, one {projektu} other {projektów}}"
},
"instances.updater-modal.badge.current": {
"defaultMessage": "Aktualny"
},
-43
View File
@@ -566,9 +566,6 @@
"content.inline-backup.world-label": {
"defaultMessage": "mundo"
},
"content.modpack-card.installation-settings": {
"defaultMessage": "Configurações de instalação"
},
"content.page-layout.additional-content": {
"defaultMessage": "Conteúdo adicional"
},
@@ -620,18 +617,6 @@
"content.page-layout.share.project-names": {
"defaultMessage": "Nomes dos projetos"
},
"content.page-layout.sort.alphabetical": {
"defaultMessage": "Alfabético"
},
"content.page-layout.sort.date-added-newest": {
"defaultMessage": "Mais recentes primeiro"
},
"content.page-layout.sort.date-added-oldest": {
"defaultMessage": "Mais antigos primeiro"
},
"content.page-layout.sort.label": {
"defaultMessage": "Ordenar por {mode}"
},
"content.page-layout.update-all": {
"defaultMessage": "Atualizar tudo"
},
@@ -2081,33 +2066,6 @@
"instances.content-install.select-icon": {
"defaultMessage": "Selecionar ícone"
},
"instances.modpack-content-modal.empty-description": {
"defaultMessage": "Este pacote de mods não inclui nenhum conteúdo adicional."
},
"instances.modpack-content-modal.empty-title": {
"defaultMessage": "Nenhum conteúdo encontrado"
},
"instances.modpack-content-modal.external-content": {
"defaultMessage": "Externo"
},
"instances.modpack-content-modal.external-content-description": {
"defaultMessage": "Este arquivo não está publicado no Modrinth."
},
"instances.modpack-content-modal.header": {
"defaultMessage": "Conteúdo do pacote de mods"
},
"instances.modpack-content-modal.loading": {
"defaultMessage": "Carregando conteúdo..."
},
"instances.modpack-content-modal.no-results": {
"defaultMessage": "Nenhum projeto corresponde com sua busca."
},
"instances.modpack-content-modal.open-in-slicer": {
"defaultMessage": "Abrir no Slicer"
},
"instances.modpack-content-modal.search-placeholder": {
"defaultMessage": "Busque {count, number} {count, plural, one {projeto} other {projetos}}"
},
"instances.updater-modal.badge.current": {
"defaultMessage": "Atual"
},
@@ -6231,4 +6189,3 @@
"defaultMessage": "Tipo"
}
}
-28
View File
@@ -398,18 +398,6 @@
"content.page-layout.share.project-names": {
"defaultMessage": "Nomes dos projetos"
},
"content.page-layout.sort.alphabetical": {
"defaultMessage": "Alfabético"
},
"content.page-layout.sort.date-added-newest": {
"defaultMessage": "Mais recentes primeiro"
},
"content.page-layout.sort.date-added-oldest": {
"defaultMessage": "Mais antigos primeiro"
},
"content.page-layout.sort.label": {
"defaultMessage": "Ordenar por {mode}"
},
"content.page-layout.update-all": {
"defaultMessage": "Atualizar tudo"
},
@@ -749,21 +737,6 @@
"instance.worlds.game_mode.unknown": {
"defaultMessage": "Modo de jogo desconhecido"
},
"instances.modpack-content-modal.empty-description": {
"defaultMessage": "Este modpack não inclui nenhum conteúdo adicional."
},
"instances.modpack-content-modal.empty-title": {
"defaultMessage": "Nenhum conteúdo encontrado"
},
"instances.modpack-content-modal.header": {
"defaultMessage": "Conteúdo do modpack"
},
"instances.modpack-content-modal.loading": {
"defaultMessage": "A carregar conteúdo..."
},
"instances.modpack-content-modal.no-results": {
"defaultMessage": "Nenhum projeto corresponde à tua pesquisa."
},
"instances.updater-modal.badge.current": {
"defaultMessage": "Atual"
},
@@ -2523,4 +2496,3 @@
"defaultMessage": "Tens alterações por guardar."
}
}
-30
View File
@@ -425,18 +425,6 @@
"content.page-layout.share.project-names": {
"defaultMessage": "Nume ale proiectului"
},
"content.page-layout.sort.alphabetical": {
"defaultMessage": "Alfabetic"
},
"content.page-layout.sort.date-added-newest": {
"defaultMessage": "Cele noi mai întâi"
},
"content.page-layout.sort.date-added-oldest": {
"defaultMessage": "Cele vechi mai întâi"
},
"content.page-layout.sort.label": {
"defaultMessage": "Sortează cu {mode}"
},
"content.page-layout.update-all": {
"defaultMessage": "Actualizează tot"
},
@@ -1022,24 +1010,6 @@
"instances.content-install.select-icon": {
"defaultMessage": "Selectează iconița"
},
"instances.modpack-content-modal.empty-description": {
"defaultMessage": "Acest modpack nu include niciun conținut suplimentar."
},
"instances.modpack-content-modal.empty-title": {
"defaultMessage": "Nu a fost găsit niciun conținut"
},
"instances.modpack-content-modal.header": {
"defaultMessage": "Conținutul Modpack"
},
"instances.modpack-content-modal.loading": {
"defaultMessage": "Se încarcă materialul..."
},
"instances.modpack-content-modal.no-results": {
"defaultMessage": "Niciun proiect nu corespunde căutării dvs."
},
"instances.modpack-content-modal.search-placeholder": {
"defaultMessage": "Căutare {count, number}{count, plural,one {proiect}other {proiecte}}"
},
"instances.updater-modal.badge.current": {
"defaultMessage": "Curent"
},
-42
View File
@@ -536,9 +536,6 @@
"content.inline-backup.world-label": {
"defaultMessage": "мир"
},
"content.modpack-card.installation-settings": {
"defaultMessage": "Настройки установки"
},
"content.page-layout.additional-content": {
"defaultMessage": "Дополнительный контент"
},
@@ -587,18 +584,6 @@
"content.page-layout.share.project-names": {
"defaultMessage": "Названия проектов"
},
"content.page-layout.sort.alphabetical": {
"defaultMessage": "По алфавиту"
},
"content.page-layout.sort.date-added-newest": {
"defaultMessage": "Сначала новые"
},
"content.page-layout.sort.date-added-oldest": {
"defaultMessage": "Сначала старые"
},
"content.page-layout.sort.label": {
"defaultMessage": "Сортировка: {mode}"
},
"content.page-layout.update-all": {
"defaultMessage": "Обновить всё"
},
@@ -1997,33 +1982,6 @@
"instances.content-install.select-icon": {
"defaultMessage": "Выбрать иконку"
},
"instances.modpack-content-modal.empty-description": {
"defaultMessage": "В сборке нет дополнительного контента."
},
"instances.modpack-content-modal.empty-title": {
"defaultMessage": "Сборка пуста"
},
"instances.modpack-content-modal.external-content": {
"defaultMessage": "Сторонний"
},
"instances.modpack-content-modal.external-content-description": {
"defaultMessage": "Файл не опубликован на Modrinth."
},
"instances.modpack-content-modal.header": {
"defaultMessage": "Содержимое сборки"
},
"instances.modpack-content-modal.loading": {
"defaultMessage": "Загрузка содержимого..."
},
"instances.modpack-content-modal.no-results": {
"defaultMessage": "Не найдены проекты, соответствующие запросу."
},
"instances.modpack-content-modal.open-in-slicer": {
"defaultMessage": "Открыть в Slicer"
},
"instances.modpack-content-modal.search-placeholder": {
"defaultMessage": "Поиск по {count, number} {count, plural, one {проекту} other {проектам}}"
},
"instances.updater-modal.badge.current": {
"defaultMessage": "Текущая"
},
-31
View File
@@ -584,18 +584,6 @@
"content.page-layout.share.project-names": {
"defaultMessage": "Imena projekata"
},
"content.page-layout.sort.alphabetical": {
"defaultMessage": "Alfabetski"
},
"content.page-layout.sort.date-added-newest": {
"defaultMessage": "Najnovije prvo"
},
"content.page-layout.sort.date-added-oldest": {
"defaultMessage": "Najstarije prvo"
},
"content.page-layout.sort.label": {
"defaultMessage": "Sortiraj so {mode}"
},
"content.page-layout.update-all": {
"defaultMessage": "Ažuiraj sve"
},
@@ -2030,24 +2018,6 @@
"instances.content-install.select-icon": {
"defaultMessage": "Izaberi ikonu"
},
"instances.modpack-content-modal.empty-description": {
"defaultMessage": "Ovaj modpack ne uključuje nikakav dodatni sadržaj."
},
"instances.modpack-content-modal.empty-title": {
"defaultMessage": "Nema pronađenog sadržaja"
},
"instances.modpack-content-modal.header": {
"defaultMessage": "Sadržaj modpacka"
},
"instances.modpack-content-modal.loading": {
"defaultMessage": "Učitavanje sadržaja..."
},
"instances.modpack-content-modal.no-results": {
"defaultMessage": "Nijedan projekat ne odgovara tvojoj pretrazi."
},
"instances.modpack-content-modal.search-placeholder": {
"defaultMessage": "Pretraga {count, number} {count, plural, one {projekta} few {projekta} other {projekata}}"
},
"instances.updater-modal.badge.current": {
"defaultMessage": "Upravo"
},
@@ -5781,4 +5751,3 @@
"defaultMessage": "Tip"
}
}
-40
View File
@@ -560,9 +560,6 @@
"content.inline-backup.world-label": {
"defaultMessage": "värld"
},
"content.modpack-card.installation-settings": {
"defaultMessage": "Installationsinställningar"
},
"content.page-layout.additional-content": {
"defaultMessage": "Ytterligare innehåll"
},
@@ -614,18 +611,6 @@
"content.page-layout.share.project-names": {
"defaultMessage": "Projektnamn"
},
"content.page-layout.sort.alphabetical": {
"defaultMessage": "Alfabetisk"
},
"content.page-layout.sort.date-added-newest": {
"defaultMessage": "Nyaste först"
},
"content.page-layout.sort.date-added-oldest": {
"defaultMessage": "Äldsta först"
},
"content.page-layout.sort.label": {
"defaultMessage": "Sortera i {mode} ordning"
},
"content.page-layout.update-all": {
"defaultMessage": "Uppdatera alla"
},
@@ -1802,30 +1787,6 @@
"instances.content-install.select-icon": {
"defaultMessage": "Välj ikon"
},
"instances.modpack-content-modal.empty-description": {
"defaultMessage": "Modpaketet innehåller inget ytterligare innehåll."
},
"instances.modpack-content-modal.empty-title": {
"defaultMessage": "Inget innehåll hittades"
},
"instances.modpack-content-modal.external-content-description": {
"defaultMessage": "Den här filen är inte publiserad på Modrinth."
},
"instances.modpack-content-modal.header": {
"defaultMessage": "Modpaketets innehåll"
},
"instances.modpack-content-modal.loading": {
"defaultMessage": "Laddar innehåll..."
},
"instances.modpack-content-modal.no-results": {
"defaultMessage": "Inga projekt matchar din sökning."
},
"instances.modpack-content-modal.open-in-slicer": {
"defaultMessage": "Öppna i Slicer"
},
"instances.modpack-content-modal.search-placeholder": {
"defaultMessage": "Sök genom {count, number} projekt"
},
"instances.updater-modal.badge.current": {
"defaultMessage": "Nuvarande"
},
@@ -5463,4 +5424,3 @@
"defaultMessage": "Typ"
}
}
-10
View File
@@ -317,15 +317,6 @@
"instances.content-install.select-icon": {
"defaultMessage": "เลือกไอคอน"
},
"instances.modpack-content-modal.empty-title": {
"defaultMessage": "ไม่พบเนื้อหา"
},
"instances.modpack-content-modal.header": {
"defaultMessage": "เนื้อหาม็อดแพ็ค"
},
"instances.modpack-content-modal.loading": {
"defaultMessage": "กำลังโหลดเนื้อหา"
},
"label.available": {
"defaultMessage": "ว่างอยู่ {amount}"
},
@@ -663,4 +654,3 @@
"defaultMessage": "ภาษา"
}
}
-33
View File
@@ -596,18 +596,6 @@
"content.page-layout.share.project-names": {
"defaultMessage": "Proje isimleri"
},
"content.page-layout.sort.alphabetical": {
"defaultMessage": "Alfabetik"
},
"content.page-layout.sort.date-added-newest": {
"defaultMessage": "Önce en yeni"
},
"content.page-layout.sort.date-added-oldest": {
"defaultMessage": "Önce en eski"
},
"content.page-layout.sort.label": {
"defaultMessage": "Sıralama şekli {mode}"
},
"content.page-layout.update-all": {
"defaultMessage": "Tümünü güncelle"
},
@@ -2051,27 +2039,6 @@
"instances.content-install.select-icon": {
"defaultMessage": "Simge seç"
},
"instances.modpack-content-modal.empty-description": {
"defaultMessage": "Bu mod paketi başka hiç bir ekstra işerik içermez."
},
"instances.modpack-content-modal.empty-title": {
"defaultMessage": "İçerik bulunamadı"
},
"instances.modpack-content-modal.external-content": {
"defaultMessage": "Harici"
},
"instances.modpack-content-modal.header": {
"defaultMessage": "Mod paketi içeriği"
},
"instances.modpack-content-modal.loading": {
"defaultMessage": "İçerik yükleniyor..."
},
"instances.modpack-content-modal.no-results": {
"defaultMessage": "Aramanızla eşleşen proje yok."
},
"instances.modpack-content-modal.search-placeholder": {
"defaultMessage": "Search {count, number} {count, plural, one {project} other {projects}}"
},
"instances.updater-modal.badge.current": {
"defaultMessage": "Şu anki"
},
-42
View File
@@ -563,9 +563,6 @@
"content.inline-backup.world-label": {
"defaultMessage": "світ"
},
"content.modpack-card.installation-settings": {
"defaultMessage": "Налаштування інсталяції"
},
"content.page-layout.additional-content": {
"defaultMessage": "Додатковий уміст"
},
@@ -611,18 +608,6 @@
"content.page-layout.share.project-names": {
"defaultMessage": "Назви проєктів"
},
"content.page-layout.sort.alphabetical": {
"defaultMessage": "За абеткою"
},
"content.page-layout.sort.date-added-newest": {
"defaultMessage": "Спочатку новіші"
},
"content.page-layout.sort.date-added-oldest": {
"defaultMessage": "Спочатку старіші"
},
"content.page-layout.sort.label": {
"defaultMessage": "Сортування: {mode}"
},
"content.page-layout.update-all": {
"defaultMessage": "Оновити все"
},
@@ -2036,33 +2021,6 @@
"instances.content-install.select-icon": {
"defaultMessage": "Вибрати значок"
},
"instances.modpack-content-modal.empty-description": {
"defaultMessage": "Збірка не включає додатковий уміст."
},
"instances.modpack-content-modal.empty-title": {
"defaultMessage": "Умісту не знайдено"
},
"instances.modpack-content-modal.external-content": {
"defaultMessage": "Зовнішні"
},
"instances.modpack-content-modal.external-content-description": {
"defaultMessage": "Цей файл не опубліковано на Modrinth."
},
"instances.modpack-content-modal.header": {
"defaultMessage": "Уміст збірки"
},
"instances.modpack-content-modal.loading": {
"defaultMessage": "Завантаження вмісту…"
},
"instances.modpack-content-modal.no-results": {
"defaultMessage": "За вашим запитом не знайдено жодного проєкту."
},
"instances.modpack-content-modal.open-in-slicer": {
"defaultMessage": "Відкрити в Slicer"
},
"instances.modpack-content-modal.search-placeholder": {
"defaultMessage": "Пошук {count, number} {count, plural, one {проєкту} other {проєктів}}"
},
"instances.updater-modal.badge.current": {
"defaultMessage": "Поточна"
},
-31
View File
@@ -530,18 +530,6 @@
"content.page-layout.share.project-names": {
"defaultMessage": "Tên dự án"
},
"content.page-layout.sort.alphabetical": {
"defaultMessage": "Thứ tự bảng chữ cái"
},
"content.page-layout.sort.date-added-newest": {
"defaultMessage": "Mới nhất"
},
"content.page-layout.sort.date-added-oldest": {
"defaultMessage": "Cũ nhất"
},
"content.page-layout.sort.label": {
"defaultMessage": "Sắp xếp theo {mode}"
},
"content.page-layout.update-all": {
"defaultMessage": "Cập nhật tất cả"
},
@@ -1709,24 +1697,6 @@
"instances.content-install.select-icon": {
"defaultMessage": "Chọn biểu tượng"
},
"instances.modpack-content-modal.empty-description": {
"defaultMessage": "Gói modpack này không bao gồm các nội dung bổ sung nào."
},
"instances.modpack-content-modal.empty-title": {
"defaultMessage": "Không có nội dung nào được tìm thấy"
},
"instances.modpack-content-modal.header": {
"defaultMessage": "Nội dung modpack"
},
"instances.modpack-content-modal.loading": {
"defaultMessage": "Đang tải nội dung..."
},
"instances.modpack-content-modal.no-results": {
"defaultMessage": "Không có dự án nào phù hợp với kết quả tìm kiếm."
},
"instances.modpack-content-modal.search-placeholder": {
"defaultMessage": "Tìm {count, number} {count, plural, other {dự án}}"
},
"instances.updater-modal.badge.current": {
"defaultMessage": "Hiện tại"
},
@@ -5079,4 +5049,3 @@
"defaultMessage": "Đội ngũ Modrinth"
}
}
-43
View File
@@ -566,9 +566,6 @@
"content.inline-backup.world-label": {
"defaultMessage": "世界"
},
"content.modpack-card.installation-settings": {
"defaultMessage": "安装设置"
},
"content.page-layout.additional-content": {
"defaultMessage": "附加内容"
},
@@ -620,18 +617,6 @@
"content.page-layout.share.project-names": {
"defaultMessage": "项目名称"
},
"content.page-layout.sort.alphabetical": {
"defaultMessage": "按字母顺序"
},
"content.page-layout.sort.date-added-newest": {
"defaultMessage": "按从新到旧顺序"
},
"content.page-layout.sort.date-added-oldest": {
"defaultMessage": "按从旧到新顺序"
},
"content.page-layout.sort.label": {
"defaultMessage": "按{mode}排序"
},
"content.page-layout.update-all": {
"defaultMessage": "更新所有"
},
@@ -2081,33 +2066,6 @@
"instances.content-install.select-icon": {
"defaultMessage": "选择图标"
},
"instances.modpack-content-modal.empty-description": {
"defaultMessage": "这个整合包不包含任何额外内容。"
},
"instances.modpack-content-modal.empty-title": {
"defaultMessage": "未找到内容"
},
"instances.modpack-content-modal.external-content": {
"defaultMessage": "外部来源"
},
"instances.modpack-content-modal.external-content-description": {
"defaultMessage": "此文件未发布于 Modrinth 平台。"
},
"instances.modpack-content-modal.header": {
"defaultMessage": "整合包内容"
},
"instances.modpack-content-modal.loading": {
"defaultMessage": "加载内容……"
},
"instances.modpack-content-modal.no-results": {
"defaultMessage": "没有符合搜索条件的项目。"
},
"instances.modpack-content-modal.open-in-slicer": {
"defaultMessage": "在 Slicer 中打开"
},
"instances.modpack-content-modal.search-placeholder": {
"defaultMessage": "搜索 {count, number} {count, plural, other {个项目}}"
},
"instances.updater-modal.badge.current": {
"defaultMessage": "当前"
},
@@ -6231,4 +6189,3 @@
"defaultMessage": "类型"
}
}
-43
View File
@@ -566,9 +566,6 @@
"content.inline-backup.world-label": {
"defaultMessage": "世界"
},
"content.modpack-card.installation-settings": {
"defaultMessage": "安裝設定"
},
"content.page-layout.additional-content": {
"defaultMessage": "額外內容"
},
@@ -620,18 +617,6 @@
"content.page-layout.share.project-names": {
"defaultMessage": "專案名稱"
},
"content.page-layout.sort.alphabetical": {
"defaultMessage": "字母順序"
},
"content.page-layout.sort.date-added-newest": {
"defaultMessage": "最新在前"
},
"content.page-layout.sort.date-added-oldest": {
"defaultMessage": "最舊在前"
},
"content.page-layout.sort.label": {
"defaultMessage": "排序依據:{mode}"
},
"content.page-layout.update-all": {
"defaultMessage": "更新全部"
},
@@ -2081,33 +2066,6 @@
"instances.content-install.select-icon": {
"defaultMessage": "選擇圖示"
},
"instances.modpack-content-modal.empty-description": {
"defaultMessage": "這個模組包不包含任何額外內容。"
},
"instances.modpack-content-modal.empty-title": {
"defaultMessage": "找不到內容"
},
"instances.modpack-content-modal.external-content": {
"defaultMessage": "外部"
},
"instances.modpack-content-modal.external-content-description": {
"defaultMessage": "這個檔案未在 Modrinth 上發布。"
},
"instances.modpack-content-modal.header": {
"defaultMessage": "模組包內容"
},
"instances.modpack-content-modal.loading": {
"defaultMessage": "正在載入內容..."
},
"instances.modpack-content-modal.no-results": {
"defaultMessage": "沒有專案符合你的搜尋字詞。"
},
"instances.modpack-content-modal.open-in-slicer": {
"defaultMessage": "在 Slicer 中開啟"
},
"instances.modpack-content-modal.search-placeholder": {
"defaultMessage": "搜尋 {count, number} {count, plural, other {個專案}}"
},
"instances.updater-modal.badge.current": {
"defaultMessage": "目前"
},
@@ -6231,4 +6189,3 @@
"defaultMessage": "類型"
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
export {
type ContentManagerContext,
type ContentModpackData,
injectContentManager,
type ManagedContentData,
provideContentManager,
type UploadState,
} from '../layouts/shared/content-tab/providers/content-manager'
@@ -119,6 +119,7 @@ const etfItem: ContentCardTableItem = {
const importedModItem: ContentCardTableItem = {
id: 'imported123',
external: true,
project: {
id: 'imported123',
slug: 'import-mod',
@@ -1,633 +0,0 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { fn } from 'storybook/test'
import { ref } from 'vue'
import NewModal from '../../components/modal/NewModal.vue'
import ContentCardItem from '../../layouts/shared/content-tab/components/ContentCardItem.vue'
import ContentModpackCard from '../../layouts/shared/content-tab/components/ContentModpackCard.vue'
import type {
ContentModpackCardCategory,
ContentModpackCardProject,
ContentModpackCardVersion,
ContentOwner,
} from '../../layouts/shared/content-tab/types'
// Real project data from Modrinth API
const fabulouslyOptimizedProject: ContentModpackCardProject = {
id: '1KVo5zza',
slug: 'fabulously-optimized',
title: 'Fabulously Optimized',
icon_url:
'https://cdn.modrinth.com/data/1KVo5zza/9f1ded4949c2a9db5ca382d3bcc912c7245486b4_96.webp',
description:
'Beautiful graphics, speedy performance and familiar features in a simple package. 1.21.11 beta!',
downloads: 8708191,
followers: 3762,
}
const cobblemonProject: ContentModpackCardProject = {
id: '5FFgwNNP',
slug: 'cobblemon-fabric',
title: 'Cobblemon Official Modpack [Fabric]',
icon_url: 'https://cdn.modrinth.com/data/5FFgwNNP/e7f9ee2e9d361623847853fe2ddce42f519ee64f.png',
description: 'The official modpack of the Cobblemon mod, for Fabric!',
downloads: 4940845,
followers: 2051,
}
const simplyOptimizedProject: ContentModpackCardProject = {
id: 'BYfVnHa7',
slug: 'sop',
title: 'Simply Optimized',
icon_url: 'https://cdn.modrinth.com/data/BYfVnHa7/845e93223da7e8d1ed1a33364b5bdb4c316ac518.png',
description:
'The leading, well-researched optimization modpack with a focus on pure performance.',
downloads: 2903242,
followers: 1387,
}
// Version data from Modrinth API
const fabulouslyOptimizedVersion: ContentModpackCardVersion = {
id: 'YEEXo8mO',
version_number: '1.12.1',
date_published: '2022-02-10T06:53:28.379507Z',
}
const cobblemonVersion: ContentModpackCardVersion = {
id: 'bpaivauC',
version_number: '1.5.2',
date_published: '2024-05-27T07:12:36.043005Z',
}
// Owner data from Modrinth API
const userOwner: ContentOwner = {
id: '2avTeeAE',
name: 'robotkoer',
avatar_url: 'https://cdn.modrinth.com/user/2avTeeAE/icon.png',
type: 'user',
}
const cobblemonOwner: ContentOwner = {
id: 'AEFONbAM',
name: 'Reisen',
avatar_url:
'https://cdn.modrinth.com/user/AEFONbAM/9e97453507a8245981d5cd825280f23be44f15ac.jpeg',
type: 'user',
}
// Categories (using Labrinth.Tags.v2.Category structure with optional action)
const optimizationCategories: ContentModpackCardCategory[] = [
{ name: 'Fabric', icon: 'fabric', project_type: 'modpack', header: 'loaders' },
{ name: 'Lightweight', icon: 'lightweight', project_type: 'modpack', header: 'categories' },
{ name: 'Multiplayer', icon: 'multiplayer', project_type: 'modpack', header: 'categories' },
{ name: 'Optimization', icon: 'optimization', project_type: 'modpack', header: 'categories' },
]
const cobblemonCategories: ContentModpackCardCategory[] = [
{ name: 'Adventure', icon: 'adventure', project_type: 'modpack', header: 'categories' },
{ name: 'Fabric', icon: 'fabric', project_type: 'modpack', header: 'loaders' },
{ name: 'Lightweight', icon: 'lightweight', project_type: 'modpack', header: 'categories' },
{ name: 'Multiplayer', icon: 'multiplayer', project_type: 'modpack', header: 'categories' },
]
const meta = {
title: 'Instances/ContentModpackCard',
component: ContentModpackCard,
parameters: {
layout: 'padded',
},
argTypes: {
project: {
control: 'object',
description:
'Project information (id, slug, title, icon_url, description, downloads, followers)',
},
version: {
control: 'object',
description: 'Version information (id, version_number, date_published)',
},
owner: {
control: 'object',
description: 'Owner/author information (user or organization)',
},
categories: {
control: 'object',
description: 'Category tags with optional click actions',
},
disabled: {
control: 'boolean',
description: 'Grays out the card when true',
},
overflowOptions: {
control: 'object',
description: 'Options for the overflow menu',
},
},
} satisfies Meta<typeof ContentModpackCard>
export default meta
type Story = StoryObj<typeof meta>
// ============================================
// All Types Overview
// ============================================
export const AllTypes: Story = {
args: {
project: fabulouslyOptimizedProject,
},
render: () => ({
components: { ContentModpackCard },
setup() {
const cards = [
{
label: 'Full featured (all actions)',
project: fabulouslyOptimizedProject,
version: fabulouslyOptimizedVersion,
owner: userOwner,
categories: optimizationCategories,
hasUpdate: true,
hasContent: true,
hasUnlink: true,
},
{
label: 'With update available only',
project: cobblemonProject,
version: cobblemonVersion,
owner: cobblemonOwner,
categories: cobblemonCategories,
hasUpdate: true,
},
{
label: 'With content button only',
project: simplyOptimizedProject,
version: fabulouslyOptimizedVersion,
owner: userOwner,
hasContent: true,
},
{
label: 'Minimal (project only)',
project: fabulouslyOptimizedProject,
},
{
label: 'With version info only',
project: cobblemonProject,
version: cobblemonVersion,
},
{
label: 'With owner only',
project: simplyOptimizedProject,
owner: userOwner,
},
{
label: 'Disabled state',
project: fabulouslyOptimizedProject,
version: fabulouslyOptimizedVersion,
owner: userOwner,
categories: optimizationCategories,
disabled: true,
},
]
return { cards }
},
template: /*html*/ `
<div class="flex flex-col gap-6">
<template v-for="card in cards" :key="card.label">
<h3 class="text-sm font-medium text-secondary">{{ card.label }}</h3>
<ContentModpackCard
:project="card.project"
:version="card.version"
:owner="card.owner"
:categories="card.categories"
:disabled="card.disabled"
@update="card.hasUpdate ? () => {} : undefined"
@content="card.hasContent ? () => {} : undefined"
@unlink="card.hasUnlink ? () => {} : undefined"
/>
</template>
</div>
`,
}),
}
// ============================================
// Basic Stories
// ============================================
export const Default: Story = {
args: {
project: cobblemonProject,
version: cobblemonVersion,
owner: userOwner,
categories: optimizationCategories,
onUpdate: fn(),
onContent: fn(),
onUnlink: fn(),
},
}
export const MinimalProjectOnly: Story = {
args: {
project: cobblemonProject,
},
}
export const WithVersion: Story = {
args: {
project: simplyOptimizedProject,
version: fabulouslyOptimizedVersion,
},
}
export const WithUserOwner: Story = {
args: {
project: simplyOptimizedProject,
version: fabulouslyOptimizedVersion,
owner: userOwner,
categories: [
{ name: 'Adventure', icon: 'adventure', project_type: 'modpack', header: 'categories' },
],
},
}
export const WithOrganizationOwner: Story = {
args: {
project: cobblemonProject,
version: cobblemonVersion,
owner: userOwner,
categories: optimizationCategories,
},
}
// ============================================
// Action Button Stories
// ============================================
export const WithUpdateButton: Story = {
args: {
project: cobblemonProject,
version: cobblemonVersion,
owner: userOwner,
categories: optimizationCategories,
onUpdate: fn(),
},
}
export const WithContentButton: Story = {
args: {
project: cobblemonProject,
version: cobblemonVersion,
owner: userOwner,
categories: optimizationCategories,
onContent: fn(),
},
}
export const WithUnlinkButton: Story = {
args: {
project: cobblemonProject,
version: cobblemonVersion,
owner: userOwner,
onUnlink: fn(),
},
}
export const WithAllActions: Story = {
args: {
project: cobblemonProject,
version: cobblemonVersion,
owner: userOwner,
categories: optimizationCategories,
onUpdate: fn(),
onContent: fn(),
onUnlink: fn(),
overflowOptions: [
{ id: 'view', action: () => console.log('View') },
{ id: 'settings', action: () => console.log('Settings') },
{ divider: true },
{ id: 'remove', action: () => console.log('Remove'), color: 'red' },
],
},
}
// ============================================
// State Stories
// ============================================
export const Disabled: Story = {
args: {
project: cobblemonProject,
version: cobblemonVersion,
owner: userOwner,
categories: optimizationCategories,
disabled: true,
},
}
export const LongTitle: Story = {
args: {
project: {
...cobblemonProject,
title: 'Super Long Modpack Title That Should Display Properly On All Screen Sizes',
description:
'This is an extremely long description that should wrap properly and not break the layout. It contains lots of information about what this modpack includes and what makes it special compared to other modpacks available on the platform.',
},
version: cobblemonVersion,
owner: {
...userOwner,
name: 'Really Long Organization Name Studios',
},
categories: [
{ name: 'Adventure', icon: 'adventure', project_type: 'modpack', header: 'categories' },
{ name: 'Technology', icon: 'technology', project_type: 'modpack', header: 'categories' },
{ name: 'Magic', icon: 'magic', project_type: 'modpack', header: 'categories' },
{ name: 'Exploration', icon: 'exploration', project_type: 'modpack', header: 'categories' },
{ name: 'Multiplayer', icon: 'multiplayer', project_type: 'modpack', header: 'categories' },
],
onUpdate: fn(),
onContent: fn(),
},
}
export const NoDescription: Story = {
args: {
project: {
...cobblemonProject,
description: undefined,
},
version: cobblemonVersion,
owner: userOwner,
categories: optimizationCategories,
},
}
export const NoStats: Story = {
args: {
project: {
...cobblemonProject,
downloads: undefined,
followers: undefined,
},
version: cobblemonVersion,
owner: userOwner,
},
}
// ============================================
// Categories Stories
// ============================================
export const WithClickableCategories: Story = {
render: (args) => ({
components: { ContentModpackCard },
setup() {
const clickedCategory = ref<string | null>(null)
const categories: ContentModpackCardCategory[] = [
{
name: 'Adventure',
icon: 'adventure',
project_type: 'modpack',
header: 'categories',
action: () => (clickedCategory.value = 'Adventure'),
},
{
name: 'Lightweight',
icon: 'lightweight',
project_type: 'modpack',
header: 'categories',
action: () => (clickedCategory.value = 'Lightweight'),
},
{
name: 'Multiplayer',
icon: 'multiplayer',
project_type: 'modpack',
header: 'categories',
action: () => (clickedCategory.value = 'Multiplayer'),
},
]
return { args, categories, clickedCategory }
},
template: /*html*/ `
<div class="flex flex-col gap-4">
<ContentModpackCard
:project="args.project"
:version="args.version"
:owner="args.owner"
:categories="categories"
/>
<div class="text-sm text-secondary">
Clicked category: <strong>{{ clickedCategory || 'None' }}</strong>
</div>
</div>
`,
}),
args: {
project: cobblemonProject,
version: cobblemonVersion,
owner: userOwner,
},
}
// ============================================
// Overflow Menu Stories
// ============================================
export const WithOverflowMenu: Story = {
render: (args) => ({
components: { ContentModpackCard },
setup() {
return { args }
},
template: /*html*/ `
<ContentModpackCard v-bind="args">
<template #view>View on Modrinth</template>
<template #settings>Settings</template>
<template #remove>Remove modpack</template>
</ContentModpackCard>
`,
}),
args: {
project: cobblemonProject,
version: cobblemonVersion,
owner: userOwner,
categories: optimizationCategories,
overflowOptions: [
{ id: 'view', action: () => console.log('View') },
{ id: 'settings', action: () => console.log('Settings') },
{ divider: true },
{ id: 'remove', action: () => console.log('Remove'), color: 'red' },
],
},
}
// ============================================
// Interactive Stories
// ============================================
export const WithContentModal: Story = {
args: {
project: cobblemonProject,
},
render: () => ({
components: { ContentModpackCard, NewModal, ContentCardItem },
setup() {
const modalRef = ref<InstanceType<typeof NewModal> | null>(null)
const modpackContent = [
{
project: {
id: '1',
slug: 'sodium',
title: 'Sodium',
icon_url:
'https://cdn.modrinth.com/data/AANobbMI/295862f4724dc3f78df3447ad6072b2dcd3ef0c9_96.webp',
},
version: { id: 'v1', version_number: '0.8.2', file_name: 'sodium-fabric-0.8.2.jar' },
},
{
project: {
id: '2',
slug: 'modmenu',
title: 'Mod Menu',
icon_url:
'https://cdn.modrinth.com/data/mOgUt4GM/5a20ed1450a0e1e79a1fe04e61bb4e5878bf1d20.png',
},
version: { id: 'v2', version_number: '16.0.0', file_name: 'modmenu-16.0.0.jar' },
},
{
project: {
id: '3',
slug: 'fabric-api',
title: 'Fabric API',
icon_url: 'https://cdn.modrinth.com/data/P7dR8mSH/icon.png',
},
version: { id: 'v3', version_number: '0.141.3', file_name: 'fabric-api-0.141.3.jar' },
},
]
return {
cobblemonProject,
cobblemonVersion,
userOwner,
optimizationCategories,
modalRef,
modpackContent,
}
},
template: /*html*/ `
<div>
<ContentModpackCard
:project="cobblemonProject"
:version="cobblemonVersion"
:owner="userOwner"
:categories="optimizationCategories"
@content="modalRef?.show()"
@update="() => alert('Update clicked')"
/>
<NewModal ref="modalRef" header="Modpack Content">
<div class="flex flex-col gap-4">
<ContentCardItem
v-for="item in modpackContent"
:key="item.project.id"
:project="item.project"
:version="item.version"
/>
</div>
</NewModal>
</div>
`,
}),
}
// ============================================
// Responsive Stories
// ============================================
export const ResponsiveView: Story = {
args: {
project: cobblemonProject,
},
render: () => ({
components: { ContentModpackCard },
setup() {
return {
cobblemonProject,
cobblemonVersion,
userOwner,
optimizationCategories,
}
},
template: /*html*/ `
<div class="flex flex-col gap-8">
<div>
<h3 class="text-sm font-medium text-secondary mb-2">Desktop (full width)</h3>
<div class="w-full">
<ContentModpackCard
:project="cobblemonProject"
:version="cobblemonVersion"
:owner="userOwner"
:categories="optimizationCategories"
@update="() => {}"
@content="() => {}"
@unlink="() => {}"
/>
</div>
</div>
<div>
<h3 class="text-sm font-medium text-secondary mb-2">Mobile (&lt;640px)</h3>
<div class="w-[360px]">
<ContentModpackCard
:project="cobblemonProject"
:version="cobblemonVersion"
:owner="userOwner"
:categories="optimizationCategories"
@update="() => {}"
@content="() => {}"
/>
</div>
</div>
</div>
`,
}),
}
// ============================================
// Edge Cases
// ============================================
export const NoIcon: Story = {
args: {
project: {
...cobblemonProject,
icon_url: undefined,
},
version: cobblemonVersion,
owner: userOwner,
categories: optimizationCategories,
},
}
export const NoOwnerAvatar: Story = {
args: {
project: cobblemonProject,
version: cobblemonVersion,
owner: {
...userOwner,
avatar_url: undefined,
},
categories: optimizationCategories,
},
}
export const HighDownloadCounts: Story = {
args: {
project: {
...cobblemonProject,
downloads: 1234567890,
followers: 9876543,
},
version: cobblemonVersion,
owner: userOwner,
categories: optimizationCategories,
},
}
@@ -0,0 +1,204 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { fn } from 'storybook/test'
import ManagedContentCard from '../../layouts/shared/content-tab/components/managed-content-card/index.vue'
import type { ManagedContentCardData } from '../../layouts/shared/content-tab/types'
const cobblemonIcon =
'https://cdn.modrinth.com/data/5FFgwNNP/e7f9ee2e9d361623847853fe2ddce42f519ee64f.png'
const serverIcon =
'https://cdn.modrinth.com/data/PEMTFQFO/f4140c3a9042e4a825d47dbb64889dad9fdc066c.png'
const userAvatar = 'https://cdn.modrinth.com/user/LnK8MbX7/icon.png'
const figmaSummary = [
{ type: 'mod', count: 52 },
{ type: 'resourcepack', count: 2 },
{ type: 'shader', count: 1 },
] satisfies ManagedContentCardData['summary']
const twoDaysAgo = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString()
const fiveMinutesAgo = Date.now() - 5 * 60 * 1000
const modpackCard = {
kind: 'modpack',
manager: {
name: 'Cobblemon Modpack',
iconUrl: cobblemonIcon,
link: '/modpack/cobblemon-fabric',
},
summary: figmaSummary,
versionNumber: 'v1.6.1',
versionLink: '/modpack/cobblemon-fabric/version/v1.6.1',
updatedAt: twoDaysAgo,
} satisfies ManagedContentCardData
const serverCard = {
kind: 'server',
manager: {
name: 'Modrinth SMP',
iconUrl: serverIcon,
link: '/server/modrinth-smp',
},
summary: figmaSummary,
syncedAt: fiveMinutesAgo,
updateAvailable: true,
} satisfies ManagedContentCardData
const sharedInstanceCard = {
kind: 'shared-instance',
manager: {
name: 'Fetch',
iconUrl: userAvatar,
link: '/user/fetch',
},
summary: figmaSummary,
syncedAt: fiveMinutesAgo,
updateAvailable: true,
} satisfies ManagedContentCardData
const meta = {
title: 'Instances/ManagedContentCard',
component: ManagedContentCard,
parameters: {
layout: 'padded',
},
decorators: [
(story) => ({
components: { story },
template: '<div class="w-[1020px] max-w-full"><story /></div>',
}),
],
args: {
showViewContent: true,
showSettings: true,
showPrimaryAction: true,
onViewContent: fn(),
onSettings: fn(),
onPrimaryAction: fn(),
},
} satisfies Meta<typeof ManagedContentCard>
export default meta
type Story = StoryObj<typeof meta>
export const Modpack: Story = {
args: {
data: modpackCard,
},
}
export const Server: Story = {
args: {
data: serverCard,
},
}
export const SharedInstance: Story = {
args: {
data: sharedInstanceCard,
},
}
export const NarrowWidth: Story = {
args: {
data: serverCard,
},
render: (args) => ({
components: { ManagedContentCard },
setup: () => ({ args }),
template: /*html*/ `
<div class="w-[620px] max-w-full">
<ManagedContentCard v-bind="args" />
</div>
`,
}),
}
export const LongManagerName: Story = {
args: {
data: {
...sharedInstanceCard,
manager: {
...sharedInstanceCard.manager,
name: 'A shared-instance manager with a very long display name',
},
},
},
}
export const MissingManagerIcon: Story = {
args: {
data: {
...serverCard,
manager: {
name: 'Modrinth SMP',
},
},
},
}
export const ImportedModpack: Story = {
args: {
data: {
kind: 'modpack',
manager: {
name: 'Imported from Prism Launcher',
},
summary: figmaSummary,
},
showPrimaryAction: false,
},
}
export const LoadingCounts: Story = {
args: {
data: {
...modpackCard,
summary: undefined,
},
},
}
export const ZeroContent: Story = {
args: {
data: {
...sharedInstanceCard,
summary: [],
updateAvailable: false,
},
showPrimaryAction: false,
},
}
export const ApplyingUpdate: Story = {
args: {
data: serverCard,
disabled: true,
disabledText: 'Updating...',
},
}
export const AccountLocked: Story = {
args: {
data: {
...sharedInstanceCard,
updateAvailable: false,
},
showPrimaryAction: false,
},
}
export const EveryContentType: Story = {
args: {
data: {
...serverCard,
summary: [
{ type: 'mod', count: 52 },
{ type: 'plugin', count: 4 },
{ type: 'datapack', count: 3 },
{ type: 'resourcepack', count: 2 },
{ type: 'shader', count: 1 },
],
},
},
}
@@ -2,10 +2,9 @@ import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import { Button } from '../../components/base/buttons'
import ModpackContentModal from '../../layouts/shared/content-tab/components/modals/ModpackContentModal.vue'
import ManagedContentModal from '../../layouts/shared/content-tab/components/managed-content-modal/index.vue'
import type { ContentItem } from '../../layouts/shared/content-tab/types'
// Sample modpack content items (representing mods included in a modpack)
const sodiumItem: ContentItem = {
file_name: 'sodium-fabric-0.8.2+mc1.21.1.jar',
file_path: '',
@@ -201,7 +200,6 @@ const entityTextureFeaturesItem: ContentItem = {
update_version_id: null,
}
// Shader pack item
const complementaryShaderItem: ContentItem = {
file_name: 'ComplementaryReimagined_r5.3.zip',
file_path: '',
@@ -256,7 +254,6 @@ const bslShaderItem: ContentItem = {
update_version_id: null,
}
// Resource pack items
const faithfulItem: ContentItem = {
file_name: 'Faithful 32x - 1.21.zip',
file_path: '',
@@ -339,7 +336,6 @@ const stayTrueItem: ContentItem = {
update_version_id: null,
}
// Mixed content (mods + shaders + resource packs)
const mixedModpackContent: ContentItem[] = [
sodiumItem,
lithiumItem,
@@ -355,7 +351,6 @@ const mixedModpackContent: ContentItem[] = [
stayTrueItem,
]
// Mods only
const modsOnlyContent: ContentItem[] = [
sodiumItem,
lithiumItem,
@@ -366,7 +361,6 @@ const modsOnlyContent: ContentItem[] = [
entityTextureFeaturesItem,
]
// Large modpack content (40+ items for testing scrolling)
const largeModpackContent: ContentItem[] = [
...mixedModpackContent,
...Array.from({ length: 35 }, (_, i) => ({
@@ -393,35 +387,31 @@ const largeModpackContent: ContentItem[] = [
]
const meta = {
title: 'Instances/ModpackContentModal',
component: ModpackContentModal,
title: 'Instances/ManagedContentModal',
component: ManagedContentModal,
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof ModpackContentModal>
} satisfies Meta<typeof ManagedContentModal>
export default meta
type Story = StoryObj<typeof meta>
// ============================================
// Basic Examples
// ============================================
export const Default: Story = {
render: () => ({
components: { ModpackContentModal, Button },
components: { ManagedContentModal, Button },
setup() {
const modalRef = ref<InstanceType<typeof ModpackContentModal> | null>(null)
const modalRef = ref<InstanceType<typeof ManagedContentModal> | null>(null)
const openModal = () => modalRef.value?.show(mixedModpackContent)
return { modalRef, openModal }
},
template: /*html*/ `
<div>
<Button type="colored" color="brand" @click="openModal">View Modpack Content (Mixed)</Button>
<ModpackContentModal
<ManagedContentModal
ref="modalRef"
modpack-name="Cobblemon Official Modpack"
modpack-icon-url="https://cdn.modrinth.com/data/5FFgwNNP/icon.png"
source-name="Cobblemon Official Modpack"
source-icon-url="https://cdn.modrinth.com/data/5FFgwNNP/icon.png"
/>
</div>
`,
@@ -430,33 +420,28 @@ export const Default: Story = {
export const ModsOnly: Story = {
render: () => ({
components: { ModpackContentModal, Button },
components: { ManagedContentModal, Button },
setup() {
const modalRef = ref<InstanceType<typeof ModpackContentModal> | null>(null)
const modalRef = ref<InstanceType<typeof ManagedContentModal> | null>(null)
const openModal = () => modalRef.value?.show(modsOnlyContent)
return { modalRef, openModal }
},
template: /*html*/ `
<div>
<Button type="colored" color="brand" @click="openModal">View Modpack Content (Mods Only)</Button>
<ModpackContentModal ref="modalRef" />
<ManagedContentModal ref="modalRef" />
</div>
`,
}),
}
// ============================================
// Loading State
// ============================================
export const LoadingState: Story = {
render: () => ({
components: { ModpackContentModal, Button },
components: { ManagedContentModal, Button },
setup() {
const modalRef = ref<InstanceType<typeof ModpackContentModal> | null>(null)
const modalRef = ref<InstanceType<typeof ManagedContentModal> | null>(null)
const openModal = () => {
modalRef.value?.showLoading()
// Simulate loading delay
setTimeout(() => {
modalRef.value?.show(mixedModpackContent)
}, 2000)
@@ -466,71 +451,59 @@ export const LoadingState: Story = {
template: /*html*/ `
<div>
<Button type="colored" color="brand" @click="openModal">View Content (With Loading)</Button>
<ModpackContentModal
<ManagedContentModal
ref="modalRef"
modpack-name="Fabulously Optimized"
modpack-icon-url="https://cdn.modrinth.com/data/1KVo5zza/icon.png"
source-name="Fabulously Optimized"
source-icon-url="https://cdn.modrinth.com/data/1KVo5zza/icon.png"
/>
</div>
`,
}),
}
// ============================================
// Empty State
// ============================================
export const EmptyContent: Story = {
render: () => ({
components: { ModpackContentModal, Button },
components: { ManagedContentModal, Button },
setup() {
const modalRef = ref<InstanceType<typeof ModpackContentModal> | null>(null)
const modalRef = ref<InstanceType<typeof ManagedContentModal> | null>(null)
const openModal = () => modalRef.value?.show([])
return { modalRef, openModal }
},
template: /*html*/ `
<div>
<Button type="colored" color="brand" @click="openModal">View Empty Modpack</Button>
<ModpackContentModal ref="modalRef" />
<ManagedContentModal ref="modalRef" />
</div>
`,
}),
}
// ============================================
// Large Content List
// ============================================
export const LargeModpack: Story = {
render: () => ({
components: { ModpackContentModal, Button },
components: { ManagedContentModal, Button },
setup() {
const modalRef = ref<InstanceType<typeof ModpackContentModal> | null>(null)
const modalRef = ref<InstanceType<typeof ManagedContentModal> | null>(null)
const openModal = () => modalRef.value?.show(largeModpackContent)
return { modalRef, openModal }
},
template: /*html*/ `
<div>
<Button type="colored" color="brand" @click="openModal">View Large Modpack (47 items)</Button>
<ModpackContentModal
<ManagedContentModal
ref="modalRef"
modpack-name="All the Mods 10"
modpack-icon-url="https://cdn.modrinth.com/data/1KVo5zza/icon.png"
source-name="All the Mods 10"
source-icon-url="https://cdn.modrinth.com/data/1KVo5zza/icon.png"
/>
</div>
`,
}),
}
// ============================================
// Search Functionality
// ============================================
export const SearchDemo: Story = {
render: () => ({
components: { ModpackContentModal, Button },
components: { ManagedContentModal, Button },
setup() {
const modalRef = ref<InstanceType<typeof ModpackContentModal> | null>(null)
const modalRef = ref<InstanceType<typeof ManagedContentModal> | null>(null)
const openModal = () => modalRef.value?.show(mixedModpackContent)
return { modalRef, openModal }
},
@@ -540,21 +513,17 @@ export const SearchDemo: Story = {
Click the button and try searching for "sodium", "shader", or "faithful" to test the search functionality.
</p>
<Button type="colored" color="brand" @click="openModal">Test Search</Button>
<ModpackContentModal ref="modalRef" />
<ManagedContentModal ref="modalRef" />
</div>
`,
}),
}
// ============================================
// Filter Demo
// ============================================
export const FilterDemo: Story = {
render: () => ({
components: { ModpackContentModal, Button },
components: { ManagedContentModal, Button },
setup() {
const modalRef = ref<InstanceType<typeof ModpackContentModal> | null>(null)
const modalRef = ref<InstanceType<typeof ManagedContentModal> | null>(null)
const openModal = () => modalRef.value?.show(mixedModpackContent)
return { modalRef, openModal }
},
@@ -564,22 +533,17 @@ export const FilterDemo: Story = {
Click the button and try the filter chips (Mods, Shaders, Resource Packs) to filter content by type.
</p>
<Button type="colored" color="brand" @click="openModal">Test Filters</Button>
<ModpackContentModal ref="modalRef" />
<ManagedContentModal ref="modalRef" />
</div>
`,
}),
}
// ============================================
// Mixed Owner Types
// ============================================
export const MixedOwnerTypes: Story = {
render: () => ({
components: { ModpackContentModal, Button },
components: { ManagedContentModal, Button },
setup() {
const modalRef = ref<InstanceType<typeof ModpackContentModal> | null>(null)
// Mix of user and organization owners
const modalRef = ref<InstanceType<typeof ManagedContentModal> | null>(null)
const mixedContent = [
sodiumItem, // User owner
fabricApiItem, // Organization owner
@@ -595,7 +559,7 @@ export const MixedOwnerTypes: Story = {
Shows content with different owner types: users (circular avatar) and organizations (rounded + icon).
</p>
<Button type="colored" color="brand" @click="openModal">View Mixed Owners</Button>
<ModpackContentModal ref="modalRef" />
<ManagedContentModal ref="modalRef" />
</div>
`,
}),