refactor: align files tab with content tab design (#5621)

* fix: files.vue bugs before styling changes

* feat: move files tab to shared layout structure

* fix: qa

* fix: qa

* fix: bugs

* fix: lint

* fix: admonition cleanup with progress + actions

* fix: cleanup

* fix: modals

* fix: admon title

* fix: i18n standard

* fix: lint + i18n pass

* fix: remove transition

* fix: type errors

* feat: files tab in app

* fix: qa

* fix: backup item minmax

* fix: use ContentPageHeader for server panel

* fix: lint

* fix: lint

* fix: lint

* feat: page leave safety

* fix: lint

* fix: cargo fmt fix

* fix: blank in prod

* fix: content card table stuff

* Revert "fix: blank in prod"

This reverts commit 74758fe185cf85a4a20355857f889cb091b97ace.

* fix: import

* feat: browse worlds/servers flow

* fix: worlds tab parity with content tab

* fix: perf bug + shader filter pill copy

* feat: singleplayer filter

* fix: ordering

* fix: breadcrumbs

* fix: lint

* fix: qa

* feat: store server proj id when adding to a non-linked instance

* fix: lint

* fix: i18n + qa

* fix: conflict

* qa: already installed modal + placeholders not server-specific

* fix: qa

* fix: add + edit server modals

* fix: qa

* fix: security

* fix: devin flags

* fix: lint

* chore: change file to break build cache

* fix: admon

* fix: import path stuff

* feat: qa

* fix: fmt fmt idiot

---------

Signed-off-by: Calum H. <calum@modrinth.com>
This commit is contained in:
Calum H.
2026-03-26 18:55:15 +00:00
committed by GitHub
parent 706eb800cb
commit 381ea51cce
170 changed files with 8052 additions and 4571 deletions

View File

@@ -19,8 +19,8 @@ import BulletDivider from '#ui/components/base/BulletDivider.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import Checkbox from '#ui/components/base/Checkbox.vue'
import type { Option as OverflowMenuOption } from '#ui/components/base/OverflowMenu.vue'
import TeleportOverflowMenu from '#ui/components/base/TeleportOverflowMenu.vue'
import Toggle from '#ui/components/base/Toggle.vue'
import TeleportOverflowMenu from '#ui/components/servers/files/explorer/TeleportOverflowMenu.vue'
import { useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
import { truncatedTooltip } from '#ui/utils/truncate'
@@ -81,6 +81,8 @@ const hasSwitchVersionListener = computed(
const versionNumberRef = ref<HTMLElement | null>(null)
const fileNameRef = ref<HTMLElement | null>(null)
const isDisabled = computed(() => props.disabled || props.installing)
const { shift: shiftHeld } = useMagicKeys()
const deleteHovered = ref(false)
</script>
@@ -94,7 +96,7 @@ const deleteHovered = ref(false)
<div
class="flex min-w-0 items-center gap-4"
:class="
hideActions ? 'flex-1' : 'flex-1 @[800px]:w-[350px] @[800px]:shrink-0 @[800px]:flex-none'
hideActions ? 'flex-1' : 'flex-1 @[800px]:w-[45%] @[800px]:shrink-0 @[800px]:flex-none'
"
>
<Checkbox
@@ -252,7 +254,7 @@ const deleteHovered = ref(false)
>
<button
v-tooltip="formatMessage(commonMessages.updateAvailableLabel)"
:disabled="disabled"
:disabled="isDisabled"
@click="emit('update')"
>
<DownloadIcon class="size-5" />
@@ -261,7 +263,7 @@ const deleteHovered = ref(false)
<ButtonStyled v-else-if="hasSwitchVersionListener && version" circular type="transparent">
<button
v-tooltip="formatMessage(commonMessages.switchVersionButton)"
:disabled="disabled"
:disabled="isDisabled"
@click="emit('switchVersion')"
>
<ArrowLeftRightIcon class="size-5" />
@@ -272,7 +274,7 @@ const deleteHovered = ref(false)
<Toggle
v-if="enabled !== undefined"
:model-value="enabled"
:disabled="disabled"
:disabled="isDisabled"
:aria-label="project.title"
class="my-auto"
@update:model-value="(val) => emit('update:enabled', val as boolean)"
@@ -287,7 +289,7 @@ const deleteHovered = ref(false)
: commonMessages.deleteLabel,
)
"
:disabled="disabled"
:disabled="isDisabled"
@click="emit('delete', $event)"
@mouseenter="deleteHovered = true"
@mouseleave="deleteHovered = false"
@@ -311,7 +313,7 @@ const deleteHovered = ref(false)
<TeleportOverflowMenu
v-if="overflowOptions?.length"
:options="overflowOptions"
:disabled="disabled"
:disabled="isDisabled"
>
<MoreVerticalIcon class="size-5" />
</TeleportOverflowMenu>

View File

@@ -192,9 +192,7 @@ function handleSort(column: ContentCardTableSortColumn) {
role="row"
class="flex min-w-0 items-center gap-4"
:class="
hasAnyActions
? 'flex-1 @[800px]:w-[350px] @[800px]:shrink-0 @[800px]:flex-none'
: 'flex-1'
hasAnyActions ? 'flex-1 @[800px]:w-[45%] @[800px]:shrink-0 @[800px]:flex-none' : 'flex-1'
"
>
<Checkbox
@@ -299,7 +297,9 @@ function handleSort(column: ContentCardTableSortColumn) {
@update:enabled="(val) => emit('update:enabled', item.id, val)"
@delete="(e: MouseEvent) => emit('delete', item.id, e)"
@update="emit('update', item.id)"
@switch-version="emit('switchVersion', item.id)"
v-on="
hasSwitchVersionListener ? { switchVersion: () => emit('switchVersion', item.id) } : {}
"
>
<template #additionalButtonsLeft>
<slot name="itemButtonsLeft" :item="item" :index="visibleRange.start + idx" />

View File

@@ -5,7 +5,7 @@ import {
DownloadIcon,
HeartIcon,
MoreVerticalIcon,
SettingsIcon,
Settings2Icon,
SpinnerIcon,
XIcon,
} from '@modrinth/assets'
@@ -20,8 +20,8 @@ import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import OverflowMenu, {
type Option as OverflowMenuOption,
} from '#ui/components/base/OverflowMenu.vue'
import TagItem from '#ui/components/base/TagItem.vue'
import TeleportOverflowMenu from '#ui/components/servers/files/explorer/TeleportOverflowMenu.vue'
import TagTagItem from '#ui/components/base/TagTagItem.vue'
import TeleportOverflowMenu from '#ui/components/base/TeleportOverflowMenu.vue'
import { useRelativeTime } from '#ui/composables/how-ago'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
@@ -36,10 +36,6 @@ import type {
const { formatMessage } = useVIntl()
const messages = defineMessages({
updating: {
id: 'content.modpack-card.updating',
defaultMessage: 'Updating...',
},
contentHintTitle: {
id: 'content.modpack-card.content-hint-title',
defaultMessage: 'Modpack content moved',
@@ -195,7 +191,7 @@ onUnmounted(() => {
<div class="flex items-center gap-2 text-secondary">
<SpinnerIcon class="animate-spin" />
<span class="font-semibold">{{
disabledText ?? formatMessage(messages.updating)
disabledText ?? formatMessage(commonMessages.updatingLabel)
}}</span>
</div>
</template>
@@ -268,7 +264,7 @@ onUnmounted(() => {
}
"
>
<SettingsIcon />
<Settings2Icon />
</button>
</ButtonStyled>
</div>
@@ -305,7 +301,7 @@ onUnmounted(() => {
{{ formatMessage(commonMessages.contentLabel) }}
</template>
<template #settings>
<SettingsIcon class="size-5" />
<Settings2Icon class="size-5" />
{{ formatMessage(commonMessages.settingsLabel) }}
</template>
</TeleportOverflowMenu></ButtonStyled
@@ -362,9 +358,13 @@ onUnmounted(() => {
</div>
<div v-if="categories?.length" class="flex flex-wrap items-center gap-1">
<TagItem v-for="cat in categories" :key="cat.name" :action="cat.action">
{{ cat.name }}
</TagItem>
<TagTagItem
v-for="cat in categories"
:key="cat.name"
:tag="cat.name"
:action="cat.action"
hide-non-loader-icon
/>
</div>
</div>
</div>

View File

@@ -21,14 +21,6 @@ const messages = defineMessages({
id: 'content.selection-bar.selected-count-simple',
defaultMessage: '{count, number} selected',
},
enable: {
id: 'content.selection-bar.enable',
defaultMessage: 'Enable',
},
disable: {
id: 'content.selection-bar.disable',
defaultMessage: 'Disable',
},
bulkEnabling: {
id: 'content.selection-bar.bulk.enabling',
defaultMessage: 'Enabling {progress}/{total} {contentType}...',
@@ -162,13 +154,15 @@ const bulkProgressMessage = computed(() => {
<ButtonStyled type="transparent">
<button
v-tooltip="
allEnabled ? formatMessage(messages.allAlreadyEnabled) : formatMessage(messages.enable)
allEnabled
? formatMessage(messages.allAlreadyEnabled)
: formatMessage(commonMessages.enableButton)
"
:disabled="isBusy || allEnabled"
@click="emit('enable')"
>
<PowerIcon />
<span class="bar-label">{{ formatMessage(messages.enable) }}</span>
<span class="bar-label">{{ formatMessage(commonMessages.enableButton) }}</span>
</button>
</ButtonStyled>
<ButtonStyled type="transparent">
@@ -176,13 +170,13 @@ const bulkProgressMessage = computed(() => {
v-tooltip="
allDisabled
? formatMessage(messages.allAlreadyDisabled)
: formatMessage(messages.disable)
: formatMessage(commonMessages.disableButton)
"
:disabled="isBusy || allDisabled"
@click="emit('disable')"
>
<PowerOffIcon />
<span class="bar-label">{{ formatMessage(messages.disable) }}</span>
<span class="bar-label">{{ formatMessage(commonMessages.disableButton) }}</span>
</button>
</ButtonStyled>

View File

@@ -1,91 +0,0 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.leavePageTitle)"
fade="warning"
max-width="500px"
>
<div class="flex flex-col gap-6">
<Admonition type="critical" :header="formatMessage(messages.uploadInProgress)">
{{ formatMessage(messages.leavePageBody) }}
</Admonition>
</div>
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled type="outlined">
<button class="!border !border-surface-4" @click="cancel">
<XIcon />
{{ formatMessage(messages.stayOnPageButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button @click="leave">
<RightArrowIcon />
{{ formatMessage(messages.leavePageButton) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { RightArrowIcon, XIcon } from '@modrinth/assets'
import { ref } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
import ButtonStyled from '#ui/components/base/ButtonStyled.vue'
import NewModal from '#ui/components/modal/NewModal.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
const { formatMessage } = useVIntl()
const messages = defineMessages({
leavePageTitle: {
id: 'instances.confirm-leave-modal.title',
defaultMessage: 'Leave page?',
},
uploadInProgress: {
id: 'instances.confirm-leave-modal.upload-in-progress',
defaultMessage: 'Upload in progress',
},
leavePageBody: {
id: 'instances.confirm-leave-modal.body',
defaultMessage:
'Files are still being uploaded. Leaving this page will cancel the upload and your changes may be lost.',
},
stayOnPageButton: {
id: 'instances.confirm-leave-modal.stay',
defaultMessage: 'Stay on page',
},
leavePageButton: {
id: 'instances.confirm-leave-modal.leave',
defaultMessage: 'Leave page',
},
})
const modal = ref<InstanceType<typeof NewModal>>()
let resolvePromise: ((value: boolean) => void) | null = null
function prompt(): Promise<boolean> {
return new Promise((resolve) => {
resolvePromise = resolve
modal.value?.show()
})
}
function leave() {
modal.value?.hide()
resolvePromise?.(true)
resolvePromise = null
}
function cancel() {
modal.value?.hide()
resolvePromise?.(false)
resolvePromise = null
}
defineExpose({ prompt })
</script>

View File

@@ -13,7 +13,7 @@
formatMessage(messages.admonitionHeader, { action: downgrade ? 'downgrade' : 'update' })
"
>
{{ formatMessage(messages.admonitionBody) }}
{{ formatMessage(server ? messages.admonitionBody : messages.admonitionBodyApp) }}
</Admonition>
<InlineBackupCreator
ref="backupCreator"
@@ -83,6 +83,10 @@ const messages = defineMessages({
id: 'content.confirm-modpack-update.admonition-body',
defaultMessage: 'Any mods or content you added on top of the modpack will be deleted.',
},
admonitionBodyApp: {
id: 'content.confirm-modpack-update.admonition-body-app',
defaultMessage: 'Any mods or content you added on top of the modpack will be preserved.',
},
confirmButton: {
id: 'content.confirm-modpack-update.confirm-button',
defaultMessage: '{action, select, downgrade {Downgrade} other {Update}} modpack',

View File

@@ -119,7 +119,7 @@
<button @click="emit('install', inst)">
{{
inst.installing
? formatMessage(messages.installingLabel)
? formatMessage(commonMessages.installingLabel)
: formatMessage(messages.installButton)
}}
</button>
@@ -176,7 +176,7 @@
<div class="flex flex-col gap-2.5">
<span class="font-semibold text-contrast">
{{ formatMessage(messages.gameVersionLabel) }}
{{ formatMessage(commonMessages.gameVersionLabel) }}
</span>
<Combobox
v-model="selectedGameVersion"
@@ -195,8 +195,8 @@
<EyeIcon v-else class="size-4" />
{{
showSnapshots
? formatMessage(messages.hideSnapshots)
: formatMessage(messages.showAllVersions)
? formatMessage(commonMessages.hideSnapshotsButton)
: formatMessage(commonMessages.showAllVersionsButton)
}}
</button>
</template>
@@ -291,10 +291,6 @@ const messages = defineMessages({
id: 'instances.content-install.installed-badge',
defaultMessage: 'Installed',
},
installingLabel: {
id: 'instances.content-install.installing-label',
defaultMessage: 'Installing...',
},
installButton: {
id: 'instances.content-install.install-button',
defaultMessage: 'Install',
@@ -319,10 +315,6 @@ const messages = defineMessages({
id: 'instances.content-install.loader-label',
defaultMessage: 'Loader',
},
gameVersionLabel: {
id: 'instances.content-install.game-version-label',
defaultMessage: 'Game version',
},
gameVersionPlaceholder: {
id: 'instances.content-install.game-version-placeholder',
defaultMessage: 'Select game version',
@@ -335,14 +327,6 @@ const messages = defineMessages({
id: 'instances.content-install.no-instances',
defaultMessage: 'No compatible instances found',
},
showAllVersions: {
id: 'instances.content-install.show-all-versions',
defaultMessage: 'Show all versions',
},
hideSnapshots: {
id: 'instances.content-install.hide-snapshots',
defaultMessage: 'Hide snapshots',
},
})
export interface ContentInstallInstance {

View File

@@ -10,7 +10,7 @@
<span class="text-lg font-extrabold text-contrast">{{
header ??
formatMessage(
isModpack.value
isModpack
? messages.switchModpackVersionHeader
: switchMode
? messages.switchVersionHeader

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import {
ArrowLeftRightIcon,
BoxIcon,
FilterIcon,
GlassesIcon,
@@ -7,7 +8,6 @@ import {
SearchIcon,
SpinnerIcon,
} from '@modrinth/assets'
import { formatProjectType } from '@modrinth/utils'
import Fuse from 'fuse.js'
import { computed, nextTick, ref, watchSyncEffect } from 'vue'
@@ -18,7 +18,12 @@ import type { Option as OverflowMenuOption } from '#ui/components/base/OverflowM
import StyledInput from '#ui/components/base/StyledInput.vue'
import NewModal from '#ui/components/modal/NewModal.vue'
import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonMessages } from '#ui/utils/common-messages'
import {
commonMessages,
commonProjectTypeCategoryMessages,
commonProjectTypeTitleMessages,
normalizeProjectType,
} from '#ui/utils/common-messages'
import { isClientOnlyEnvironment } from '../../composables/content-filtering'
import type { ContentCardTableItem, ContentItem } from '../../types'
@@ -32,6 +37,7 @@ interface Props {
modpackIconUrl?: string
enableToggle?: boolean
getOverflowOptions?: (item: ContentItem) => OverflowMenuOption[]
switchVersion?: (item: ContentItem) => void
}
const props = withDefaults(defineProps<Props>(), {
@@ -39,6 +45,7 @@ const props = withDefaults(defineProps<Props>(), {
modpackIconUrl: undefined,
enableToggle: false,
getOverflowOptions: undefined,
switchVersion: undefined,
})
const emit = defineEmits<{
@@ -72,14 +79,6 @@ const messages = defineMessages({
id: 'instances.modpack-content-modal.no-results',
defaultMessage: 'No projects match your search.',
},
allFilter: {
id: 'instances.modpack-content-modal.filter-all',
defaultMessage: 'All',
},
copyLink: {
id: 'instances.modpack-content-modal.copy-link',
defaultMessage: 'Copy link',
},
})
export interface ModpackContentModalState {
@@ -133,25 +132,36 @@ watchSyncEffect(() => fuse.setCollection(items.value))
const filterOptions = computed(() => {
const frequency = items.value.reduce(
(map, item) => {
map[item.project_type] = (map[item.project_type] || 0) + 1
const normalized = normalizeProjectType(item.project_type)
map[normalized] = (map[normalized] || 0) + 1
return map
},
{} as Record<string, number>,
)
// Sort by frequency (most common first)
return Object.entries(frequency)
const options = Object.entries(frequency)
.sort(([, a], [, b]) => b - a)
.map(([type]) => ({
id: type,
label: formatProjectType(type) + 's',
}))
.map(([type]) => {
const msg =
commonProjectTypeCategoryMessages[type as keyof typeof commonProjectTypeCategoryMessages]
return {
id: type,
label: msg ? formatMessage(msg) : type.charAt(0).toUpperCase() + type.slice(1) + 's',
}
})
if (items.value.some((item) => !item.enabled)) {
options.push({ id: 'disabled', label: 'Disabled' })
}
return options
})
const stats = computed(() => {
const counts: Record<string, number> = {}
for (const item of items.value) {
counts[item.project_type] = (counts[item.project_type] || 0) + 1
const normalized = normalizeProjectType(item.project_type)
counts[normalized] = (counts[normalized] || 0) + 1
}
return counts
})
@@ -165,9 +175,18 @@ function toggleFilter(filterId: string) {
}
}
const attributeFilterIds = new Set(['disabled'])
const typeFilteredCount = computed(() => {
if (selectedFilters.value.length === 0) return items.value.length
return items.value.filter((item) => selectedFilters.value.includes(item.project_type)).length
const typeFilters = selectedFilters.value.filter((f) => !attributeFilterIds.has(f))
const hasDisabledFilter = selectedFilters.value.includes('disabled')
return items.value.filter((item) => {
if (typeFilters.length > 0 && !typeFilters.includes(normalizeProjectType(item.project_type)))
return false
if (hasDisabledFilter && item.enabled) return false
return true
}).length
})
const filteredItems = computed(() => {
@@ -184,9 +203,15 @@ const filteredItems = computed(() => {
})
}
// Apply type filters
if (selectedFilters.value.length > 0) {
result = result.filter((item) => selectedFilters.value.includes(item.project_type))
const typeFilters = selectedFilters.value.filter((f) => !attributeFilterIds.has(f))
const hasDisabledFilter = selectedFilters.value.includes('disabled')
result = result.filter((item) => {
if (typeFilters.length > 0 && !typeFilters.includes(normalizeProjectType(item.project_type)))
return false
if (hasDisabledFilter && item.enabled) return false
return true
})
}
return result
@@ -216,7 +241,18 @@ const tableItems = computed<ContentCardTableItem[]>(() =>
...(props.enableToggle ? { enabled: item.enabled } : {}),
isClientOnly: isClientOnlyEnvironment(item.environment),
disabled: disabledIds.value.has(item.file_name),
overflowOptions: props.getOverflowOptions?.(item),
overflowOptions: [
...(props.switchVersion
? [
{
id: formatMessage(commonMessages.switchVersionButton),
icon: ArrowLeftRightIcon,
action: () => props.switchVersion!(item),
},
]
: []),
...(props.getOverflowOptions?.(item) ?? []),
],
})),
)
@@ -344,7 +380,7 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem })
/>
<!-- Filters -->
<div v-if="filterOptions.length > 1" class="flex items-center gap-2">
<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
@@ -357,7 +393,7 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem })
"
@click="selectedFilters = []"
>
{{ formatMessage(messages.allFilter) }}
{{ formatMessage(commonMessages.allProjectType) }}
</button>
<button
v-for="option in filterOptions"
@@ -416,7 +452,7 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem })
class="flex min-w-0 items-center gap-4"
:class="
props.enableToggle
? 'flex-1 @[800px]:w-[350px] @[800px]:shrink-0 @[800px]:flex-none'
? 'flex-1 @[800px]:w-[45%] @[800px]:shrink-0 @[800px]:flex-none'
: 'flex-1'
"
>
@@ -434,7 +470,7 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem })
</div>
<div
class="hidden @[800px]:flex"
:class="props.enableToggle ? 'w-[335px] min-w-0' : 'flex-1'"
:class="props.enableToggle ? 'flex-1 min-w-0' : 'flex-1'"
>
<span class="font-semibold text-secondary">{{
formatMessage(commonMessages.versionLabel)
@@ -475,7 +511,17 @@ defineExpose({ show, showLoading, hide, getState, restore, updateItem })
<div class="flex items-center gap-1.5">
<component :is="getTypeIcon(type as string)" class="size-5 text-secondary" />
<span class="font-medium text-primary">
{{ count }} {{ formatProjectType(type as string) }}{{ count !== 1 ? 's' : '' }}
{{ count }}
{{
formatMessage(
commonProjectTypeTitleMessages[
normalizeProjectType(
type as string,
) as keyof typeof commonProjectTypeTitleMessages
] ?? commonProjectTypeTitleMessages.project,
{ count },
)
}}
</span>
</div>
</template>

View File

@@ -2,6 +2,9 @@ import { useSessionStorage } from '@vueuse/core'
import type { Ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { useVIntl } from '#ui/composables/i18n'
import { commonProjectTypeCategoryMessages, normalizeProjectType } from '#ui/utils/common-messages'
import type { ContentItem } from '../types'
const CLIENT_ONLY_ENVIRONMENTS = new Set(['client_only', 'singleplayer_only'])
@@ -20,11 +23,12 @@ export interface ContentFilterConfig {
showUpdateFilter?: boolean
showClientOnlyFilter?: boolean
isPackLocked?: Ref<boolean>
formatProjectType?: (type: string) => string
persistKey?: string
}
export function useContentFilters(items: Ref<ContentItem[]>, config?: ContentFilterConfig) {
const { formatMessage } = useVIntl()
const selectedFilters = config?.persistKey
? useSessionStorage<string[]>(`content-filters:${config.persistKey}`, [])
: ref<string[]>([])
@@ -34,12 +38,15 @@ export function useContentFilters(items: Ref<ContentItem[]>, config?: ContentFil
if (config?.showTypeFilters) {
const frequency = items.value.reduce((map: Record<string, number>, item) => {
map[item.project_type] = (map[item.project_type] || 0) + 1
const normalized = normalizeProjectType(item.project_type)
map[normalized] = (map[normalized] || 0) + 1
return map
}, {})
const types = Object.keys(frequency).sort((a, b) => frequency[b] - frequency[a])
for (const type of types) {
const label = config.formatProjectType ? config.formatProjectType(type) + 's' : type + 's'
const msg =
commonProjectTypeCategoryMessages[type as keyof typeof commonProjectTypeCategoryMessages]
const label = msg ? formatMessage(msg) : type.charAt(0).toUpperCase() + type.slice(1) + 's'
options.push({ id: type, label })
}
}
@@ -89,7 +96,10 @@ export function useContentFilters(items: Ref<ContentItem[]>, config?: ContentFil
const activeAttributes = selectedFilters.value.filter((f) => attributeFilters.has(f))
return source.filter((item) => {
if (typeFilters.length > 0 && !typeFilters.includes(item.project_type)) {
if (
typeFilters.length > 0 &&
!typeFilters.includes(normalizeProjectType(item.project_type))
) {
return false
}

View File

@@ -4,7 +4,6 @@ export { default as ContentCardTable } from './components/ContentCardTable.vue'
export { default as ContentModpackCard } from './components/ContentModpackCard.vue'
export { default as ConfirmBulkUpdateModal } from './components/modals/ConfirmBulkUpdateModal.vue'
export { default as ConfirmDeletionModal } from './components/modals/ConfirmDeletionModal.vue'
export { default as ConfirmLeaveModal } from './components/modals/ConfirmLeaveModal.vue'
export { default as ConfirmModpackUpdateModal } from './components/modals/ConfirmModpackUpdateModal.vue'
export { default as ConfirmReinstallModal } from './components/modals/ConfirmReinstallModal.vue'
export { default as ConfirmRepairModal } from './components/modals/ConfirmRepairModal.vue'
@@ -22,3 +21,4 @@ export { default as ContentCardLayout } from './layout.vue'
export { default as ContentPageLayout } from './layout.vue'
export * from './providers'
export * from './types'
export { default as ConfirmLeaveModal } from '#ui/components/modal/ConfirmLeaveModal.vue'

View File

@@ -20,7 +20,7 @@ import {
TrashIcon,
UploadIcon,
} from '@modrinth/assets'
import { formatBytes, formatProjectType } from '@modrinth/utils'
import { formatBytes } from '@modrinth/utils'
import { computed, ref, watch } from 'vue'
import Admonition from '#ui/components/base/Admonition.vue'
@@ -29,6 +29,7 @@ import EmptyState from '#ui/components/base/EmptyState.vue'
import OverflowMenu from '#ui/components/base/OverflowMenu.vue'
import ProgressBar from '#ui/components/base/ProgressBar.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 } from '#ui/utils/common-messages'
@@ -50,6 +51,7 @@ import { injectContentManager } from './providers/content-manager'
import type { ContentCardTableItem, ContentItem } from './types'
const { formatMessage } = useVIntl()
const debug = useDebugLogger('ContentPageLayout')
const messages = defineMessages({
loadingContent: {
@@ -228,7 +230,6 @@ const { selectedFilters, filterOptions, toggleFilter, applyFilters } = useConten
showUpdateFilter: ctx.hasUpdateSupport,
showClientOnlyFilter: ctx.showClientOnlyFilter ?? false,
isPackLocked: ctx.isPackLocked,
formatProjectType,
persistKey: ctx.filterPersistKey,
},
)
@@ -267,7 +268,7 @@ const filteredItems = computed(() => {
return applyFilters(searched)
})
const tableItems = computed<ContentCardTableItem[]>(() => {
return filteredItems.value.map((item) => {
const items = filteredItems.value.map((item) => {
const base = ctx.mapToTableItem(item)
return {
...base,
@@ -282,9 +283,35 @@ const tableItems = computed<ContentCardTableItem[]>(() => {
overflowOptions: ctx.getOverflowOptions?.(item),
}
})
const updatable = items.filter((i) => i.hasUpdate)
if (updatable.length > 0) {
debug('tableItems: items with hasUpdate=true', {
count: updatable.length,
ids: updatable.map((i) => i.id),
isPackLocked: ctx.isPackLocked.value,
})
}
return items
})
const hasOutdatedProjects = computed(() => ctx.items.value.some((p) => p.has_update))
const hasOutdatedProjects = computed(() => {
const outdated = ctx.items.value.filter((p) => p.has_update)
if (outdated.length > 0) {
debug('hasOutdatedProjects: raw items with has_update=true', {
count: outdated.length,
items: outdated.map((p) => ({
id: p.id,
fileName: p.file_name,
title: p.project?.title,
has_update: p.has_update,
update_version_id: p.update_version_id,
})),
})
}
return outdated.length > 0
})
// Deletion
const pendingDeletionItems = ref<ContentItem[]>([])
@@ -877,7 +904,7 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
:count="pendingDeletionItems.length"
:item-type="ctx.contentTypeLabel.value"
:variant="ctx.deletionContext ?? 'instance'"
:backup-tip="pendingDeletionItems.map((i) => i.project.title).join(', ')"
:backup-tip="pendingDeletionItems.map((i) => i.project?.title ?? i.file_name).join(', ')"
@delete="confirmDelete"
/>
<ConfirmBulkUpdateModal

View File

@@ -25,15 +25,7 @@ export interface ContentModpackData {
disabledText?: string
}
export interface UploadState {
isUploading: boolean
currentFileName: string | null
currentFileProgress: number
uploadedBytes: number
totalBytes: number
completedFiles: number
totalFiles: number
}
export type { UploadState } from '@modrinth/api-client'
export interface ContentManagerContext {
// Data