feat(category): user hierarchy, fix filter
This commit is contained in:
parent
535aabe4a3
commit
f7f8778707
@ -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"
|
||||
]
|
||||
},
|
||||
|
@ -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
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
@ -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'
|
||||
|
@ -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
|
||||
|
||||
|
@ -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;
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user