From d10e8d313d483818c61f4fbfdd2d920f18e0f354 Mon Sep 17 00:00:00 2001 From: Pulsar-Programmer <95889506+Pulsar-Programmer@users.noreply.github.com> Date: Thu, 2 Jul 2026 21:57:17 -0400 Subject: [PATCH 1/2] Add filtering, search, and sorting to collections Fixes #1355 --- .../src/pages/collection/[collection].vue | 318 +++++++++++++++++- 1 file changed, 302 insertions(+), 16 deletions(-) diff --git a/apps/frontend/src/pages/collection/[collection].vue b/apps/frontend/src/pages/collection/[collection].vue index cc181c17b7..12c87d6afb 100644 --- a/apps/frontend/src/pages/collection/[collection].vue +++ b/apps/frontend/src/pages/collection/[collection].vue @@ -312,21 +312,84 @@ class="mb-4" /> + + + + + + + + {{ formatMessage(commonMessages.sortByLabel) }} + + + + + + + {{ formatMessage(commonMessages.filtersLabel) }} + + {{ selectedFilters.length }} + + + + + + + + + + {{ filterType.formatted_name }} + + + + + + + + + + + + {{ formatMessage(messages.clearFiltersButton) }} + + + + + @@ -385,13 +463,18 @@ import { CalendarIcon, ChevronLeftIcon, CompassIcon, + DropdownIcon, EditIcon, + FilterIcon, + getCategoryIcon, + getLoaderIcon, GlobeIcon, HeartMinusIcon, LinkIcon, LockIcon, MoreVerticalIcon, SaveIcon, + SearchIcon, SpinnerIcon, TrashIcon, UpdatedIcon, @@ -401,6 +484,7 @@ import { import { Avatar, ButtonStyled, + Combobox, commonMessages, commonProjectTypeCategoryMessages, commonProjectTypeSentenceMessages, @@ -409,6 +493,9 @@ import { defineMessages, EmptyState, FileInput, + formatCategory, + formatCategoryHeader, + formatLoader, HorizontalRule, injectModrinthClient, injectNotificationManager, @@ -421,6 +508,8 @@ import { ProjectCard, ProjectCardList, RadioButtons, + SearchFilterControl, + SearchSidebarFilter, SidebarCard, StyledInput, useCompactNumber, @@ -450,6 +539,7 @@ const route = useNativeRoute() const router = useRouter() const auth = await useAuth() const cosmetics = useCosmetics() +const tags = useGeneratedState() const queryClient = useQueryClient() const baseId = useId() @@ -534,6 +624,30 @@ const messages = defineMessages({ id: 'collection.label.no-projects', defaultMessage: 'No projects in collection yet', }, + noResultsLabel: { + id: 'collection.label.no-results', + defaultMessage: 'No projects match your search', + }, + clearFiltersButton: { + id: 'collection.button.clear-filters', + defaultMessage: 'Clear filters', + }, + searchPlaceholder: { + id: 'collection.search.placeholder', + defaultMessage: 'Search collection...', + }, + gameVersionFilterLabel: { + id: 'search.filter_type.game_version', + defaultMessage: 'Game version', + }, + loaderFilterLabel: { + id: 'search.filter_type.mod_loader', + defaultMessage: 'Loader', + }, + showAllVersionsLabel: { + id: 'search.filter_type.game_version.all_versions', + defaultMessage: 'Show all versions', + }, projectsCountLabel: { id: 'collection.label.projects-count', defaultMessage: '{count, plural, =0 {No projects yet} other {{count} {type}}}', @@ -770,6 +884,183 @@ function getProjectTypeCategoryMessage(type) { return commonProjectTypeCategoryMessages[type] ?? commonProjectTypeCategoryMessages.project } +const searchQuery = ref('') +const filtersOpen = ref(false) +const selectedFilters = ref([]) +const toggledGroups = ref([]) + +const sortOptions = [ + { value: 'downloads', label: 'Downloads' }, + { value: 'follows', label: 'Followers' }, + { value: 'updated', label: 'Date updated' }, + { value: 'newest', label: 'Date published' }, + { value: 'name', label: 'Name' }, +] +const currentSort = ref('downloads') + +const typeFilteredProjects = computed(() => { + if (!projects.value) return [] + const typeParam = route.params.projectType + if (typeParam === undefined) return projects.value + const type = typeParam.substring(0, typeParam.length - 1) + return projects.value.filter((x) => x.project_type === type) +}) + +const collectionFilterTypes = computed(() => { + const gameVersions = new Set() + const loaders = new Set() + const categories = new Set() + for (const project of typeFilteredProjects.value) { + project.game_versions?.forEach((version) => gameVersions.add(version)) + project.loaders?.forEach((loader) => loaders.add(loader)) + project.categories?.forEach((category) => categories.add(category)) + } + for (const loader of loaders) { + categories.delete(loader) + } + + const filterTypes = [] + + const gameVersionOptions = tags.value.gameVersions + .filter((gameVersion) => gameVersions.has(gameVersion.version)) + .map((gameVersion) => ({ + id: gameVersion.version, + toggle_group: gameVersion.version_type !== 'release' ? 'all_versions' : undefined, + method: 'or', + value: gameVersion.version, + })) + if (gameVersionOptions.length > 0) { + filterTypes.push({ + id: 'game_version', + formatted_name: formatMessage(messages.gameVersionFilterLabel), + supported_project_types: [], + display: 'scrollable', + query_param: 'v', + supports_negative_filter: false, + searchable: true, + toggle_groups: gameVersionOptions.some((option) => option.toggle_group) + ? [{ id: 'all_versions', formatted_name: formatMessage(messages.showAllVersionsLabel) }] + : [], + options: gameVersionOptions, + }) + } + + const loaderOptions = tags.value.loaders + .filter((loader) => loaders.has(loader.name)) + .map((loader) => ({ + id: loader.name, + formatted_name: formatLoader(formatMessage, loader.name), + icon: getLoaderIcon(loader.name), + method: 'or', + value: loader.name, + })) + if (loaderOptions.length > 0) { + filterTypes.push({ + id: 'mod_loader', + formatted_name: formatMessage(messages.loaderFilterLabel), + supported_project_types: [], + display: 'scrollable', + query_param: 'g', + supports_negative_filter: true, + searchable: false, + options: loaderOptions, + }) + } + + const seenCategories = new Set() + const categoryOptions = [] + for (const category of tags.value.categories) { + if (!categories.has(category.name) || seenCategories.has(category.name)) continue + seenCategories.add(category.name) + categoryOptions.push({ + id: category.name, + formatted_name: formatCategory(formatMessage, category.name), + icon: getCategoryIcon(category.name), + method: 'or', + value: category.name, + }) + } + if (categoryOptions.length > 0) { + filterTypes.push({ + id: 'category', + formatted_name: formatCategoryHeader(formatMessage, 'categories'), + supported_project_types: [], + display: 'scrollable', + query_param: 'f', + supports_negative_filter: true, + searchable: false, + options: categoryOptions, + }) + } + + return filterTypes +}) + +watch( + () => route.params.projectType, + () => { + const validOptions = new Set( + collectionFilterTypes.value.flatMap((filterType) => + filterType.options.map((option) => `${filterType.id}:${option.id}`), + ), + ) + selectedFilters.value = selectedFilters.value.filter((filter) => + validOptions.has(`${filter.type}:${filter.option}`), + ) + }, +) + +function projectMatchesFilters(project) { + for (const filterType of collectionFilterTypes.value) { + const selected = selectedFilters.value.filter((filter) => filter.type === filterType.id) + if (selected.length === 0) continue + const values = + filterType.id === 'game_version' + ? (project.game_versions ?? []) + : filterType.id === 'mod_loader' + ? (project.loaders ?? []) + : (project.categories ?? []) + if (selected.some((filter) => filter.negative && values.includes(filter.option))) { + return false + } + const included = selected.filter((filter) => !filter.negative) + if (included.length > 0 && !included.some((filter) => values.includes(filter.option))) { + return false + } + } + return true +} + +const displayProjects = computed(() => { + const query = searchQuery.value.trim().toLowerCase() + const filtered = typeFilteredProjects.value.filter( + (project) => + (!query || + project.title?.toLowerCase().includes(query) || + project.description?.toLowerCase().includes(query)) && + projectMatchesFilters(project), + ) + return filtered.sort((a, b) => { + switch (currentSort.value) { + case 'follows': + return (b.followers ?? 0) - (a.followers ?? 0) + case 'updated': + return dayjs(b.updated).diff(dayjs(a.updated)) + case 'newest': + return dayjs(b.published).diff(dayjs(a.published)) + case 'name': + return a.title.localeCompare(b.title) + default: + return (b.downloads ?? 0) - (a.downloads ?? 0) + } + }) +}) + +function clearSearchAndFilters() { + searchQuery.value = '' + selectedFilters.value = [] +} + const showUpdatedDate = computed(() => { if (!collection.value?.updated || !collection.value?.created) { return false @@ -940,11 +1231,6 @@ function openEditModal(event) {