degmods.com/src/components/ModForm.tsx

694 lines
20 KiB
TypeScript
Raw Normal View History

2024-07-21 21:30:03 +05:00
import _ from 'lodash'
import Papa from 'papaparse'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import 'react-quill/dist/quill.snow.css'
import { FixedSizeList as List } from 'react-window'
import { kinds, UnsignedEvent } from 'nostr-tools'
import { toast } from 'react-toastify'
import { v4 as uuidv4 } from 'uuid'
import { useAppSelector } from '../hooks'
2024-07-12 01:03:52 +05:00
import '../styles/styles.css'
2024-07-21 21:30:03 +05:00
import { now } from '../utils'
import { CheckboxField, InputField } from './Inputs'
interface DownloadUrl {
url: string
hash: string
signatureKey: string
malwareScanLink: string
modVersion: string
customNote: string
}
interface FormState {
game: string
title: string
body: string
featuredImageUrl: string
summary: string
nsfw: boolean
screenshotsUrls: string[]
tags: string
downloadUrls: DownloadUrl[]
}
interface GameOption {
value: string
label: string
}
let processedCSV = false
2024-07-12 01:03:52 +05:00
export const ModForm = () => {
2024-07-21 21:30:03 +05:00
const userState = useAppSelector((state) => state.user)
const [gameOptions, setGameOptions] = useState<GameOption[]>([])
const [formState, setFormState] = useState<FormState>({
game: '',
title: '',
body: '',
featuredImageUrl: '',
summary: '',
nsfw: false,
screenshotsUrls: [''],
tags: '',
downloadUrls: [
{
url: '',
hash: '',
signatureKey: '',
malwareScanLink: '',
modVersion: '',
customNote: ''
}
]
})
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<HTMLInputElement>) => {
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 () => {
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 (!validateState()) return
const uuid = uuidv4()
const unsignedEvent: UnsignedEvent = {
kind: kinds.ClassifiedListing,
created_at: now(),
pubkey: hexPubkey,
content: formState.body,
tags: [
['d', uuid],
// todo: t tag for degmod identifier (use url)
['game', formState.game],
['title', formState.title],
['featuredImageUrl', formState.featuredImageUrl],
['summary', formState.summary],
['nsfw', formState.nsfw.toString()],
['screenshotsUrls', ...formState.screenshotsUrls],
['tags', formState.tags],
[
'downloadUrls',
...formState.downloadUrls.map((downloadUrl) =>
JSON.stringify(downloadUrl)
)
]
]
}
const signedEvent = await window.nostr?.signEvent(unsignedEvent)
console.log('signedEvent :>> ', signedEvent)
// todo: publish event
}
const validateState = () => {
if (formState.game === '') {
toast.error('Game field can not be empty')
return false
}
if (formState.title === '') {
toast.error('Title field can not be empty')
return false
}
if (formState.body === '') {
toast.error('Body field can not be empty')
return false
}
if (formState.featuredImageUrl === '') {
toast.error('FeaturedImageUrl field can not be empty')
return false
}
if (formState.summary === '') {
toast.error('Summary field can not be empty')
return false
}
if (
formState.screenshotsUrls.length === 0 ||
formState.screenshotsUrls.every((url) => url === '')
) {
toast.error('Required at least one screenshot url')
return false
}
if (formState.tags === '') {
toast.error('tags field can not be empty')
return false
}
if (
formState.downloadUrls.length === 0 ||
formState.downloadUrls.every((download) => download.url === '')
) {
toast.error('Required at least one download url')
return false
}
return true
}
2024-07-12 01:03:52 +05:00
return (
<>
2024-07-21 21:30:03 +05:00
<GameDropdown
options={gameOptions}
selected={formState.game}
onChange={handleInputChange}
2024-07-12 01:03:52 +05:00
/>
2024-07-21 21:30:03 +05:00
2024-07-12 01:03:52 +05:00
<InputField
label='Title'
placeholder='Return the banana mod'
name='title'
2024-07-21 21:30:03 +05:00
value={formState.title}
onChange={handleInputChange}
2024-07-12 01:03:52 +05:00
/>
2024-07-21 21:30:03 +05:00
2024-07-12 01:03:52 +05:00
<InputField
label='Body'
2024-07-21 21:30:03 +05:00
type='richtext'
2024-07-12 01:03:52 +05:00
placeholder="Here's what this mod is all about"
name='body'
2024-07-21 21:30:03 +05:00
value={formState.body}
onChange={handleInputChange}
2024-07-12 01:03:52 +05:00
/>
2024-07-21 21:30:03 +05:00
2024-07-12 01:03:52 +05:00
<InputField
label='Featured Image URL'
description='We recommend to upload images to https://nostr.build/'
type='text'
inputMode='url'
placeholder='Image URL'
name='featuredImageUrl'
2024-07-21 21:30:03 +05:00
value={formState.featuredImageUrl}
onChange={handleInputChange}
2024-07-12 01:03:52 +05:00
/>
<InputField
label='Summary'
type='textarea'
placeholder='This is a quick description of my mod'
name='summary'
2024-07-21 21:30:03 +05:00
value={formState.summary}
onChange={handleInputChange}
2024-07-12 01:03:52 +05:00
/>
2024-07-21 21:30:03 +05:00
<CheckboxField
label='This mod not safe for work (NSFW)'
name='nsfw'
isChecked={formState.nsfw}
handleChange={handleCheckboxChange}
2024-07-12 01:03:52 +05:00
/>
2024-07-21 21:30:03 +05:00
<div className='inputLabelWrapperMain'>
<div className='labelWrapperMain'>
<label className='form-label labelMain'>Screenshots URLs</label>
<button
className='btn btnMain btnMainAdd'
type='button'
onClick={addScreenshotUrl}
>
<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>
</div>
<p className='labelDescriptionMain'>
We recommend to upload images to https://nostr.build/
</p>
{formState.screenshotsUrls.map((url, index) => (
<ScreenshotUrlFields
key={index}
index={index}
url={url}
onUrlChange={handleScreenshotUrlChange}
onRemove={removeScreenshotUrl}
/>
))}
</div>
2024-07-12 01:03:52 +05:00
<InputField
label='Tags'
description='Separate each tag with a comma. (Example: tag1, tag2, tag3)'
placeholder='Tags'
name='tags'
2024-07-21 21:30:03 +05:00
value={formState.tags}
onChange={handleInputChange}
2024-07-12 01:03:52 +05:00
/>
<div className='inputLabelWrapperMain'>
<div className='labelWrapperMain'>
<label className='form-label labelMain'>Download URLs</label>
2024-07-21 21:30:03 +05:00
<button
className='btn btnMain btnMainAdd'
type='button'
onClick={addDownloadUrl}
>
2024-07-12 01:03:52 +05:00
<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>
</div>
<p className='labelDescriptionMain'>
You can upload your game mod to Github, as an example, and keep
2024-07-21 21:30:03 +05:00
updating it there. Also, it's advisable that you hash your package as
2024-07-12 01:03:52 +05:00
well with your nostr public key.
</p>
2024-07-21 21:30:03 +05:00
{formState.downloadUrls.map((download, index) => (
<DownloadUrlFields
key={index}
index={index}
download={download}
onUrlChange={handleDownloadUrlChange}
onRemove={removeDownloadUrl}
/>
))}
</div>
<div className='IBMSMSMBS_WriteAction'>
<button className='btn btnMain' type='button' onClick={handlePublish}>
Publish
</button>
2024-07-12 01:03:52 +05:00
</div>
</>
)
}
2024-07-21 21:30:03 +05:00
type DownloadUrlFieldsProps = {
index: number
download: DownloadUrl
onUrlChange: (index: number, field: keyof DownloadUrl, value: string) => void
onRemove: (index: number) => void
}
2024-07-12 01:03:52 +05:00
2024-07-21 21:30:03 +05:00
const DownloadUrlFields = React.memo(
({ index, download, onUrlChange, onRemove }: DownloadUrlFieldsProps) => {
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target
onUrlChange(index, name as keyof DownloadUrl, value)
}
return (
<div className='inputWrapperMainWrapper'>
<div className='inputWrapperMain'>
<input
type='text'
className='inputMain'
name='url'
placeholder='Download URL'
value={download.url}
onChange={handleChange}
/>
<button
className='btn btnMain btnMainRemove'
type='button'
onClick={() => onRemove(index)}
2024-07-12 01:03:52 +05:00
>
2024-07-21 21:30:03 +05:00
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-32 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M135.2 17.69C140.6 6.848 151.7 0 163.8 0H284.2C296.3 0 307.4 6.848 312.8 17.69L320 32H416C433.7 32 448 46.33 448 64C448 81.67 433.7 96 416 96H32C14.33 96 0 81.67 0 64C0 46.33 14.33 32 32 32H128L135.2 17.69zM394.8 466.1C393.2 492.3 372.3 512 346.9 512H101.1C75.75 512 54.77 492.3 53.19 466.1L31.1 128H416L394.8 466.1z'></path>
</svg>
</button>
2024-07-12 01:03:52 +05:00
</div>
2024-07-21 21:30:03 +05:00
<div className='inputWrapperMain'>
<div className='inputWrapperMainBox'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-96 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M320 448c0 17.67-14.31 32-32 32H64c-17.69 0-32-14.33-32-32v-384C32 46.34 46.31 32.01 64 32.01S96 46.34 96 64.01v352h192C305.7 416 320 430.3 320 448z'></path>
</svg>
</div>
<input
type='text'
className='inputMain'
name='hash'
placeholder='SHA-256 Hash'
value={download.hash}
onChange={handleChange}
/>
<div className='inputWrapperMainBox'></div>
</div>
<div className='inputWrapperMain'>
<div className='inputWrapperMainBox'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-96 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M320 448c0 17.67-14.31 32-32 32H64c-17.69 0-32-14.33-32-32v-384C32 46.34 46.31 32.01 64 32.01S96 46.34 96 64.01v352h192C305.7 416 320 430.3 320 448z'></path>
</svg>
</div>
<input
type='text'
className='inputMain'
placeholder='Signature public key'
name='signatureKey'
value={download.signatureKey}
onChange={handleChange}
/>
<div className='inputWrapperMainBox'></div>
</div>
<div className='inputWrapperMain'>
<div className='inputWrapperMainBox'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-96 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M320 448c0 17.67-14.31 32-32 32H64c-17.69 0-32-14.33-32-32v-384C32 46.34 46.31 32.01 64 32.01S96 46.34 96 64.01v352h192C305.7 416 320 430.3 320 448z'></path>
</svg>
</div>
<input
type='text'
className='inputMain'
name='malwareScanLink'
placeholder='Malware Scan Link'
value={download.malwareScanLink}
onChange={handleChange}
/>
<div className='inputWrapperMainBox'></div>
</div>
<div className='inputWrapperMain'>
<div className='inputWrapperMainBox'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-96 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M320 448c0 17.67-14.31 32-32 32H64c-17.69 0-32-14.33-32-32v-384C32 46.34 46.31 32.01 64 32.01S96 46.34 96 64.01v352h192C305.7 416 320 430.3 320 448z'></path>
</svg>
</div>
<input
type='text'
className='inputMain'
placeholder='Mod version (1.0)'
name='modVersion'
value={download.modVersion}
onChange={handleChange}
/>
<div className='inputWrapperMainBox'></div>
</div>
<div className='inputWrapperMain'>
<div className='inputWrapperMainBox'>
<svg
xmlns='http://www.w3.org/2000/svg'
viewBox='-96 0 512 512'
width='1em'
height='1em'
fill='currentColor'
>
<path d='M320 448c0 17.67-14.31 32-32 32H64c-17.69 0-32-14.33-32-32v-384C32 46.34 46.31 32.01 64 32.01S96 46.34 96 64.01v352h192C305.7 416 320 430.3 320 448z'></path>
</svg>
</div>
<input
type='text'
className='inputMain'
placeholder='Custom note/message'
name='customNote'
value={download.customNote}
onChange={handleChange}
/>
<div className='inputWrapperMainBox'></div>
2024-07-12 01:03:52 +05:00
</div>
</div>
2024-07-21 21:30:03 +05:00
)
}
)
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<HTMLInputElement>) => {
onUrlChange(index, e.target.value)
}
return (
2024-07-12 01:03:52 +05:00
<div className='inputWrapperMain'>
<input
type='text'
className='inputMain'
inputMode='url'
2024-07-21 21:30:03 +05:00
placeholder='We recommend to upload images to https://nostr.build/'
value={url}
onChange={handleChange}
2024-07-12 01:03:52 +05:00
/>
2024-07-21 21:30:03 +05:00
<button
className='btn btnMain btnMainRemove'
type='button'
onClick={() => onRemove(index)}
>
2024-07-12 01:03:52 +05:00
<svg
xmlns='http://www.w3.org/2000/svg'
2024-07-21 21:30:03 +05:00
viewBox='-32 0 512 512'
2024-07-12 01:03:52 +05:00
width='1em'
height='1em'
fill='currentColor'
>
2024-07-21 21:30:03 +05:00
<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>
2024-07-12 01:03:52 +05:00
</svg>
2024-07-21 21:30:03 +05:00
</button>
</div>
)
}
)
type GameDropdownProps = {
options: GameOption[]
selected: string
onChange: (name: string, value: string) => void
}
const GameDropdown = ({ options, selected, onChange }: GameDropdownProps) => {
const [searchTerm, setSearchTerm] = useState('')
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState('')
const inputRef = useRef<HTMLInputElement>(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 (
<div className='inputLabelWrapperMain'>
<label className='form-label labelMain'>Game</label>
<p className='labelDescriptionMain'>
Can't find the game you're looking for? Send us a DM mentioning it so we
can add it.
</p>
<div className='dropdown dropdownMain'>
2024-07-12 01:03:52 +05:00
<input
2024-07-21 21:30:03 +05:00
ref={inputRef}
2024-07-12 01:03:52 +05:00
type='text'
2024-07-21 21:30:03 +05:00
className='inputMain dropdown-toggle'
placeholder='This mod is for a game called...'
aria-expanded='false'
data-bs-toggle='dropdown'
value={searchTerm || selected}
onChange={(e) => handleSearchChange(e.target.value)}
onBlur={() => {
if (!isOptionClicked.current) {
setSearchTerm('')
}
isOptionClicked.current = false
}}
2024-07-12 01:03:52 +05:00
/>
2024-07-21 21:30:03 +05:00
<div className='dropdown-menu dropdownMainMenu'>
<List
height={500}
width={'100%'}
itemCount={filteredOptions.length}
itemSize={35}
2024-07-12 01:03:52 +05:00
>
2024-07-21 21:30:03 +05:00
{({ index, style }) => (
<div
style={style}
className='dropdown-item dropdownMainMenuItem'
onMouseDown={() => (isOptionClicked.current = true)}
onClick={() => {
onChange('game', filteredOptions[index].value)
setSearchTerm('')
if (inputRef.current) {
inputRef.current.blur()
}
}}
>
{filteredOptions[index].label}
</div>
)}
</List>
2024-07-12 01:03:52 +05:00
</div>
</div>
</div>
)
}