feat: categories and popups #171

Merged
enes merged 22 commits from 116-categories into staging 2024-12-12 16:37:38 +00:00
6 changed files with 263 additions and 99 deletions
Showing only changes of commit f7f8778707 - Show all commits

View File

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

View File

@ -1,10 +1,14 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import { Category } from 'types' import { Category } from 'types'
import categoriesData from './../../assets/categories/categories.json' import {
import { capitalizeEachWord } from 'utils' addToUserCategories,
capitalizeEachWord,
deleteFromUserCategories
} from 'utils'
import { useLocalStorage } from 'hooks'
import styles from './CategoryFilterPopup.module.scss' import styles from './CategoryFilterPopup.module.scss'
import categoriesData from './../../assets/categories/categories.json'
interface CategoryFilterPopupProps { interface CategoryFilterPopupProps {
categories: string[] categories: string[]
@ -21,6 +25,9 @@ export const CategoryFilterPopup = ({
setHierarchies, setHierarchies,
handleClose handleClose
}: CategoryFilterPopupProps) => { }: CategoryFilterPopupProps) => {
const [userHierarchies, setUserHierarchies] = useLocalStorage<
(string | Category)[]
>('user-hierarchies', [])
const [filterCategories, setFilterCategories] = useState(categories) const [filterCategories, setFilterCategories] = useState(categories)
const [filterHierarchies, setFilterHierarchies] = useState(hierarchies) const [filterHierarchies, setFilterHierarchies] = useState(hierarchies)
const handleApply = () => { const handleApply = () => {
@ -54,15 +61,28 @@ export const CategoryFilterPopup = ({
} }
const handleAddNew = () => { const handleAddNew = () => {
if (inputValue) { if (inputValue) {
const values = inputValue const value = inputValue.toLowerCase()
const values = value
.trim() .trim()
.split('>') .split('>')
.map((s) => s.trim()) .map((s) => s.trim())
if (values.length > 1) {
setFilterHierarchies([...filterHierarchies, values.join(':')]) setUserHierarchies((prev) => {
} else { addToUserCategories(prev, value)
setFilterCategories([...filterCategories, values[0]]) 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('') setInputValue('')
} }
} }
@ -104,14 +124,13 @@ export const CategoryFilterPopup = ({
</div> </div>
<input <input
type='text' type='text'
className='inputMain inputMainWithBtn dropdown-toggle' className='inputMain inputMainWithBtn'
placeholder='Select some categories...' placeholder='Select some categories...'
aria-expanded='false'
data-bs-toggle='dropdown'
value={inputValue} value={inputValue}
onChange={handleInputChange} onChange={handleInputChange}
/> />
{true && ( {userHierarchies.length > 0 && (
<>
<div className='inputLabelWrapperMain'> <div className='inputLabelWrapperMain'>
<label <label
className='form-label labelMain' className='form-label labelMain'
@ -121,6 +140,51 @@ export const CategoryFilterPopup = ({
</label> </label>
<p className='labelDescriptionMain'>Maybe</p> <p className='labelDescriptionMain'>Maybe</p>
</div> </div>
<div
className='inputMain'
style={{
minHeight: '40px',
maxHeight: '500px',
height: '100%',
overflow: 'auto'
}}
>
{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 className='inputLabelWrapperMain'>
<div <div
@ -139,8 +203,12 @@ export const CategoryFilterPopup = ({
className='dropdown-item dropdownMainMenuItem' className='dropdown-item dropdownMainMenuItem'
onClick={handleAddNew} onClick={handleAddNew}
> >
Search for "{inputValue}" category Add and search for "{inputValue}" category
<button className='btn btnMain btnMainInsideField btnMainRemove'> <button
type='button'
className='btn btnMain btnMainInsideField btnMainAdd'
title='Add'
>
<svg <svg
xmlns='http://www.w3.org/2000/svg' xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512' viewBox='-32 0 512 512'
@ -153,10 +221,10 @@ export const CategoryFilterPopup = ({
</button> </button>
</div> </div>
</div> </div>
{(categoriesData as Category[]).map((category) => ( {(categoriesData as Category[]).map((category, i) => (
<CategoryCheckbox <CategoryCheckbox
key={`${category.name}_${i}`}
inputValue={inputValue} inputValue={inputValue}
key={category.name}
category={category} category={category}
path={[category.name]} path={[category.name]}
handleSingleSelection={handleSingleSelection} handleSingleSelection={handleSingleSelection}
@ -221,6 +289,7 @@ interface CategoryCheckboxProps {
selectedSingles: string[] selectedSingles: string[]
selectedCombinations: string[] selectedCombinations: string[]
indentLevel?: number indentLevel?: number
handleRemove?: (path: string[]) => void
} }
const CategoryCheckbox: React.FC<CategoryCheckboxProps> = ({ const CategoryCheckbox: React.FC<CategoryCheckboxProps> = ({
@ -231,7 +300,8 @@ const CategoryCheckbox: React.FC<CategoryCheckboxProps> = ({
handleCombinationSelection, handleCombinationSelection,
selectedSingles, selectedSingles,
selectedCombinations, selectedCombinations,
indentLevel = 0 indentLevel = 0,
handleRemove
}) => { }) => {
const name = typeof category === 'string' ? category : category.name const name = typeof category === 'string' ? category : category.name
const isMatching = path const isMatching = path
@ -330,6 +400,24 @@ const CategoryCheckbox: React.FC<CategoryCheckboxProps> = ({
checked={isSingleChecked} checked={isSingleChecked}
onChange={handleSingleChange} 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>
</div> </div>
)} )}
@ -350,6 +438,11 @@ const CategoryCheckbox: React.FC<CategoryCheckboxProps> = ({
selectedSingles={selectedSingles} selectedSingles={selectedSingles}
selectedCombinations={selectedCombinations} selectedCombinations={selectedCombinations}
indentLevel={indentLevel + 1} indentLevel={indentLevel + 1}
{...(typeof handleRemove === 'function'
? {
handleRemove
}
: {})}
/> />
) )
} else { } else {
@ -364,6 +457,11 @@ const CategoryCheckbox: React.FC<CategoryCheckboxProps> = ({
selectedSingles={selectedSingles} selectedSingles={selectedSingles}
selectedCombinations={selectedCombinations} selectedCombinations={selectedCombinations}
indentLevel={indentLevel + 1} indentLevel={indentLevel + 1}
{...(typeof handleRemove === 'function'
? {
handleRemove
}
: {})}
/> />
) )
} }

View File

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

View File

@ -91,8 +91,38 @@ export const GamePage = () => {
return true return true
} }
// If search term is missing, only filter by sources const filterCategoryFn = (mod: ModDetails) => {
if (searchTerm === '') return mods.filter(filterSourceFn) // 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() const lowerCaseSearchTerm = searchTerm.toLowerCase()
@ -105,8 +135,15 @@ export const GamePage = () => {
tag.toLowerCase().includes(lowerCaseSearchTerm) tag.toLowerCase().includes(lowerCaseSearchTerm)
) > -1 ) > -1
return mods.filter(filterFn).filter(filterSourceFn) return mods.filter(filterFn).filter(filterSourceFn).filter(filterCategoryFn)
}, [filterOptions.source, mods, searchTerm]) }, [
categories,
filterOptions.source,
hierarchies,
linkedHierarchy,
mods,
searchTerm
])
const filteredModList = useFilteredMods( const filteredModList = useFilteredMods(
filteredMods, filteredMods,
@ -132,24 +169,13 @@ export const GamePage = () => {
} }
useEffect(() => { useEffect(() => {
if (!gameName) return
const filter: NDKFilter = { const filter: NDKFilter = {
kinds: [NDKKind.Classified], kinds: [NDKKind.Classified],
'#t': [T_TAG_VALUE] '#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, { const subscription = ndk.subscribe(filter, {
cacheUsage: NDKSubscriptionCacheUsage.PARALLEL, cacheUsage: NDKSubscriptionCacheUsage.PARALLEL,
closeOnEose: true closeOnEose: true
@ -173,7 +199,7 @@ export const GamePage = () => {
return () => { return () => {
subscription.stop() subscription.stop()
} }
}, [gameName, ndk, linkedHierarchy, categories, hierarchies]) }, [gameName, ndk])
if (!gameName) return null 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 */ /* the 4 classes below here are a temp fix for the games dropdown stylings */
/* add an exception (not category) for normal dropdown - due !important */
.dropdownMainMenu.dropdownMainMenuAlt { .dropdownMainMenu.dropdownMainMenuAlt:not(.category) {
max-height: unset !important; max-height: unset !important;
} }
.dropdownMainMenu.dropdownMainMenuAlt > div { .dropdownMainMenu.dropdownMainMenuAlt:not(.category) > div {
height: unset !important; height: unset !important;
} }
.dropdownMainMenu.dropdownMainMenuAlt > div > div { .dropdownMainMenu.dropdownMainMenuAlt:not(.category) > div > div {
height: unset !important; height: unset !important;
width: 100% !important; width: 100% !important;
display: flex; display: flex;
@ -291,7 +291,7 @@ h6 {
padding: 5px; padding: 5px;
} }
.dropdownMainMenu.dropdownMainMenuAlt > div > div > div { .dropdownMainMenu.dropdownMainMenuAlt:not(.category) > div > div > div {
position: relative !important; position: relative !important;
left: unset !important; left: unset !important;
top: unset !important; top: unset !important;

View File

@ -25,3 +25,70 @@ const flattenCategories = (
export const getCategories = () => { export const getCategories = () => {
return flattenCategories(categoriesData) 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)
}
}