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) }}

+
+ +
+

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..." },