From 3b2dce54c5b36deba62c60de42a6af481912e256 Mon Sep 17 00:00:00 2001 From: enes Date: Tue, 3 Dec 2024 10:47:55 +0100 Subject: [PATCH] feat: initial categories --- src/assets/categories/categories.json | 24 +++ src/components/ModForm.tsx | 249 +++++++++++++++++++++++++- src/pages/mod/index.tsx | 33 ++++ src/pages/search.tsx | 48 ++++- src/types/category.ts | 12 ++ src/types/index.ts | 1 + src/types/mod.ts | 4 + src/utils/category.ts | 27 +++ src/utils/index.ts | 1 + src/utils/mod.ts | 10 +- src/utils/utils.ts | 4 + 11 files changed, 409 insertions(+), 4 deletions(-) create mode 100644 src/assets/categories/categories.json create mode 100644 src/types/category.ts create mode 100644 src/utils/category.ts 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()) +}