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:
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<span class="text-lg font-extrabold text-contrast">{{
|
||||
header ??
|
||||
formatMessage(
|
||||
isModpack.value
|
||||
isModpack
|
||||
? messages.switchModpackVersionHeader
|
||||
: switchMode
|
||||
? messages.switchVersionHeader
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user