Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(theseus): add category filter to mods within instance #1263

Closed
wants to merge 8 commits into from
Closed
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
75 changes: 75 additions & 0 deletions apps/app-frontend/src/components/ui/filter/CategoryFilter.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
<script setup>
import { computed, ref } from 'vue'
import { get_categories, sortByNameOrNumber } from '@/helpers/tags.js'
import { handleError } from '@/store/notifications.js'
import { SearchFilter } from '@modrinth/ui'
import { formatCategory, formatCategoryHeader } from '@modrinth/utils'

const props = defineProps({
projectType: {
type: String,
required: true,
},
facets: {
type: Object,
required: true,
},
})

const emits = defineEmits(['toggleFacet'])

function onToggleFacet(elementName) {
emits('toggleFacet', elementName)
}

const sortedCategories = computed(() => {
const values = new Map()

outer: for (const category of categories.value.filter((cat) => {
return (
cat.project_type === (props.projectType === 'datapack' ? 'mod' : props.projectType) ||
props.projectType === 'all'
)
})) {
if (!values.has(category.header)) {
values.set(category.header, [])
}

for (let existingCategory of values.get(category.header)) {
if (existingCategory.name === category.name) {
continue outer
}
}

values.get(category.header).push(category)
}
return values
})

const categories = await get_categories()
.catch(handleError)
.then((s) => sortByNameOrNumber(s, ['header', 'name']))
.then(ref)
</script>

<template>
<div
v-for="categoryList in Array.from(sortedCategories)"
:key="categoryList[0]"
class="categories"
>
<h2>{{ formatCategoryHeader(categoryList[0]) }}</h2>
<div v-for="category in categoryList[1]" :key="category.name">
<SearchFilter
:active-filters="facets"
:icon="category.icon"
:display-name="formatCategory(category.name)"
:facet-name="`categories:${encodeURIComponent(category.name)}`"
class="filter-checkbox"
@toggle="onToggleFacet"
/>
</div>
</div>
</template>

