import _ from 'lodash' import { Event, kinds, nip19, UnsignedEvent } from 'nostr-tools' import Papa from 'papaparse' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'react-toastify' import { FixedSizeList as List } from 'react-window' import { v4 as uuidv4 } from 'uuid' import { useAppSelector } from '../hooks' import '../styles/styles.css' import { isReachable, isValidImageUrl, isValidUrl, log, LogType, now } from '../utils' import { CheckboxField, InputError, InputField } from './Inputs' import { RelayController } from '../controllers' import { useNavigate } from 'react-router-dom' import { getModsInnerPageRoute } from '../routes' import { DownloadUrl, FormState } from '../types' import { LoadingSpinner } from './LoadingSpinner' interface FormErrors { game?: string title?: string body?: string featuredImageUrl?: string summary?: string nsfw?: string screenshotsUrls?: string[] tags?: string downloadUrls?: string[] } interface GameOption { value: string label: string } let processedCSV = false export const ModForm = () => { const navigate = useNavigate() const userState = useAppSelector((state) => state.user) const [isPublishing, setIsPublishing] = useState(false) const [gameOptions, setGameOptions] = useState([]) const [formState, setFormState] = useState({ game: '', title: '', body: '', featuredImageUrl: '', summary: '', nsfw: false, screenshotsUrls: [''], tags: '', downloadUrls: [ { url: '', hash: '', signatureKey: '', malwareScanLink: '', modVersion: '', customNote: '' } ] }) const [formErrors, setFormErrors] = useState({}) useEffect(() => { if (processedCSV) return processedCSV = true // Fetch the CSV file from the public folder fetch('/assets/games.csv') .then((response) => response.text()) .then((csvText) => { // Parse the CSV text using PapaParse Papa.parse<{ 'Game Name': string '16 by 9 image': string 'Boxart image': string }>(csvText, { worker: true, header: true, complete: (results) => { const options = results.data.map((row) => ({ label: row['Game Name'], value: row['Game Name'] })) setGameOptions(options) } }) }) .catch((error) => console.error('Error fetching CSV file:', error)) }, []) const handleInputChange = useCallback((name: string, value: string) => { setFormState((prevState) => ({ ...prevState, [name]: value })) }, []) const handleCheckboxChange = useCallback( (e: React.ChangeEvent) => { const { name, checked } = e.target setFormState((prevState) => ({ ...prevState, [name]: checked })) }, [] ) const addScreenshotUrl = useCallback(() => { setFormState((prevState) => ({ ...prevState, screenshotsUrls: [...prevState.screenshotsUrls, ''] })) }, []) const removeScreenshotUrl = useCallback((index: number) => { setFormState((prevState) => ({ ...prevState, screenshotsUrls: prevState.screenshotsUrls.filter((_, i) => i !== index) })) }, []) const handleScreenshotUrlChange = useCallback( (index: number, value: string) => { setFormState((prevState) => ({ ...prevState, screenshotsUrls: [ ...prevState.screenshotsUrls.map((url, i) => { if (index === i) return value return url }) ] })) }, [] ) const addDownloadUrl = useCallback(() => { setFormState((prevState) => ({ ...prevState, downloadUrls: [ ...prevState.downloadUrls, { url: '', hash: '', signatureKey: '', malwareScanLink: '', modVersion: '', customNote: '' } ] })) }, []) const removeDownloadUrl = useCallback((index: number) => { setFormState((prevState) => ({ ...prevState, downloadUrls: prevState.downloadUrls.filter((_, i) => i !== index) })) }, []) const handleDownloadUrlChange = useCallback( (index: number, field: keyof DownloadUrl, value: string) => { setFormState((prevState) => ({ ...prevState, downloadUrls: [ ...prevState.downloadUrls.map((url, i) => { if (index === i) url[field] = value return url }) ] })) }, [] ) const handlePublish = async () => { setIsPublishing(true) let hexPubkey: string if (userState.isAuth && userState.user?.pubkey) { hexPubkey = userState.user.pubkey as string } else { hexPubkey = (await window.nostr?.getPublicKey()) as string } if (!hexPubkey) { toast.error('Could not get pubkey') return } if (!(await validateState())) { setIsPublishing(false) return } const uuid = uuidv4() const currentTimeStamp = now() const unsignedEvent: UnsignedEvent = { kind: kinds.ClassifiedListing, created_at: currentTimeStamp, pubkey: hexPubkey, content: formState.body, tags: [ ['d', uuid], ['t', window.location.host], ['published_at', currentTimeStamp.toString()], ['game', formState.game], ['title', formState.title], ['featuredImageUrl', formState.featuredImageUrl], ['summary', formState.summary], ['nsfw', formState.nsfw.toString()], ['screenshotsUrls', ...formState.screenshotsUrls], ['tags', ...formState.tags.split(',')], [ 'downloadUrls', ...formState.downloadUrls.map((downloadUrl) => JSON.stringify(downloadUrl) ) ] ] } const signedEvent = await window.nostr ?.signEvent(unsignedEvent) .then((event) => event as Event) .catch((err) => { toast.error('Failed to sign the event!') log(true, LogType.Error, 'Failed to sign the event!', err) return null }) if (!signedEvent) { setIsPublishing(false) return } const publishedOnRelays = await RelayController.getInstance().publish( signedEvent as Event ) // Handle cases where publishing failed or succeeded if (publishedOnRelays.length === 0) { toast.error('Failed to publish event on any relay') } else { toast.success( `Event published successfully to the following relays\n\n${publishedOnRelays.join( '\n' )}` ) const nevent = nip19.neventEncode({ id: signedEvent.id, author: signedEvent.pubkey, kind: signedEvent.kind, relays: publishedOnRelays }) navigate(getModsInnerPageRoute(nevent)) } setIsPublishing(false) } const validateState = async (): Promise => { const errors: FormErrors = {} if (formState.game === '') { errors.game = 'Game field can not be empty' } if (formState.title === '') { errors.title = 'Title field can not be empty' } if (formState.body === '') { errors.body = 'Body field can not be empty' } if (formState.featuredImageUrl === '') { errors.featuredImageUrl = 'FeaturedImageUrl field can not be empty' } else if ( !isValidImageUrl(formState.featuredImageUrl) || !(await isReachable(formState.featuredImageUrl)) ) { errors.featuredImageUrl = 'FeaturedImageUrl must be a valid and reachable image URL' } if (formState.summary === '') { errors.summary = 'Summary field can not be empty' } if (formState.screenshotsUrls.length === 0) { errors.screenshotsUrls = ['Required at least one screenshot url'] } else { for (let i = 0; i < formState.screenshotsUrls.length; i++) { const url = formState.screenshotsUrls[i] if ( !isValidUrl(url) || !isValidImageUrl(url) || !(await isReachable(url)) ) { if (!errors.screenshotsUrls) errors.screenshotsUrls = Array(formState.screenshotsUrls.length) errors.screenshotsUrls![i] = 'All screenshot URLs must be valid and reachable image URLs' } } } if (formState.tags === '') { errors.tags = 'Tags field can not be empty' } if (formState.downloadUrls.length === 0) { errors.downloadUrls = ['Required at least one download url'] } else { for (let i = 0; i < formState.downloadUrls.length; i++) { const downloadUrl = formState.downloadUrls[i] if ( !isValidUrl(downloadUrl.url) || !(await isReachable(downloadUrl.url)) ) { if (!errors.downloadUrls) errors.downloadUrls = Array(formState.downloadUrls.length) errors.downloadUrls![i] = 'Download url must be valid and reachable' } } } setFormErrors(errors) return Object.keys(errors).length === 0 } return ( <> {isPublishing && }

We recommend to upload images to https://nostr.build/

{formState.screenshotsUrls.map((url, index) => ( <> {formErrors.screenshotsUrls && formErrors.screenshotsUrls[index] && ( )} ))} {formState.screenshotsUrls.length === 0 && formErrors.screenshotsUrls && formErrors.screenshotsUrls[0] && ( )}

You can upload your game mod to Github, as an example, and keep updating it there. Also, it's advisable that you hash your package as well with your nostr public key.

{formState.downloadUrls.map((download, index) => ( <> {formErrors.downloadUrls && formErrors.downloadUrls[index] && ( )} ))} {formState.downloadUrls.length === 0 && formErrors.downloadUrls && formErrors.downloadUrls[0] && ( )}
) } type DownloadUrlFieldsProps = { index: number url: string hash: string signatureKey: string malwareScanLink: string modVersion: string customNote: string onUrlChange: (index: number, field: keyof DownloadUrl, value: string) => void onRemove: (index: number) => void } const DownloadUrlFields = React.memo( ({ index, url, hash, signatureKey, malwareScanLink, modVersion, customNote, onUrlChange, onRemove }: DownloadUrlFieldsProps) => { const handleChange = (e: React.ChangeEvent) => { const { name, value } = e.target onUrlChange(index, name as keyof DownloadUrl, value) } return (
) } ) type ScreenshotUrlFieldsProps = { index: number url: string onUrlChange: (index: number, value: string) => void onRemove: (index: number) => void } const ScreenshotUrlFields = React.memo( ({ index, url, onUrlChange, onRemove }: ScreenshotUrlFieldsProps) => { const handleChange = (e: React.ChangeEvent) => { onUrlChange(index, e.target.value) } return (
) } ) type GameDropdownProps = { options: GameOption[] selected: string error?: string onChange: (name: string, value: string) => void } const GameDropdown = ({ options, selected, error, onChange }: GameDropdownProps) => { const [searchTerm, setSearchTerm] = useState('') const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('') const inputRef = useRef(null) const isOptionClicked = useRef(false) const handleSearchChange = useCallback((value: string) => { setSearchTerm(value) _.debounce(() => { setDebouncedSearchTerm(value) }, 300)() }, []) const filteredOptions = useMemo(() => { if (debouncedSearchTerm === '') return [] else { return options.filter((option) => option.label.toLowerCase().includes(debouncedSearchTerm.toLowerCase()) ) } }, [debouncedSearchTerm, options]) return (

Can't find the game you're looking for? Send us a DM mentioning it so we can add it.

handleSearchChange(e.target.value)} onBlur={() => { if (!isOptionClicked.current) { setSearchTerm('') } isOptionClicked.current = false }} />
{({ index, style }) => (
(isOptionClicked.current = true)} onClick={() => { onChange('game', filteredOptions[index].value) setSearchTerm('') if (inputRef.current) { inputRef.current.blur() } }} > {filteredOptions[index].label}
)}
{error && }
) }