Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -248,6 +265,45 @@ watch(
/>
</div>

<div class="mt-6 flex items-center justify-between gap-4">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.contentStatusFilterModeTitle) }}
</h2>
<p class="m-0 mt-1">{{ formatMessage(messages.contentStatusFilterModeDescription) }}</p>
</div>
<Combobox
id="content-status-filter-mode"
name="Content status filter mode dropdown"
class="max-w-56"
:model-value="
themeStore.getFeatureFlag(showBothContentStatusFiltersFeatureFlag) ? 'both' : 'right-click'
"
:options="[
{
value: 'right-click',
label: formatMessage(messages.contentStatusFilterModeRightClick),
},
{
value: 'both',
label: formatMessage(messages.contentStatusFilterModeBoth),
},
]"
:display-value="
themeStore.getFeatureFlag(showBothContentStatusFiltersFeatureFlag)
? formatMessage(messages.contentStatusFilterModeBoth)
: formatMessage(messages.contentStatusFilterModeRightClick)
"
@update:model-value="
(value) => {
const showBoth = value === 'both'
themeStore.featureFlags[showBothContentStatusFiltersFeatureFlag] = showBoth
settings.feature_flags[showBothContentStatusFiltersFeatureFlag] = showBoth
}
"
/>
</div>

<div class="mt-6 flex items-center justify-between">
<div>
<h2 class="m-0 text-lg font-semibold text-contrast">
Expand Down
12 changes: 12 additions & 0 deletions apps/app-frontend/src/locales/en-US/index.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
},
Expand Down
7 changes: 7 additions & 0 deletions apps/app-frontend/src/pages/instance/Mods.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -842,6 +846,9 @@ provideContentManager({
installing: item.installing,
}),
filterPersistKey: props.instance.path,
showBothContentStatusFilters: computed(() =>
themeStore.getFeatureFlag(showBothContentStatusFiltersFeatureFlag),
),
})

await initProjects()
Expand Down
1 change: 1 addition & 0 deletions apps/app-frontend/src/store/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand Down
1 change: 1 addition & 0 deletions packages/app-lib/src/state/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ pub enum FeatureFlag {
ServersInApp,
ServerProjectQa,
I18nDebug,
ContentFilterShowBothStatuses,
}

impl Settings {
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -31,14 +31,57 @@ export interface ContentFilterConfig {
showWarningsFilter?: boolean
isPackLocked?: Ref<boolean>
persistKey?: string
showBothStatusFilters?: Ref<boolean> | ComputedRef<boolean>
}

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<ContentItem[]>, config?: ContentFilterConfig) {
const { formatMessage } = useVIntl()

const selectedFilters = config?.persistKey
? useSessionStorage<string[]>(`content-filters:${config.persistKey}`, [])
: ref<string[]>([])
const statusFilter = config?.persistKey
? useSessionStorage<'enabled' | 'disabled'>(
`content-status-filter:${config.persistKey}`,
'disabled',
)
: ref<'enabled' | 'disabled'>('disabled')

const availableStatusFilters = computed<Array<'enabled' | 'disabled'>>(() => {
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<Array<'enabled' | 'disabled'>>(() => {
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<ContentFilterOption[]>(() => {
const options: ContentFilterOption[] = []
Expand All @@ -59,27 +102,49 @@ export function useContentFilters(items: Ref<ContentItem[]>, 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)
Expand All @@ -88,10 +153,23 @@ export function useContentFilters(items: Ref<ContentItem[]>, 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))

Expand All @@ -105,6 +183,7 @@ export function useContentFilters(items: Ref<ContentItem[]>, 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
}
Expand All @@ -113,5 +192,5 @@ export function useContentFilters(items: Ref<ContentItem[]>, config?: ContentFil
})
}

return { selectedFilters, filterOptions, toggleFilter, applyFilters }
return { selectedFilters, filterOptions, toggleFilter, cycleStatusFilter, applyFilters }
}
17 changes: 12 additions & 5 deletions packages/ui/src/layouts/shared/content-tab/layout.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<InstanceType<typeof ConfirmBulkUpdateModal>>()
const pendingBulkUpdateItems = ref<ContentItem[]>([])
Expand Down Expand Up @@ -618,6 +624,7 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
"
:aria-pressed="selectedFilters.includes(option.id)"
@click="toggleFilter(option.id)"
@contextmenu="handleFilterContextMenu($event, option.id)"
>
{{ option.label }}
</button>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> | ComputedRef<boolean>
}

export const [injectContentManager, provideContentManager] = createContext<ContentManagerContext>(
Expand Down
12 changes: 12 additions & 0 deletions packages/ui/src/locales/en-US/index.json
Original file line number Diff line number Diff line change
Expand Up @@ -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..."
},
Expand Down