<style scoped lang="scss"></style>
26 changes: 26 additions & 0 deletions apps/app-frontend/src/helpers/tags.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,29 @@ export async function get_donation_platforms() {
export async function get_report_types() {
return await invoke('plugin:tags|tags_get_report_types')
}

// Sorts alphabetically, but correctly identifies 8x, 128x, 256x, etc
// identifier[0], then if it ties, identifier[1], etc
export function sortByNameOrNumber(sortable, identifiers) {
sortable.sort((a, b) => {
for (let identifier of identifiers) {
let aNum = parseFloat(a[identifier])
let bNum = parseFloat(b[identifier])
if (isNaN(aNum) && isNaN(bNum)) {
// Both are strings, sort alphabetically
let stringComp = a[identifier].localeCompare(b[identifier])
if (stringComp != 0) return stringComp
} else if (!isNaN(aNum) && !isNaN(bNum)) {
// Both are numbers, sort numerically
let numComp = aNum - bNum
if (numComp != 0) return numComp
} else {
// One is a number and one is a string, numbers go first
let numStringComp = isNaN(aNum) ? 1 : -1
if (numStringComp != 0) return numStringComp
}
}
return 0
})
return sortable
}
74 changes: 5 additions & 69 deletions apps/app-frontend/src/pages/Browse.vue
Original file line number Diff line number Diff line change
@@ -1,21 +1,11 @@
<script setup>
import { computed, nextTick, ref, readonly, shallowRef, watch, onUnmounted } from 'vue'
import { ClearIcon, SearchIcon, ClientIcon, ServerIcon, XIcon } from '@modrinth/assets'
import {
Pagination,
Checkbox,
Button,
DropdownSelect,
Promotion,
NavRow,
Card,
SearchFilter,
} from '@modrinth/ui'
import { formatCategoryHeader, formatCategory } from '@modrinth/utils'
import { formatCategory } from '@modrinth/utils'
import Multiselect from 'vue-multiselect'
import { handleError } from '@/store/state'
import { useBreadcrumbs } from '@/store/breadcrumbs'
import { get_categories, get_loaders, get_game_versions } from '@/helpers/tags'
import { get_loaders, get_game_versions, sortByNameOrNumber, get_categories } from '@/helpers/tags'
import { useRoute, useRouter } from 'vue-router'
import { Avatar } from '@modrinth/ui'
import SearchCard from '@/components/ui/SearchCard.vue'
Expand All @@ -28,6 +18,7 @@ import { check_installed, get, get as getInstance } from '@/helpers/profile.js'
import { convertFileSrc } from '@tauri-apps/api/tauri'
import { isOffline } from '@/helpers/utils'
import { offline_listener } from '@/helpers/events'
import CategoryFilter from '@/components/ui/filter/CategoryFilter.vue'

const router = useRouter()
const route = useRoute()
Expand Down Expand Up @@ -368,45 +359,6 @@ function getSearchUrl(offset, useObj) {
return useObj ? obj : url
}

const sortedCategories = computed(() => {
const values = new Map()
for (const category of categories.value.filter(
(cat) => cat.project_type === (projectType.value === 'datapack' ? 'mod' : projectType.value),
)) {
if (!values.has(category.header)) {
values.set(category.header, [])
}
values.get(category.header).push(category)
}
return values
})

// Sorts alphabetically, but correctly identifies 8x, 128x, 256x, etc
// identifier[0], then if it ties, identifier[1], etc
async function sortByNameOrNumber(sortable, identifiers) {
sortable.sort((a, b) => {
for (let identifier of identifiers) {
let aNum = parseFloat(a[identifier])
let bNum = parseFloat(b[identifier])
if (isNaN(aNum) && isNaN(bNum)) {
// Both are strings, sort alphabetically
let stringComp = a[identifier].localeCompare(b[identifier])
if (stringComp != 0) return stringComp
} else if (!isNaN(aNum) && !isNaN(bNum)) {
// Both are numbers, sort numerically
let numComp = aNum - bNum
if (numComp != 0) return numComp
} else {
// One is a number and one is a string, numbers go first
let numStringComp = isNaN(aNum) ? 1 : -1
if (numStringComp != 0) return numStringComp
}
}
return 0
})
return sortable
}

async function clearFilters() {
for (const facet of [...facets.value]) {
await toggleFacet(facet, true)
Expand Down Expand Up @@ -639,23 +591,7 @@ onUnmounted(() => unlistenOffline())
@update:model-value="onSearchChangeToTop(1)"
/>
</div>
<div
v-for="categoryList in Array.from(sortedCategories)"
:key="categoryList[0]"
class="categories"
>
<h2>{{ formatCategoryHeader(categoryList[0]) }}</h2>
<div v-for="category in categoryList[1]" :key="category.name">
<SearchFilter
:active-filters="facets"
:icon="category.icon"
:display-name="formatCategory(category.name)"
:facet-name="`categories:${encodeURIComponent(category.name)}`"
class="filter-checkbox"
@toggle="toggleFacet"
/>
</div>
</div>
<CategoryFilter :facets="facets" :project-type="projectType" @toggle-facet="toggleFacet" />
<div v-if="projectType !== 'datapack'" class="environment">
<h2>Environments</h2>
<SearchFilter
Expand Down Expand Up @@ -913,7 +849,7 @@ onUnmounted(() => unlistenOffline())
background: transparent;
}

h2 {
:deep(h2) {
color: var(--color-contrast);
margin-top: 1rem;
margin-bottom: 0.5rem;
Expand Down
3 changes: 1 addition & 2 deletions apps/app-frontend/src/pages/instance/Index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -350,9 +350,8 @@ Button {
background: transparent;
}

.card {
:deep(.card) {
min-height: unset;
margin-bottom: 0;
}
}

Expand Down
65 changes: 62 additions & 3 deletions apps/app-frontend/src/pages/instance/Mods.vue
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@
</p>
</div>
<div class="button-group push-right">
<Button @click="deleteWarning.hide()"> Cancel </Button>
<Button @click="deleteWarning.hide()"> Cancel</Button>
<Button color="danger" @click="deleteSelected">
<TrashIcon />
Remove
Expand All @@ -344,7 +344,7 @@
</p>
</div>
<div class="button-group push-right">
<Button @click="deleteDisabledWarning.hide()"> Cancel </Button>
<Button @click="deleteDisabledWarning.hide()"> Cancel</Button>
<Button color="danger" @click="deleteDisabled">
<TrashIcon />
Remove
Expand All @@ -364,6 +364,15 @@
:instance="instance"
:versions="props.versions"
/>
<Teleport v-if="mounted" to=".side-cards">
<Card class="filter-panel">
<CategoryFilter
:facets="facets"
:project-type="selectableProjectTypes[selectedProjectType]"
@toggle-facet="toggleFacet"
/>
</Card>
</Teleport>
</template>
<script setup>
import {
Expand Down Expand Up @@ -394,7 +403,7 @@ import {
Card,
} from '@modrinth/ui'
import { formatProjectType } from '@modrinth/utils'
import { computed, onUnmounted, ref, watch } from 'vue'
import { computed, onUnmounted, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import {
add_project_from_path,
Expand All @@ -413,6 +422,7 @@ import { highlightModInProfile } from '@/helpers/utils.js'
import { MenuIcon, ToggleIcon, TextInputIcon, AddProjectImage, PackageIcon } from '@/assets/icons'
import ExportModal from '@/components/ui/ExportModal.vue'
import ModpackVersionModal from '@/components/ui/ModpackVersionModal.vue'
import CategoryFilter from '@/components/ui/filter/CategoryFilter.vue'

const router = useRouter()

Expand Down Expand Up @@ -453,12 +463,25 @@ const canUpdatePack = computed(() => {
})
const exportModal = ref(null)

// used to make sure to only render teleport (category selector)
// when the page is fully loading so the sidebar definitely exists
let mounted = ref(false)

onMounted(() => {
mounted.value = true
})

const initProjects = (initInstance) => {
projects.value = []
if (!initInstance || !initInstance.projects) return
for (const [path, project] of Object.entries(initInstance.projects)) {
if (project.metadata.type === 'modrinth' && !props.offline) {
let owner = project.metadata.members.find((x) => x.role === 'Owner')

let categories = project.metadata.project.categories.concat(
project.metadata.project.additional_categories,
)

projects.value.push({
path,
name: project.metadata.project.title,
Expand All @@ -471,6 +494,7 @@ const initProjects = (initInstance) => {
updateVersion: project.metadata.update_version,
outdated: !!project.metadata.update_version,
project_type: project.metadata.project.project_type,
categories: categories,
id: project.metadata.project.id,
})
} else if (project.metadata.type === 'inferred') {
Expand All @@ -483,6 +507,7 @@ const initProjects = (initInstance) => {
icon: project.metadata.icon ? convertFileSrc(project.metadata.icon) : null,
disabled: project.disabled,
outdated: false,
categories: [],
project_type: project.metadata.project_type,
})
} else {
Expand Down Expand Up @@ -532,6 +557,7 @@ watch(
const modpackVersionModal = ref(null)
const installing = computed(() => props.instance.install_stage !== 'installed')

const facets = ref([])
const searchFilter = ref('')
const selectAll = ref(false)
const selectedProjectType = ref('All')
Expand Down Expand Up @@ -573,13 +599,26 @@ const selectableProjectTypes = computed(() => {

const search = computed(() => {
const projectType = selectableProjectTypes.value[selectedProjectType.value]
const facetNames = facets.value.map((facet) => {
return facet.split(':')[1]
})

const filtered = projects.value
.filter((mod) => {
return (
mod.name.toLowerCase().includes(searchFilter.value.toLowerCase()) &&
(projectType === 'all' || mod.project_type === projectType)
)
})
.filter((mod) => {
for (let facet of facetNames) {
if (!mod.categories.includes(facet)) {
return false
}
}

return true
})
.filter((mod) => {
if (hideNonSelected.value) {
return !mod.disabled
Expand All @@ -590,6 +629,16 @@ const search = computed(() => {
return updateSort(filtered)
})

function toggleFacet(name) {
const index = facets.value.indexOf(name)

if (index !== -1) {
facets.value.splice(index, 1)
} else {
facets.value.push(name)
}
}

const updateSort = (projects) => {
switch (sortColumn.value) {
case 'Version':
Expand Down Expand Up @@ -1133,6 +1182,7 @@ onUnmounted(() => {
}
}
}

.empty-prompt {
display: flex;
flex-direction: column;
Expand All @@ -1156,6 +1206,15 @@ onUnmounted(() => {
margin: 0;
}
}

.filter-panel {
:deep(h2) {
color: var(--color-contrast);
margin-top: 1rem;
margin-bottom: 0.5rem;
font-size: 1.16rem;
}
}
</style>
<style lang="scss">
.updating-indicator {
Expand Down