diff --git a/src/assets/categories/categories.json b/src/assets/categories/categories.json
new file mode 100644
index 0000000..2948280
--- /dev/null
+++ b/src/assets/categories/categories.json
@@ -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"
+]
diff --git a/src/components/ModForm.tsx b/src/components/ModForm.tsx
index ea54912..4fee8a0 100644
--- a/src/components/ModForm.tsx
+++ b/src/components/ModForm.tsx
@@ -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}
/>
+
@@ -532,7 +554,6 @@ export const ModForm = ({ existingModData }: ModFormProps) => {
)}
))}
-
{formState.downloadUrls.length === 0 &&
formErrors.downloadUrls &&
formErrors.downloadUrls[0] && (
@@ -878,3 +899,227 @@ const GameDropdown = ({
)
}
+
+interface CategoryAutocompleteProps {
+ lTags: string[]
+ LTags: string[]
+ setFormState: (value: React.SetStateAction
) => 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(initialCategories)
+ const [inputValue, setInputValue] = useState('')
+ const [filteredOptions, setFilteredOptions] = useState()
+
+ 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((prev, cat) => [...prev, ...cat.l], [])
+ )
+ const concatenatedValue = Array.from(uniqueValues)
+ return concatenatedValue
+ }
+ const getSelectedHeirarchy = (cats: Categories[]) => {
+ const heirarchies = cats.reduce(
+ (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) => {
+ 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 (
+
+
+
You can select multiple categories
+
+
+
+
+
+
+ {filteredOptions && filteredOptions.length > 0 ? (
+
+ {({ index, style }) => (
+ handleSelect(filteredOptions[index])}
+ >
+ {capitalizeEachWord(filteredOptions[index].hierarchy)}
+ {selectedCategories.some(
+ (cat) =>
+ cat.hierarchy === filteredOptions[index].hierarchy
+ ) && (
+
+ )}
+
+ )}
+
+ ) : (
+
+ {({ index, style }) => (
+
+ {inputValue &&
+ !filteredOptions?.find(
+ (option) =>
+ option.hierarchy.toLowerCase() ===
+ inputValue.toLowerCase()
+ ) ? (
+ <>
+ Add "{inputValue}"
+
+ >
+ ) : (
+ <>No matches>
+ )}
+
+ )}
+
+ )}
+
+
+
+ {LTags.length > 0 && (
+
+ {LTags.map((hierarchy) => {
+ const heirarchicalCategories = hierarchy.split(`:`)
+ const categories = heirarchicalCategories
+ .map
((c: string) => (
+
+ {capitalizeEachWord(c)}
+
+ ))
+ .reduce((prev, curr) => [
+ prev,
+ ,
+ curr
+ ])
+
+ return (
+
+ {categories}
+
+ )
+ })}
+
+ )}
+
+ )
+}
diff --git a/src/pages/mod/index.tsx b/src/pages/mod/index.tsx
index cc77765..82587fc 100644
--- a/src/pages/mod/index.tsx
+++ b/src/pages/mod/index.tsx
@@ -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}
))}
+
+ {LTags.length > 0 && (
+
+ {LTags.map((hierarchy) => {
+ const heirarchicalCategories = hierarchy.split(`:`)
+ const categories = heirarchicalCategories
+ .map
((c: string) => (
+
+ {c}
+
+ ))
+ .reduce((prev, curr) => [
+ prev,
+ ,
+ curr
+ ])
+
+ return (
+
+ {categories}
+
+ )
+ })}
+
+ )}
diff --git a/src/pages/search.tsx b/src/pages/search.tsx
index 683696f..4901f1e 100644
--- a/src/pages/search.tsx
+++ b/src/pages/search.tsx
@@ -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 && (
)}
+
+ {searchKind === SearchKindEnum.Categories && (
+
+ )}
@@ -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()
+
+ 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 (
+
+
+ {mods?.map((mod) => (
+
+ ))}
+
+
+ )
+}
diff --git a/src/types/category.ts b/src/types/category.ts
new file mode 100644
index 0000000..4d871f8
--- /dev/null
+++ b/src/types/category.ts
@@ -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[]
+}
diff --git a/src/types/index.ts b/src/types/index.ts
index 8fe37df..d26ffe0 100644
--- a/src/types/index.ts
+++ b/src/types/index.ts
@@ -4,3 +4,4 @@ export * from './nostr'
export * from './user'
export * from './zap'
export * from './blog'
+export * from './category'
diff --git a/src/types/mod.ts b/src/types/mod.ts
index bc27e18..c520ac3 100644
--- a/src/types/mod.ts
+++ b/src/types/mod.ts
@@ -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[]
}
diff --git a/src/utils/category.ts b/src/utils/category.ts
new file mode 100644
index 0000000..6aef63f
--- /dev/null
+++ b/src/utils/category.ts
@@ -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((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)
+}
diff --git a/src/utils/index.ts b/src/utils/index.ts
index 91fe37b..f6de84d 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -7,3 +7,4 @@ export * from './localStorage'
export * from './consts'
export * from './blog'
export * from './curationSets'
+export * from './category'
diff --git a/src/utils/mod.ts b/src/utils/mod.ts
index 24b59bd..07f34a0 100644
--- a/src/utils/mod.ts
+++ b/src/utils/mod.ts
@@ -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: '',
diff --git a/src/utils/utils.ts b/src/utils/utils.ts
index 4658496..bb740e5 100644
--- a/src/utils/utils.ts
+++ b/src/utils/utils.ts
@@ -156,3 +156,7 @@ export const parseFormData = (formData: FormData) => {
return result
}
+
+export const capitalizeEachWord = (str: string): string => {
+ return str.replace(/\b\w/g, (char) => char.toUpperCase())
+}