diff --git a/apps/app-frontend/src/components/ui/settings/AppearanceSettings.vue b/apps/app-frontend/src/components/ui/settings/AppearanceSettings.vue
index 1b28db2867..253352ea88 100644
--- a/apps/app-frontend/src/components/ui/settings/AppearanceSettings.vue
+++ b/apps/app-frontend/src/components/ui/settings/AppearanceSettings.vue
@@ -12,6 +12,7 @@ const { formatMessage } = useVIntl()
const worldsInHomeFeatureFlag = 'worlds_in_home' as FeatureFlag
const skipUnknownPackWarningFeatureFlag = 'skip_unknown_pack_warning' as FeatureFlag
+const showBothContentStatusFiltersFeatureFlag = 'content_filter_show_both_statuses' as FeatureFlag
const messages = defineMessages({
colorThemeTitle: {
@@ -100,6 +101,22 @@ const messages = defineMessages({
defaultMessage:
"If you attempt to install a Modrinth Pack file (.mrpack) that isn't hosted on Modrinth, we'll make sure you understand the risks before installing it.",
},
+ contentStatusFilterModeTitle: {
+ id: 'app.appearance-settings.content-status-filter-mode.title',
+ defaultMessage: 'Content status filter buttons',
+ },
+ contentStatusFilterModeDescription: {
+ id: 'app.appearance-settings.content-status-filter-mode.description',
+ defaultMessage: 'Choose how the Enabled and Disabled filters appear on instance content pages.',
+ },
+ contentStatusFilterModeRightClick: {
+ id: 'app.appearance-settings.content-status-filter-mode.right-click',
+ defaultMessage: 'Right-click to switch between them',
+ },
+ contentStatusFilterModeBoth: {
+ id: 'app.appearance-settings.content-status-filter-mode.both',
+ defaultMessage: 'Always show both buttons',
+ },
})
const os = ref(await getOS())
@@ -248,6 +265,45 @@ watch(
/>
+
+
+
+ {{ formatMessage(messages.contentStatusFilterModeTitle) }}
+
+
{{ formatMessage(messages.contentStatusFilterModeDescription) }}
+
+
{
+ const showBoth = value === 'both'
+ themeStore.featureFlags[showBothContentStatusFiltersFeatureFlag] = showBoth
+ settings.feature_flags[showBothContentStatusFiltersFeatureFlag] = showBoth
+ }
+ "
+ />
+
+
diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json
index 98a1db79b7..b64e8bfb81 100644
--- a/apps/app-frontend/src/locales/en-US/index.json
+++ b/apps/app-frontend/src/locales/en-US/index.json
@@ -47,6 +47,18 @@
"app.appearance-settings.color-theme.title": {
"message": "Color theme"
},
+ "app.appearance-settings.content-status-filter-mode.both": {
+ "message": "Both buttons in UI always"
+ },
+ "app.appearance-settings.content-status-filter-mode.description": {
+ "message": "Choose how Enabled and Disabled filters appear on instance content pages."
+ },
+ "app.appearance-settings.content-status-filter-mode.right-click": {
+ "message": "Right click to switch button"
+ },
+ "app.appearance-settings.content-status-filter-mode.title": {
+ "message": "Content status filters"
+ },
"app.appearance-settings.default-landing-page.description": {
"message": "Change the page to which the launcher opens on."
},
diff --git a/apps/app-frontend/src/pages/instance/Mods.vue b/apps/app-frontend/src/pages/instance/Mods.vue
index 8a5a8ffe6f..5b445101d0 100644
--- a/apps/app-frontend/src/pages/instance/Mods.vue
+++ b/apps/app-frontend/src/pages/instance/Mods.vue
@@ -120,6 +120,8 @@ import type { CacheBehaviour, GameInstance } from '@/helpers/types'
import { highlightModInProfile } from '@/helpers/utils.js'
import { injectContentInstall } from '@/providers/content-install'
import { installVersionDependencies } from '@/store/install'
+import { useTheming } from '@/store/state'
+import type { FeatureFlag } from '@/store/theme.ts'
const messages = defineMessages({
shareTitle: {
@@ -155,6 +157,8 @@ const { handleError, addNotification } = injectNotificationManager()
const { installingItems } = injectContentInstall()
const router = useRouter()
const debug = useDebugLogger('Mods:ContentUpdate')
+const themeStore = useTheming()
+const showBothContentStatusFiltersFeatureFlag = 'content_filter_show_both_statuses' as FeatureFlag
const props = defineProps<{
instance: GameInstance
@@ -842,6 +846,9 @@ provideContentManager({
installing: item.installing,
}),
filterPersistKey: props.instance.path,
+ showBothContentStatusFilters: computed(() =>
+ themeStore.getFeatureFlag(showBothContentStatusFiltersFeatureFlag),
+ ),
})
await initProjects()
diff --git a/apps/app-frontend/src/store/theme.ts b/apps/app-frontend/src/store/theme.ts
index 0d5d0c9b3f..69a5c431f8 100644
--- a/apps/app-frontend/src/store/theme.ts
+++ b/apps/app-frontend/src/store/theme.ts
@@ -9,6 +9,7 @@ export const DEFAULT_FEATURE_FLAGS = {
server_ram_as_bytes_always_on: false,
always_show_app_controls: false,
skip_unknown_pack_warning: false,
+ content_filter_show_both_statuses: false,
i18n_debug: false,
}
diff --git a/packages/app-lib/src/state/settings.rs b/packages/app-lib/src/state/settings.rs
index 9ef4ccf68b..e69a47c833 100644
--- a/packages/app-lib/src/state/settings.rs
+++ b/packages/app-lib/src/state/settings.rs
@@ -61,6 +61,7 @@ pub enum FeatureFlag {
ServersInApp,
ServerProjectQa,
I18nDebug,
+ ContentFilterShowBothStatuses,
}
impl Settings {
diff --git a/packages/ui/src/layouts/shared/content-tab/composables/content-filtering.ts b/packages/ui/src/layouts/shared/content-tab/composables/content-filtering.ts
index 0e10446e54..1ee867df64 100644
--- a/packages/ui/src/layouts/shared/content-tab/composables/content-filtering.ts
+++ b/packages/ui/src/layouts/shared/content-tab/composables/content-filtering.ts
@@ -1,8 +1,8 @@
import { useSessionStorage } from '@vueuse/core'
-import type { Ref } from 'vue'
+import type { ComputedRef, Ref } from 'vue'
import { computed, ref, watch } from 'vue'
-import { useVIntl } from '#ui/composables/i18n'
+import { defineMessages, useVIntl } from '#ui/composables/i18n'
import { commonProjectTypeCategoryMessages, normalizeProjectType } from '#ui/utils/common-messages'
import type { ClientWarningType, ContentItem } from '../types'
@@ -31,14 +31,57 @@ export interface ContentFilterConfig {
showWarningsFilter?: boolean
isPackLocked?: Ref
persistKey?: string
+ showBothStatusFilters?: Ref | ComputedRef
}
+const messages = defineMessages({
+ updates: {
+ id: 'content.filter.updates',
+ defaultMessage: 'Updates',
+ },
+ warnings: {
+ id: 'content.filter.warnings',
+ defaultMessage: 'Warnings',
+ },
+ enabled: {
+ id: 'content.filter.enabled',
+ defaultMessage: 'Enabled',
+ },
+ disabled: {
+ id: 'content.filter.disabled',
+ defaultMessage: 'Disabled',
+ },
+})
+
export function useContentFilters(items: Ref, config?: ContentFilterConfig) {
const { formatMessage } = useVIntl()
const selectedFilters = config?.persistKey
? useSessionStorage(`content-filters:${config.persistKey}`, [])
: ref([])
+ const statusFilter = config?.persistKey
+ ? useSessionStorage<'enabled' | 'disabled'>(
+ `content-status-filter:${config.persistKey}`,
+ 'disabled',
+ )
+ : ref<'enabled' | 'disabled'>('disabled')
+
+ const availableStatusFilters = computed>(() => {
+ const filters: Array<'enabled' | 'disabled'> = []
+ if (items.value.some((m) => m.enabled)) {
+ filters.push('enabled')
+ }
+ if (items.value.some((m) => !m.enabled)) {
+ filters.push('disabled')
+ }
+ return filters
+ })
+
+ const visibleStatusFilters = computed>(() => {
+ if (config?.showBothStatusFilters?.value) return availableStatusFilters.value
+ if (availableStatusFilters.value.includes(statusFilter.value)) return [statusFilter.value]
+ return availableStatusFilters.value.slice(0, 1)
+ })
const filterOptions = computed(() => {
const options: ContentFilterOption[] = []
@@ -59,27 +102,49 @@ export function useContentFilters(items: Ref, config?: ContentFil
}
if (config?.showUpdateFilter && items.value.some((m) => m.has_update)) {
- options.push({ id: 'updates', label: 'Updates' })
+ options.push({ id: 'updates', label: formatMessage(messages.updates) })
}
if (config?.showWarningsFilter && items.value.some((m) => getClientWarningType(m) !== null)) {
- options.push({ id: 'warnings', label: 'Warnings' })
+ options.push({ id: 'warnings', label: formatMessage(messages.warnings) })
}
- if (items.value.some((m) => !m.enabled)) {
- options.push({ id: 'disabled', label: 'Disabled' })
+ for (const status of visibleStatusFilters.value) {
+ options.push({
+ id: status,
+ label: formatMessage(status === 'enabled' ? messages.enabled : messages.disabled),
+ })
}
return options
})
- watch(filterOptions, () => {
- selectedFilters.value = selectedFilters.value.filter((f) =>
- filterOptions.value.some((opt) => opt.id === f),
- )
- })
+ watch(
+ filterOptions,
+ () => {
+ selectedFilters.value = selectedFilters.value.filter((f) =>
+ filterOptions.value.some((opt) => opt.id === f),
+ )
+ },
+ { immediate: true },
+ )
function toggleFilter(filterId: string) {
+ if (filterId === 'enabled' || filterId === 'disabled') {
+ statusFilter.value = filterId
+ const index = selectedFilters.value.indexOf(filterId)
+ const otherStatusFilter = filterId === 'enabled' ? 'disabled' : 'enabled'
+ if (index === -1) {
+ selectedFilters.value = [
+ ...selectedFilters.value.filter((filter) => filter !== otherStatusFilter),
+ filterId,
+ ]
+ } else {
+ selectedFilters.value.splice(index, 1)
+ }
+ return
+ }
+
const index = selectedFilters.value.indexOf(filterId)
if (index === -1) {
selectedFilters.value.push(filterId)
@@ -88,10 +153,23 @@ export function useContentFilters(items: Ref, config?: ContentFil
}
}
+ function cycleStatusFilter(filterId: string) {
+ if (config?.showBothStatusFilters?.value) return false
+ if (filterId !== 'enabled' && filterId !== 'disabled') return false
+ if (availableStatusFilters.value.length < 2) return false
+
+ const next = statusFilter.value === 'disabled' ? 'enabled' : 'disabled'
+ selectedFilters.value = selectedFilters.value.map((filter) =>
+ filter === statusFilter.value ? next : filter,
+ )
+ statusFilter.value = next
+ return true
+ }
+
function applyFilters(source: ContentItem[]): ContentItem[] {
if (selectedFilters.value.length === 0) return source
- const attributeFilters = new Set(['updates', 'disabled', 'warnings'])
+ const attributeFilters = new Set(['updates', 'enabled', 'disabled', 'warnings'])
const typeFilters = selectedFilters.value.filter((f) => !attributeFilters.has(f))
const activeAttributes = selectedFilters.value.filter((f) => attributeFilters.has(f))
@@ -105,6 +183,7 @@ export function useContentFilters(items: Ref, config?: ContentFil
for (const filter of activeAttributes) {
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
}
@@ -113,5 +192,5 @@ export function useContentFilters(items: Ref, config?: ContentFil
})
}
- return { selectedFilters, filterOptions, toggleFilter, applyFilters }
+ return { selectedFilters, filterOptions, toggleFilter, cycleStatusFilter, applyFilters }
}
diff --git a/packages/ui/src/layouts/shared/content-tab/layout.vue b/packages/ui/src/layouts/shared/content-tab/layout.vue
index bb95744091..3df6f61b07 100644
--- a/packages/ui/src/layouts/shared/content-tab/layout.vue
+++ b/packages/ui/src/layouts/shared/content-tab/layout.vue
@@ -214,16 +214,15 @@ const { searchQuery, search } = useContentSearch(sortedItems, [
'file_name',
])
-const { selectedFilters, filterOptions, toggleFilter, applyFilters } = useContentFilters(
- ctx.items,
- {
+const { selectedFilters, filterOptions, toggleFilter, cycleStatusFilter, applyFilters } =
+ useContentFilters(ctx.items, {
showTypeFilters: true,
showUpdateFilter: ctx.hasUpdateSupport,
showWarningsFilter: true,
isPackLocked: ctx.isPackLocked,
persistKey: ctx.filterPersistKey,
- },
-)
+ showBothStatusFilters: ctx.showBothContentStatusFilters,
+ })
const { selectedIds, selectedItems, clearSelection, removeFromSelection } = useContentSelection(
ctx.items,
@@ -437,6 +436,13 @@ function handleSwitchVersionById(id: string) {
}
}
+function handleFilterContextMenu(event: MouseEvent, filterId: string) {
+ if (filterId === 'enabled' || filterId === 'disabled') {
+ event.preventDefault()
+ cycleStatusFilter(filterId)
+ }
+}
+
// Bulk updating
const confirmBulkUpdateModal = ref>()
const pendingBulkUpdateItems = ref([])
@@ -618,6 +624,7 @@ const confirmUnlinkModal = ref>()
"
:aria-pressed="selectedFilters.includes(option.id)"
@click="toggleFilter(option.id)"
+ @contextmenu="handleFilterContextMenu($event, option.id)"
>
{{ option.label }}
diff --git a/packages/ui/src/layouts/shared/content-tab/providers/content-manager.ts b/packages/ui/src/layouts/shared/content-tab/providers/content-manager.ts
index 01265d1ae6..f3d359fd92 100644
--- a/packages/ui/src/layouts/shared/content-tab/providers/content-manager.ts
+++ b/packages/ui/src/layouts/shared/content-tab/providers/content-manager.ts
@@ -92,6 +92,9 @@ export interface ContentManagerContext {
// Filter persistence key — when set, selected filters are saved/restored via sessionStorage
filterPersistKey?: string
+
+ // Status filter display mode
+ showBothContentStatusFilters?: Ref | ComputedRef
}
export const [injectContentManager, provideContentManager] = createContext(
diff --git a/packages/ui/src/locales/en-US/index.json b/packages/ui/src/locales/en-US/index.json
index afc77f6a30..6b4cfc2cc0 100644
--- a/packages/ui/src/locales/en-US/index.json
+++ b/packages/ui/src/locales/en-US/index.json
@@ -350,6 +350,18 @@
"content.diff-modal.updated-count": {
"defaultMessage": "{count} updated"
},
+ "content.filter.disabled": {
+ "defaultMessage": "Disabled"
+ },
+ "content.filter.enabled": {
+ "defaultMessage": "Enabled"
+ },
+ "content.filter.updates": {
+ "defaultMessage": "Updates"
+ },
+ "content.filter.warnings": {
+ "defaultMessage": "Warnings"
+ },
"content.inline-backup.backing-up": {
"defaultMessage": "Creating backup..."
},