feat(category): user hierarchy, fix filter

This commit is contained in:
enes 2024-12-11 14:03:33 +01:00
parent 535aabe4a3
commit f7f8778707
6 changed files with 263 additions and 99 deletions

View File

@ -3,7 +3,10 @@
"name": "audio",
"sub": [
{ "name": "music", "sub": ["background", "ambient"] },
{ "name": "sound effects", "sub": ["footsteps", "weapons"] },
{
"name": "sound effects",
"sub": ["footsteps", "weapons"]
},
"voice"
]
},

View File

@ -1,10 +1,14 @@
import React, { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { Category } from 'types'
import categoriesData from './../../assets/categories/categories.json'
import { capitalizeEachWord } from 'utils'
import {
addToUserCategories,
capitalizeEachWord,
deleteFromUserCategories
} from 'utils'
import { useLocalStorage } from 'hooks'
import styles from './CategoryFilterPopup.module.scss'
import categoriesData from './../../assets/categories/categories.json'
interface CategoryFilterPopupProps {
categories: string[]
@ -21,6 +25,9 @@ export const CategoryFilterPopup = ({
setHierarchies,
handleClose
}: CategoryFilterPopupProps) => {
const [userHierarchies, setUserHierarchies] = useLocalStorage<
(string | Category)[]
>('user-hierarchies', [])
const [filterCategories, setFilterCategories] = useState(categories)
const [filterHierarchies, setFilterHierarchies] = useState(hierarchies)
const handleApply = () => {
@ -54,15 +61,28 @@ export const CategoryFilterPopup = ({
}
const handleAddNew = () => {
if (inputValue) {
const values = inputValue
const value = inputValue.toLowerCase()
const values = value
.trim()
.split('>')
.map((s) => s.trim())
if (values.length > 1) {
setFilterHierarchies([...filterHierarchies, values.join(':')])
} else {
setFilterCategories([...filterCategories, values[0]])
}
setUserHierarchies((prev) => {
addToUserCategories(prev, value)
return [...prev]
})
const path = values.join(':')
// Add new hierarchy to current selection and active selection
setFilterHierarchies((prev) => {
prev.push(path)
return [...prev]
})
setHierarchies((prev) => {
prev.push(path)
return [...prev]
})
setInputValue('')
}
}
@ -104,23 +124,67 @@ export const CategoryFilterPopup = ({
</div>
<input
type='text'
className='inputMain inputMainWithBtn dropdown-toggle'
className='inputMain inputMainWithBtn'
placeholder='Select some categories...'
aria-expanded='false'
data-bs-toggle='dropdown'
value={inputValue}
onChange={handleInputChange}
/>
{true && (
<div className='inputLabelWrapperMain'>
<label
className='form-label labelMain'
style={{ fontWeight: 'bold' }}
{userHierarchies.length > 0 && (
<>
<div className='inputLabelWrapperMain'>
<label
className='form-label labelMain'
style={{ fontWeight: 'bold' }}
>
Custom categories
</label>
<p className='labelDescriptionMain'>Maybe</p>
</div>
<div
className='inputMain'
style={{
minHeight: '40px',
maxHeight: '500px',
height: '100%',
overflow: 'auto'
}}
>
Custom categories
</label>
<p className='labelDescriptionMain'>Maybe</p>
</div>
{userHierarchies
.filter((c) => typeof c !== 'string')
.map((c, i) => (
<CategoryCheckbox
key={`${c}_${i}`}
inputValue={inputValue}
category={c}
path={[c.name]}
handleSingleSelection={handleSingleSelection}
handleCombinationSelection={
handleCombinationSelection
}
selectedSingles={filterCategories}
selectedCombinations={filterHierarchies}
handleRemove={(path) => {
setUserHierarchies((prev) => {
deleteFromUserCategories(prev, path.join('>'))
return [...prev]
})
// Remove the deleted hierarchies from current filter selection and active selection
setFilterHierarchies((prev) =>
prev.filter(
(h) => !h.startsWith(path.join(':'))
)
)
setHierarchies((prev) =>
prev.filter(
(h) => !h.startsWith(path.join(':'))
)
)
}}
/>
))}
</div>
</>
)}
<div className='inputLabelWrapperMain'>
<div
@ -139,8 +203,12 @@ export const CategoryFilterPopup = ({
className='dropdown-item dropdownMainMenuItem'
onClick={handleAddNew}
>
Search for "{inputValue}" category
<button className='btn btnMain btnMainInsideField btnMainRemove'>
Add and search for "{inputValue}" category
<button
type='button'
className='btn btnMain btnMainInsideField btnMainAdd'
title='Add'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
@ -153,10 +221,10 @@ export const CategoryFilterPopup = ({
</button>
</div>
</div>
{(categoriesData as Category[]).map((category) => (
{(categoriesData as Category[]).map((category, i) => (
<CategoryCheckbox
key={`${category.name}_${i}`}
inputValue={inputValue}
key={category.name}
category={category}
path={[category.name]}
handleSingleSelection={handleSingleSelection}
@ -221,6 +289,7 @@ interface CategoryCheckboxProps {
selectedSingles: string[]
selectedCombinations: string[]
indentLevel?: number
handleRemove?: (path: string[]) => void
}
const CategoryCheckbox: React.FC<CategoryCheckboxProps> = ({
@ -231,7 +300,8 @@ const CategoryCheckbox: React.FC<CategoryCheckboxProps> = ({
handleCombinationSelection,
selectedSingles,
selectedCombinations,
indentLevel = 0
indentLevel = 0,
handleRemove
}) => {
const name = typeof category === 'string' ? category : category.name
const isMatching = path
@ -330,6 +400,24 @@ const CategoryCheckbox: React.FC<CategoryCheckboxProps> = ({
checked={isSingleChecked}
onChange={handleSingleChange}
/>
{typeof handleRemove === 'function' && (
<button
className='btn btnMain btnMainRemove'
title='Remove'
type='button'
onClick={() => handleRemove(path)}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='0 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M480 416C497.7 416 512 430.3 512 448C512 465.7 497.7 480 480 480H150.6C133.7 480 117.4 473.3 105.4 461.3L25.37 381.3C.3786 356.3 .3786 315.7 25.37 290.7L258.7 57.37C283.7 32.38 324.3 32.38 349.3 57.37L486.6 194.7C511.6 219.7 511.6 260.3 486.6 285.3L355.9 416H480zM265.4 416L332.7 348.7L195.3 211.3L70.63 336L150.6 416L265.4 416z'></path>
</svg>
</button>
)}
</div>
</div>
)}
@ -350,6 +438,11 @@ const CategoryCheckbox: React.FC<CategoryCheckboxProps> = ({
selectedSingles={selectedSingles}
selectedCombinations={selectedCombinations}
indentLevel={indentLevel + 1}
{...(typeof handleRemove === 'function'
? {
handleRemove
}
: {})}
/>
)
} else {
@ -364,6 +457,11 @@ const CategoryCheckbox: React.FC<CategoryCheckboxProps> = ({
selectedSingles={selectedSingles}
selectedCombinations={selectedCombinations}
indentLevel={indentLevel + 1}
{...(typeof handleRemove === 'function'
? {
handleRemove
}
: {})}
/>
)
}

View File

@ -1,7 +1,6 @@
import _ from 'lodash'
import { Event, kinds, nip19, UnsignedEvent } from 'nostr-tools'
import React, {
CSSProperties,
Fragment,
useCallback,
useEffect,
@ -11,7 +10,7 @@ import React, {
} from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { toast } from 'react-toastify'
import { VariableSizeList, FixedSizeList } from 'react-window'
import { FixedSizeList } from 'react-window'
import { v4 as uuidv4 } from 'uuid'
import { T_TAG_VALUE } from '../constants'
import { useAppSelector, useGames, useNDKContext } from '../hooks'
@ -73,9 +72,10 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
useEffect(() => {
if (location.pathname === appRoutes.submitMod) {
// Only trigger when the pathname changes to submit-mod
setFormState(initializeFormState())
}
}, [location.pathname]) // Only trigger when the pathname changes to submit-mod
}, [location.pathname])
useEffect(() => {
if (existingModData) {
@ -974,7 +974,7 @@ export const CategoryAutocomplete = ({
}
const handleAddNew = () => {
if (inputValue) {
const value = inputValue.trim()
const value = inputValue.trim().toLowerCase()
const newOption: Categories = {
name: value,
hierarchy: value,
@ -993,44 +993,11 @@ export const CategoryAutocomplete = ({
}))
}, [selectedCategories, setFormState])
const listRef = useRef<VariableSizeList>(null)
const rowHeights = useRef<{ [index: number]: number }>({})
const setRowHeight = (index: number, size: number) => {
rowHeights.current = { ...rowHeights.current, [index]: size }
if (listRef.current) {
listRef.current.resetAfterIndex(index)
}
}
const getRowHeight = (index: number) => {
return (rowHeights.current[index] || 35) + 8
}
const Row = ({ index, style }: { index: number; style: CSSProperties }) => {
const rowRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const rowElement = rowRef.current
if (!rowElement) return
const updateHeight = () => {
const height = Math.max(rowElement.scrollHeight, 35)
setRowHeight(index, height)
}
const observer = new ResizeObserver(() => {
updateHeight()
})
observer.observe(rowElement)
updateHeight()
return () => {
observer.disconnect()
}
}, [index])
const Row = ({ index }: { index: number }) => {
if (!filteredOptions) return null
return (
<div
ref={rowRef}
style={style}
className='dropdown-item dropdownMainMenuItem'
onClick={() => handleSelect(filteredOptions[index])}
>
@ -1039,6 +1006,7 @@ export const CategoryAutocomplete = ({
(cat) => cat.hierarchy === filteredOptions[index].hierarchy
) && (
<button
type='button'
className='btn btnMain btnMainInsideField btnMainRemove'
onClick={() => handleRemove(filteredOptions[index])}
>
@ -1074,6 +1042,7 @@ export const CategoryAutocomplete = ({
/>
<button
className='btn btnMain btnMainInsideField btnMainRemove'
title='Remove'
type='button'
onClick={handleReset}
>
@ -1088,17 +1057,14 @@ export const CategoryAutocomplete = ({
</svg>
</button>
<div className='dropdown-menu dropdownMainMenu dropdownMainMenuAlt'>
<div
className='dropdown-menu dropdownMainMenu dropdownMainMenuAlt category'
style={{
maxHeight: '500px'
}}
>
{filteredOptions && filteredOptions.length > 0 ? (
<VariableSizeList
ref={listRef}
height={500}
width={'100%'}
itemCount={filteredOptions.length}
itemSize={getRowHeight}
>
{Row}
</VariableSizeList>
filteredOptions.map((c, i) => <Row key={c.hierarchy} index={i} />)
) : (
<div
className='dropdown-item dropdownMainMenuItem'
@ -1111,7 +1077,11 @@ export const CategoryAutocomplete = ({
) ? (
<>
Add "{inputValue}"
<button className='btn btnMain btnMainInsideField btnMainRemove'>
<button
type='button'
className='btn btnMain btnMainInsideField btnMainAdd'
title='Add'
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'

View File

@ -91,8 +91,38 @@ export const GamePage = () => {
return true
}
// If search term is missing, only filter by sources
if (searchTerm === '') return mods.filter(filterSourceFn)
const filterCategoryFn = (mod: ModDetails) => {
// Linked overrides the category popup selection
if (linkedHierarchy && linkedHierarchy !== '') {
return mod.LTags.includes(linkedHierarchy)
}
// If no selections are active return true
if (!(hierarchies.length || categories.length)) {
return true
}
// Hierarchy selection active
if (hierarchies.length) {
const isMatch = mod.LTags.some((item) => hierarchies.includes(item))
// Matched hierarchy, return true immediately otherwise check categories
if (isMatch) return isMatch
}
// Category selection
if (categories.length) {
// Return result immediately
return mod.lTags.some((item) => categories.includes(item))
}
// No matches
return false
}
// If search term is missing, only filter by sources and category
if (searchTerm === '')
return mods.filter(filterSourceFn).filter(filterCategoryFn)
const lowerCaseSearchTerm = searchTerm.toLowerCase()
@ -105,8 +135,15 @@ export const GamePage = () => {
tag.toLowerCase().includes(lowerCaseSearchTerm)
) > -1
return mods.filter(filterFn).filter(filterSourceFn)
}, [filterOptions.source, mods, searchTerm])
return mods.filter(filterFn).filter(filterSourceFn).filter(filterCategoryFn)
}, [
categories,
filterOptions.source,
hierarchies,
linkedHierarchy,
mods,
searchTerm
])
const filteredModList = useFilteredMods(
filteredMods,
@ -132,24 +169,13 @@ export const GamePage = () => {
}
useEffect(() => {
if (!gameName) return
const filter: NDKFilter = {
kinds: [NDKKind.Classified],
'#t': [T_TAG_VALUE]
}
// Linked category will override the filter
if (linkedHierarchy && linkedHierarchy !== '') {
filter['#L'] = [`com.degmods:${linkedHierarchy}`]
} else {
if (categories.length) {
filter['#l'] = categories.map((l) => `com.degmods:${l}`)
}
if (hierarchies.length) {
filter['#L'] = hierarchies.map((L) => `com.degmods:${L}`)
}
}
const subscription = ndk.subscribe(filter, {
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL,
closeOnEose: true
@ -173,7 +199,7 @@ export const GamePage = () => {
return () => {
subscription.stop()
}
}, [gameName, ndk, linkedHierarchy, categories, hierarchies])
}, [gameName, ndk])
if (!gameName) return null

View File

@ -271,16 +271,16 @@ h6 {
}
/* the 4 classes below here are a temp fix for the games dropdown stylings */
.dropdownMainMenu.dropdownMainMenuAlt {
/* add an exception (not category) for normal dropdown - due !important */
.dropdownMainMenu.dropdownMainMenuAlt:not(.category) {
max-height: unset !important;
}
.dropdownMainMenu.dropdownMainMenuAlt > div {
.dropdownMainMenu.dropdownMainMenuAlt:not(.category) > div {
height: unset !important;
}
.dropdownMainMenu.dropdownMainMenuAlt > div > div {
.dropdownMainMenu.dropdownMainMenuAlt:not(.category) > div > div {
height: unset !important;
width: 100% !important;
display: flex;
@ -291,7 +291,7 @@ h6 {
padding: 5px;
}
.dropdownMainMenu.dropdownMainMenuAlt > div > div > div {
.dropdownMainMenu.dropdownMainMenuAlt:not(.category) > div > div > div {
position: relative !important;
left: unset !important;
top: unset !important;

View File

@ -25,3 +25,70 @@ const flattenCategories = (
export const getCategories = () => {
return flattenCategories(categoriesData)
}
export const buildCategories = (input: string[]) => {
const categories: (string | Category)[] = []
input.forEach((cat) => {
addToUserCategories(categories, cat)
})
return categories
}
export const addToUserCategories = (
categories: (string | Category)[],
input: string
) => {
const segments = input.split('>').map((s) => s.trim())
let currentLevel: (string | Category)[] = categories
for (let i = 0; i < segments.length; i++) {
const segment = segments[i].trim()
const existingNode = currentLevel.find(
(item) => typeof item !== 'string' && item.name === segment
)
if (!existingNode) {
const newCategory: Category = { name: segment, sub: [] }
currentLevel.push(newCategory)
if (newCategory.sub) {
currentLevel = newCategory.sub
}
} else if (typeof existingNode !== 'string') {
if (!existingNode.sub) {
existingNode.sub = []
}
currentLevel = existingNode.sub
}
}
}
export const deleteFromUserCategories = (
categories: (string | Category)[],
input: string
) => {
const segments = input.split('>').map((s) => s.trim())
const value = segments.pop()
if (!value) {
return
}
let currentLevel: (string | Category)[] = categories
for (let i = 0; i < segments.length; i++) {
const key = segments[i]
const existingNode = currentLevel.find(
(item) => typeof item === 'object' && item.name === key
) as Category
if (existingNode && existingNode.sub) {
currentLevel = existingNode.sub
}
}
const valueIndex = currentLevel.findIndex(
(item) =>
item === value || (typeof item === 'object' && item.name === value)
)
if (valueIndex !== -1) {
currentLevel.splice(valueIndex, 1)
}
}