feat: categories and popups #171

Merged
enes merged 22 commits from 116-categories into staging 2024-12-12 16:37:38 +00:00
11 changed files with 409 additions and 4 deletions
Showing only changes of commit 3b2dce54c5 - Show all commits

View File

@ -0,0 +1,24 @@
[
{
"name": "audio",
"sub": [
{ "name": "music", "sub": ["background", "ambient"] },
{ "name": "sound effects", "sub": ["footsteps", "weapons"] },
"voice"
]
},
{
"name": "graphical",
"sub": [
{
"name": "textures",
"sub": ["highres textures", "lowres textures"]
},
"models",
"shaders"
]
},
{ "name": "user interface", "sub": ["hud", "menus", "icons"] },
{ "name": "gameplay", "sub": ["mechanics", "balance", "ai"] },
"bugfixes"
]

View File

@ -16,8 +16,10 @@ import { T_TAG_VALUE } from '../constants'
import { useAppSelector, useGames, useNDKContext } from '../hooks'
import { appRoutes, getModPageRoute } from '../routes'
import '../styles/styles.css'
import { DownloadUrl, ModDetails, ModFormState } from '../types'
import { Categories, DownloadUrl, ModDetails, ModFormState } from '../types'
import {
capitalizeEachWord,
getCategories,
initializeFormState,
isReachable,
isValidImageUrl,
@ -231,6 +233,21 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
tags.push(['originalAuthor', formState.originalAuthor])
}
// Prepend com.degmods to avoid leaking categories to 3rd party client's search
// Add heirarchical namespaces labels
if (formState.LTags.length > 0) {
for (let i = 0; i < formState.LTags.length; i++) {
tags.push(['L', `com.degmods:${formState.LTags[i]}`])
}
}
// Add category labels
if (formState.lTags.length > 0) {
for (let i = 0; i < formState.lTags.length; i++) {
tags.push(['l', `com.degmods:${formState.lTags[i]}`])
}
}
const unsignedEvent: UnsignedEvent = {
kind: kinds.ClassifiedListing,
created_at: currentTimeStamp,
@ -489,6 +506,11 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
error={formErrors.tags}
onChange={handleInputChange}
/>
<CategoryAutocomplete
lTags={formState.lTags}
LTags={formState.LTags}
setFormState={setFormState}
/>
<div className='inputLabelWrapperMain'>
<div className='labelWrapperMain'>
<label className='form-label labelMain'>Download URLs</label>
@ -532,7 +554,6 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
)}
</Fragment>
))}
{formState.downloadUrls.length === 0 &&
formErrors.downloadUrls &&
formErrors.downloadUrls[0] && (
@ -878,3 +899,227 @@ const GameDropdown = ({
</div>
)
}
interface CategoryAutocompleteProps {
lTags: string[]
LTags: string[]
setFormState: (value: React.SetStateAction<ModFormState>) => void
}
export const CategoryAutocomplete = ({
lTags,
LTags,
setFormState
}: CategoryAutocompleteProps) => {
const flattenedCategories = getCategories()
const initialCategories = lTags.map((name) => {
const existingCategory = flattenedCategories.find(
(cat) => cat.name === name
)
if (existingCategory) {
return existingCategory
} else {
return { name, hierarchy: name, l: [name] }
}
})
const [selectedCategories, setSelectedCategories] =
useState<Categories[]>(initialCategories)
const [inputValue, setInputValue] = useState<string>('')
const [filteredOptions, setFilteredOptions] = useState<Categories[]>()
useEffect(() => {
const newFilteredOptions = flattenedCategories.filter((option) =>
option.hierarchy.toLowerCase().includes(inputValue.toLowerCase())
)
setFilteredOptions(newFilteredOptions)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inputValue])
const getSelectedCategories = (cats: Categories[]) => {
const uniqueValues = new Set(
cats.reduce<string[]>((prev, cat) => [...prev, ...cat.l], [])
)
const concatenatedValue = Array.from(uniqueValues)
return concatenatedValue
}
const getSelectedHeirarchy = (cats: Categories[]) => {
const heirarchies = cats.reduce<string[]>(
(prev, cat) => [...prev, cat.hierarchy.replace(/ > /g, ':')],
[]
)
const concatenatedValue = Array.from(heirarchies)
return concatenatedValue
}
const handleReset = () => {
setSelectedCategories([])
setInputValue('')
}
const handleRemove = (option: Categories) => {
setSelectedCategories(
selectedCategories.filter((cat) => cat.hierarchy !== option.hierarchy)
)
}
const handleSelect = (option: Categories) => {
if (!selectedCategories.some((cat) => cat.hierarchy === option.hierarchy)) {
setSelectedCategories([...selectedCategories, option])
}
setInputValue('')
}
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setInputValue(e.target.value)
}
const handleAddNew = () => {
if (inputValue) {
const newOption: Categories = {
name: inputValue,
hierarchy: inputValue,
l: [inputValue]
}
setSelectedCategories([...selectedCategories, newOption])
setInputValue('')
}
}
useEffect(() => {
setFormState((prevState) => ({
...prevState,
['lTags']: getSelectedCategories(selectedCategories),
['LTags']: getSelectedHeirarchy(selectedCategories)
}))
}, [selectedCategories, setFormState])
return (
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain'>Categories</label>
<p className='labelDescriptionMain'>You can select multiple categories</p>
<div className='dropdown dropdownMain'>
<div className='inputWrapperMain inputWrapperMainAlt'>
<input
type='text'
className='inputMain inputMainWithBtn dropdown-toggle'
placeholder='Select some categories...'
aria-expanded='false'
data-bs-toggle='dropdown'
value={inputValue}
onChange={handleInputChange}
/>
<button
className='btn btnMain btnMainInsideField btnMainRemove'
type='button'
onClick={handleReset}
>
<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 className='dropdown-menu dropdownMainMenu dropdownMainMenuAlt'>
{filteredOptions && filteredOptions.length > 0 ? (
<List
height={500}
width={'100%'}
itemCount={filteredOptions.length}
itemSize={35}
>
{({ index, style }) => (
<div
style={style}
className='dropdown-item dropdownMainMenuItem'
onClick={() => handleSelect(filteredOptions[index])}
>
{capitalizeEachWord(filteredOptions[index].hierarchy)}
{selectedCategories.some(
(cat) =>
cat.hierarchy === filteredOptions[index].hierarchy
) && (
<button
className='btn btnMain btnMainInsideField btnMainRemove'
onClick={() => handleRemove(filteredOptions[index])}
>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M323.3 32.01H188.7C172.3 32.01 160 44.31 160 60.73V96.01H32C14.33 96.01 0 110.3 0 128S14.33 160 32 160H480c17.67 0 32-14.33 32-32.01S497.7 96.01 480 96.01H352v-35.28C352 44.31 339.7 32.01 323.3 32.01zM64.9 477.5C66.5 492.3 79.31 504 94.72 504H417.3c15.41 0 28.22-11.72 29.81-26.5L480 192.2H32L64.9 477.5z'></path>
</svg>
</button>
)}
</div>
)}
</List>
) : (
<List height={500} width={'100%'} itemCount={1} itemSize={35}>
{({ index, style }) => (
<div
style={style}
className='dropdown-item dropdownMainMenuItem'
onClick={handleAddNew}
>
{inputValue &&
!filteredOptions?.find(
(option) =>
option.hierarchy.toLowerCase() ===
inputValue.toLowerCase()
) ? (
<>
Add "{inputValue}"
<button className='btn btnMain btnMainInsideField btnMainRemove'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M432 256c0 17.69-14.33 32.01-32 32.01H256v144c0 17.69-14.33 31.99-32 31.99s-32-14.3-32-31.99v-144H48c-17.67 0-32-14.32-32-32.01s14.33-31.99 32-31.99H192v-144c0-17.69 14.33-32.01 32-32.01s32 14.32 32 32.01v144h144C417.7 224 432 238.3 432 256z'></path>
</svg>
</button>
</>
) : (
<>No matches</>
)}
</div>
)}
</List>
)}
</div>
</div>
</div>
{LTags.length > 0 && (
<div className='IBMSMSMBSSCategories'>
{LTags.map((hierarchy) => {
const heirarchicalCategories = hierarchy.split(`:`)
const categories = heirarchicalCategories
.map<React.ReactNode>((c: string) => (
<a className='IBMSMSMBSSCategoriesBoxItem'>
<p>{capitalizeEachWord(c)}</p>
</a>
))
.reduce((prev, curr) => [
prev,
<div className='IBMSMSMBSSCategoriesBoxSeparator'>
<p>&gt;</p>
</div>,
curr
])
return (
<div key={hierarchy} className='IBMSMSMBSSCategoriesBox'>
{categories}
</div>
)
})}
</div>
)}
</div>
)
}

View File

@ -105,6 +105,8 @@ export const ModPage = () => {
body={mod.body}
screenshotsUrls={mod.screenshotsUrls}
tags={mod.tags}
LTags={mod.LTags}
lTags={mod.lTags}
nsfw={mod.nsfw}
repost={mod.repost}
originalAuthor={mod.originalAuthor}
@ -426,6 +428,8 @@ type BodyProps = {
body: string
screenshotsUrls: string[]
tags: string[]
LTags: string[]
lTags: string[]
nsfw: boolean
repost: boolean
originalAuthor?: string
@ -437,6 +441,8 @@ const Body = ({
body,
screenshotsUrls,
tags,
LTags,
lTags,
nsfw,
repost,
originalAuthor
@ -532,6 +538,33 @@ const Body = ({
{tag}
</a>
))}
{LTags.length > 0 && (
<div className='IBMSMSMBSSCategories'>
{LTags.map((hierarchy) => {
const heirarchicalCategories = hierarchy.split(`:`)
const categories = heirarchicalCategories
.map<React.ReactNode>((c: string) => (
<a className='IBMSMSMBSSCategoriesBoxItem'>
<p>{c}</p>
</a>
))
.reduce((prev, curr) => [
prev,
<div className='IBMSMSMBSSCategoriesBoxSeparator'>
<p>&gt;</p>
</div>,
curr
])
return (
<div key={hierarchy} className='IBMSMSMBSSCategoriesBox'>
{categories}
</div>
)
})}
</div>
)}
</div>
</div>
</div>

View File

@ -43,7 +43,8 @@ import { useCuratedSet } from 'hooks/useCuratedSet'
enum SearchKindEnum {
Mods = 'Mods',
Games = 'Games',
Users = 'Users'
Users = 'Users',
Categories = 'Categories'
}
export const SearchPage = () => {
@ -132,6 +133,10 @@ export const SearchPage = () => {
{searchKind === SearchKindEnum.Games && (
<GamesResult searchTerm={searchTerm} />
)}
{searchKind === SearchKindEnum.Categories && (
<CategoriesResult searchTerm={searchTerm} />
)}
</div>
</div>
</div>
@ -538,3 +543,44 @@ function dedup(event1: NDKEvent, event2: NDKEvent) {
return event2
}
interface CategoriesResultProps {
searchTerm: string
}
const CategoriesResult = ({ searchTerm }: CategoriesResultProps) => {
const { ndk } = useNDKContext()
const [mods, setMods] = useState<ModDetails[]>()
useEffect(() => {
const call = async () => {
const filter: NDKFilter = {
kinds: [NDKKind.Classified],
'#l': [`com.degmods:${searchTerm}`]
}
const ndkEventSet = await ndk.fetchEvents(filter)
const events = Array.from(ndkEventSet)
const mods: ModDetails[] = []
events.map((e) => {
if (isModDataComplete(e)) {
const mod = extractModData(e)
mods.push(mod)
}
})
setMods(mods)
}
call()
}, [ndk, searchTerm])
return (
<div className='IBMSecMain IBMSMListWrapper'>
<div className='IBMSMList'>
{mods?.map((mod) => (
<ModCard key={mod.id} {...mod} />
))}
</div>
</div>
)
}

12
src/types/category.ts Normal file
View File

@ -0,0 +1,12 @@
export interface Category {
name: string
sub?: (Category | string)[]
}
export type CategoriesData = Category[]
export interface Categories {
name: string
hierarchy: string
l: string[]
}

View File

@ -4,3 +4,4 @@ export * from './nostr'
export * from './user'
export * from './zap'
export * from './blog'
export * from './category'

View File

@ -31,6 +31,10 @@ export interface ModFormState {
originalAuthor?: string
screenshotsUrls: string[]
tags: string
/** Hierarchical labels */
LTags: string[]
/** Category labels for category search */
lTags: string[]
downloadUrls: DownloadUrl[]
}

27
src/utils/category.ts Normal file
View File

@ -0,0 +1,27 @@
import { Categories, Category } from 'types/category'
import categoriesData from './../assets/categories/categories.json'
const flattenCategories = (
categories: (Category | string)[],
parentPath: string[] = []
): Categories[] => {
return categories.flatMap<Categories, Category | string>((cat) => {
if (typeof cat === 'string') {
const path = [...parentPath, cat]
const hierarchy = path.join(' > ')
return [{ name: cat, hierarchy, l: path }]
} else {
const path = [...parentPath, cat.name]
const hierarchy = path.join(' > ')
if (cat.sub) {
const obj: Categories = { name: cat.name, hierarchy, l: path }
return [obj].concat(flattenCategories(cat.sub, path))
}
return [{ name: cat.name, hierarchy, l: path }]
}
})
}
export const getCategories = () => {
return flattenCategories(categoriesData)
}

View File

@ -7,3 +7,4 @@ export * from './localStorage'
export * from './consts'
export * from './blog'
export * from './curationSets'
export * from './category'

View File

@ -1,7 +1,7 @@
import { NDKEvent } from '@nostr-dev-kit/ndk'
import { Event } from 'nostr-tools'
import { ModDetails, ModFormState } from '../types'
import { getTagValue } from './nostr'
import { getTagValue, getTagValues } from './nostr'
/**
* Extracts and normalizes mod data from an event.
@ -43,6 +43,12 @@ export const extractModData = (event: Event | NDKEvent): ModDetails => {
originalAuthor: getFirstTagValue('originalAuthor'),
screenshotsUrls: getTagValue(event, 'screenshotsUrls') || [],
tags: getTagValue(event, 'tags') || [],
LTags: (getTagValues(event, 'L') || []).map((t) =>
t.replace('com.degmods:', '')
),
lTags: (getTagValues(event, 'l') || []).map((t) =>
t.replace('com.degmods:', '')
),
downloadUrls: (getTagValue(event, 'downloadUrls') || []).map((item) =>
JSON.parse(item)
)
@ -124,6 +130,8 @@ export const initializeFormState = (
originalAuthor: existingModData?.originalAuthor || undefined,
screenshotsUrls: existingModData?.screenshotsUrls || [''],
tags: existingModData?.tags.join(',') || '',
lTags: existingModData?.lTags || [],
LTags: existingModData?.LTags || [],
downloadUrls: existingModData?.downloadUrls || [
{
url: '',

View File

@ -156,3 +156,7 @@ export const parseFormData = <T>(formData: FormData) => {
return result
}
export const capitalizeEachWord = (str: string): string => {
return str.replace(/\b\w/g, (char) => char.toUpperCase())
}