feat: submit mod #2
964
package-lock.json
generated
964
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@ -12,16 +12,29 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nostr-dev-kit/ndk": "2.8.2",
|
"@nostr-dev-kit/ndk": "2.8.2",
|
||||||
"@reduxjs/toolkit": "2.2.6",
|
"@reduxjs/toolkit": "2.2.6",
|
||||||
|
"lodash": "4.17.21",
|
||||||
"nostr-login": "1.5.2",
|
"nostr-login": "1.5.2",
|
||||||
"nostr-tools": "2.7.1",
|
"nostr-tools": "2.7.1",
|
||||||
|
"papaparse": "5.4.1",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-quill": "2.0.0",
|
||||||
"react-redux": "9.1.2",
|
"react-redux": "9.1.2",
|
||||||
"react-router-dom": "^6.24.1"
|
"react-router-dom": "^6.24.1",
|
||||||
|
"react-select": "5.8.0",
|
||||||
|
"react-toastify": "10.0.5",
|
||||||
|
"react-virtualized-auto-sizer": "1.0.24",
|
||||||
|
"react-window": "1.8.10",
|
||||||
|
"uuid": "10.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/lodash": "4.17.7",
|
||||||
|
"@types/papaparse": "5.3.14",
|
||||||
"@types/react": "^18.3.3",
|
"@types/react": "^18.3.3",
|
||||||
"@types/react-dom": "^18.3.0",
|
"@types/react-dom": "^18.3.0",
|
||||||
|
"@types/react-select": "5.0.1",
|
||||||
|
"@types/react-window": "1.8.8",
|
||||||
|
"@types/uuid": "10.0.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.13.1",
|
"@typescript-eslint/eslint-plugin": "^7.13.1",
|
||||||
"@typescript-eslint/parser": "^7.13.1",
|
"@typescript-eslint/parser": "^7.13.1",
|
||||||
"@vitejs/plugin-react": "^4.3.1",
|
"@vitejs/plugin-react": "^4.3.1",
|
||||||
|
203171
public/assets/games.csv
Normal file
203171
public/assets/games.csv
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,22 +1,67 @@
|
|||||||
import '../styles/styles.css'
|
import '../styles/styles.css'
|
||||||
|
import ReactQuill from 'react-quill'
|
||||||
|
import '../styles/customQuillStyles.css'
|
||||||
|
import React from 'react'
|
||||||
|
|
||||||
|
const editorFormats = [
|
||||||
|
'header',
|
||||||
|
'font',
|
||||||
|
'size',
|
||||||
|
'bold',
|
||||||
|
'italic',
|
||||||
|
'underline',
|
||||||
|
'strike',
|
||||||
|
'blockquote',
|
||||||
|
'list',
|
||||||
|
'bullet',
|
||||||
|
'indent',
|
||||||
|
'link'
|
||||||
|
]
|
||||||
|
|
||||||
|
const editorModules = {
|
||||||
|
toolbar: [
|
||||||
|
[{ header: '1' }, { header: '2' }, { font: [] }],
|
||||||
|
[{ size: [] }],
|
||||||
|
['bold', 'italic', 'underline', 'strike', 'blockquote'],
|
||||||
|
[
|
||||||
|
{ list: 'ordered' },
|
||||||
|
{ list: 'bullet' },
|
||||||
|
{ indent: '-1' },
|
||||||
|
{ indent: '+1' }
|
||||||
|
],
|
||||||
|
['link']
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
interface InputFieldProps {
|
interface InputFieldProps {
|
||||||
label: string
|
label: string
|
||||||
description?: string
|
description?: string
|
||||||
type?: 'text' | 'textarea'
|
type?: 'text' | 'textarea' | 'richtext'
|
||||||
placeholder: string
|
placeholder: string
|
||||||
name: string
|
name: string
|
||||||
inputMode?: 'url'
|
inputMode?: 'url'
|
||||||
|
value: string
|
||||||
|
onChange: (name: string, value: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const InputField = ({
|
export const InputField = React.memo(
|
||||||
|
({
|
||||||
label,
|
label,
|
||||||
description,
|
description,
|
||||||
type = 'text',
|
type = 'text',
|
||||||
placeholder,
|
placeholder,
|
||||||
name,
|
name,
|
||||||
inputMode
|
inputMode,
|
||||||
}: InputFieldProps) => (
|
value,
|
||||||
|
onChange
|
||||||
|
}: InputFieldProps) => {
|
||||||
|
const handleChange = (
|
||||||
|
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||||
|
) => {
|
||||||
|
onChange(name, e.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
<div className='inputLabelWrapperMain'>
|
<div className='inputLabelWrapperMain'>
|
||||||
<label className='form-label labelMain'>{label}</label>
|
<label className='form-label labelMain'>{label}</label>
|
||||||
{description && <p className='labelDescriptionMain'>{description}</p>}
|
{description && <p className='labelDescriptionMain'>{description}</p>}
|
||||||
@ -25,7 +70,18 @@ export const InputField = ({
|
|||||||
className='inputMain'
|
className='inputMain'
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
name={name}
|
name={name}
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
></textarea>
|
></textarea>
|
||||||
|
) : type === 'richtext' ? (
|
||||||
|
<ReactQuill
|
||||||
|
className='inputMain'
|
||||||
|
formats={editorFormats}
|
||||||
|
modules={editorModules}
|
||||||
|
placeholder={placeholder}
|
||||||
|
value={value}
|
||||||
|
onChange={(content) => onChange(name, content)}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
@ -33,53 +89,33 @@ export const InputField = ({
|
|||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
name={name}
|
name={name}
|
||||||
inputMode={inputMode}
|
inputMode={inputMode}
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
interface CheckboxFieldProps {
|
interface CheckboxFieldProps {
|
||||||
label: string
|
label: string
|
||||||
name: string
|
name: string
|
||||||
|
isChecked: boolean
|
||||||
|
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CheckboxField = ({ label, name }: CheckboxFieldProps) => (
|
export const CheckboxField = React.memo(
|
||||||
|
({ label, name, isChecked, handleChange }: CheckboxFieldProps) => (
|
||||||
<div className='inputLabelWrapperMain inputLabelWrapperMainAlt inputLabelWrapperMainAltStylized'>
|
<div className='inputLabelWrapperMain inputLabelWrapperMainAlt inputLabelWrapperMainAltStylized'>
|
||||||
<label className='form-label labelMain'>{label}</label>
|
<label className='form-label labelMain'>{label}</label>
|
||||||
<input type='checkbox' className='CheckboxMain' name={name} />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
|
|
||||||
interface ImageUploadFieldProps {
|
|
||||||
label: string
|
|
||||||
description: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ImageUploadField = ({
|
|
||||||
label,
|
|
||||||
description
|
|
||||||
}: ImageUploadFieldProps) => (
|
|
||||||
<div className='inputLabelWrapperMain'>
|
|
||||||
<label className='form-label labelMain'>{label}</label>
|
|
||||||
{description && <p className='labelDescriptionMain'>{description}</p>}
|
|
||||||
<div className='inputWrapperMain'>
|
|
||||||
<input
|
<input
|
||||||
type='text'
|
type='checkbox'
|
||||||
className='inputMain'
|
className='CheckboxMain'
|
||||||
inputMode='url'
|
name={name}
|
||||||
placeholder='We recommend to upload images to https://nostr.build/'
|
checked={isChecked}
|
||||||
|
onChange={handleChange}
|
||||||
/>
|
/>
|
||||||
<button className='btn btnMain btnMainRemove' type='button'>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
@ -1,26 +1,308 @@
|
|||||||
|
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'
|
||||||
import '../styles/styles.css'
|
import '../styles/styles.css'
|
||||||
import { CheckboxField, ImageUploadField, InputField } from './Inputs'
|
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
|
||||||
|
|
||||||
export const ModForm = () => {
|
export const ModForm = () => {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<InputField
|
<GameDropdown
|
||||||
label='Game'
|
options={gameOptions}
|
||||||
description="Can't find the game you're looking for? Send us a DM mentioning it so we can add it."
|
selected={formState.game}
|
||||||
placeholder='The mod is for a game called...'
|
onChange={handleInputChange}
|
||||||
name='game'
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InputField
|
<InputField
|
||||||
label='Title'
|
label='Title'
|
||||||
placeholder='Return the banana mod'
|
placeholder='Return the banana mod'
|
||||||
name='title'
|
name='title'
|
||||||
|
value={formState.title}
|
||||||
|
onChange={handleInputChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InputField
|
<InputField
|
||||||
label='Body'
|
label='Body'
|
||||||
type='textarea'
|
type='richtext'
|
||||||
placeholder="Here's what this mod is all about"
|
placeholder="Here's what this mod is all about"
|
||||||
name='body'
|
name='body'
|
||||||
|
value={formState.body}
|
||||||
|
onChange={handleInputChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<InputField
|
<InputField
|
||||||
label='Featured Image URL'
|
label='Featured Image URL'
|
||||||
description='We recommend to upload images to https://nostr.build/'
|
description='We recommend to upload images to https://nostr.build/'
|
||||||
@ -28,29 +310,71 @@ export const ModForm = () => {
|
|||||||
inputMode='url'
|
inputMode='url'
|
||||||
placeholder='Image URL'
|
placeholder='Image URL'
|
||||||
name='featuredImageUrl'
|
name='featuredImageUrl'
|
||||||
|
value={formState.featuredImageUrl}
|
||||||
|
onChange={handleInputChange}
|
||||||
/>
|
/>
|
||||||
<InputField
|
<InputField
|
||||||
label='Summary'
|
label='Summary'
|
||||||
type='textarea'
|
type='textarea'
|
||||||
placeholder='This is a quick description of my mod'
|
placeholder='This is a quick description of my mod'
|
||||||
name='summary'
|
name='summary'
|
||||||
|
value={formState.summary}
|
||||||
|
onChange={handleInputChange}
|
||||||
/>
|
/>
|
||||||
<CheckboxField label='This mod not safe for work (NSFW)' name='nsfw' />
|
<CheckboxField
|
||||||
<ImageUploadField
|
label='This mod not safe for work (NSFW)'
|
||||||
label='Screenshots URLs'
|
name='nsfw'
|
||||||
description='We recommend to upload images to https://nostr.build/'
|
isChecked={formState.nsfw}
|
||||||
|
handleChange={handleCheckboxChange}
|
||||||
/>
|
/>
|
||||||
|
<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>
|
||||||
<InputField
|
<InputField
|
||||||
label='Tags'
|
label='Tags'
|
||||||
description='Separate each tag with a comma. (Example: tag1, tag2, tag3)'
|
description='Separate each tag with a comma. (Example: tag1, tag2, tag3)'
|
||||||
placeholder='Tags'
|
placeholder='Tags'
|
||||||
name='tags'
|
name='tags'
|
||||||
|
value={formState.tags}
|
||||||
|
onChange={handleInputChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className='inputLabelWrapperMain'>
|
<div className='inputLabelWrapperMain'>
|
||||||
<div className='labelWrapperMain'>
|
<div className='labelWrapperMain'>
|
||||||
<label className='form-label labelMain'>Download URLs</label>
|
<label className='form-label labelMain'>Download URLs</label>
|
||||||
<button className='btn btnMain btnMainAdd' type='button'>
|
<button
|
||||||
|
className='btn btnMain btnMainAdd'
|
||||||
|
type='button'
|
||||||
|
onClick={addDownloadUrl}
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns='http://www.w3.org/2000/svg'
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
viewBox='-32 0 512 512'
|
viewBox='-32 0 512 512'
|
||||||
@ -64,29 +388,59 @@ export const ModForm = () => {
|
|||||||
</div>
|
</div>
|
||||||
<p className='labelDescriptionMain'>
|
<p className='labelDescriptionMain'>
|
||||||
You can upload your game mod to Github, as an example, and keep
|
You can upload your game mod to Github, as an example, and keep
|
||||||
updating it there. Also, its advisable that you hash your package as
|
updating it there. Also, it's advisable that you hash your package as
|
||||||
well with your nostr public key.
|
well with your nostr public key.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<DownloadUrlFields />
|
{formState.downloadUrls.map((download, index) => (
|
||||||
<DownloadUrlFields />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
type DownloadUrlFieldsProps = {
|
||||||
|
index: number
|
||||||
|
download: DownloadUrl
|
||||||
|
onUrlChange: (index: number, field: keyof DownloadUrl, value: string) => void
|
||||||
|
onRemove: (index: number) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
const DownloadUrlFields = () => {
|
|
||||||
return (
|
return (
|
||||||
<div className='inputWrapperMainWrapper'>
|
<div className='inputWrapperMainWrapper'>
|
||||||
<div className='inputWrapperMain'>
|
<div className='inputWrapperMain'>
|
||||||
<input
|
<input
|
||||||
type='text'
|
type='text'
|
||||||
className='inputMain'
|
className='inputMain'
|
||||||
inputMode='url'
|
name='url'
|
||||||
placeholder='https://...'
|
placeholder='Download URL'
|
||||||
value='https://github.com/'
|
value={download.url}
|
||||||
|
onChange={handleChange}
|
||||||
/>
|
/>
|
||||||
<button className='btn btnMain btnMainRemove' type='button'>
|
|
||||||
|
<button
|
||||||
|
className='btn btnMain btnMainRemove'
|
||||||
|
type='button'
|
||||||
|
onClick={() => onRemove(index)}
|
||||||
|
>
|
||||||
<svg
|
<svg
|
||||||
xmlns='http://www.w3.org/2000/svg'
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
viewBox='-32 0 512 512'
|
viewBox='-32 0 512 512'
|
||||||
@ -113,8 +467,10 @@ const DownloadUrlFields = () => {
|
|||||||
<input
|
<input
|
||||||
type='text'
|
type='text'
|
||||||
className='inputMain'
|
className='inputMain'
|
||||||
inputMode='url'
|
name='hash'
|
||||||
placeholder='SHA-256 Hash'
|
placeholder='SHA-256 Hash'
|
||||||
|
value={download.hash}
|
||||||
|
onChange={handleChange}
|
||||||
/>
|
/>
|
||||||
<div className='inputWrapperMainBox'></div>
|
<div className='inputWrapperMainBox'></div>
|
||||||
</div>
|
</div>
|
||||||
@ -133,9 +489,10 @@ const DownloadUrlFields = () => {
|
|||||||
<input
|
<input
|
||||||
type='text'
|
type='text'
|
||||||
className='inputMain'
|
className='inputMain'
|
||||||
inputMode='url'
|
|
||||||
placeholder='Signature public key'
|
placeholder='Signature public key'
|
||||||
value='npub18n4ysp43ux5c98fs6h9c57qpr4p8r3j8f6e32v0vj8egzy878aqqyzzk9r'
|
name='signatureKey'
|
||||||
|
value={download.signatureKey}
|
||||||
|
onChange={handleChange}
|
||||||
/>
|
/>
|
||||||
<div className='inputWrapperMainBox'></div>
|
<div className='inputWrapperMainBox'></div>
|
||||||
</div>
|
</div>
|
||||||
@ -154,8 +511,10 @@ const DownloadUrlFields = () => {
|
|||||||
<input
|
<input
|
||||||
type='text'
|
type='text'
|
||||||
className='inputMain'
|
className='inputMain'
|
||||||
inputMode='url'
|
name='malwareScanLink'
|
||||||
placeholder='Malware scan link'
|
placeholder='Malware Scan Link'
|
||||||
|
value={download.malwareScanLink}
|
||||||
|
onChange={handleChange}
|
||||||
/>
|
/>
|
||||||
<div className='inputWrapperMainBox'></div>
|
<div className='inputWrapperMainBox'></div>
|
||||||
</div>
|
</div>
|
||||||
@ -174,8 +533,10 @@ const DownloadUrlFields = () => {
|
|||||||
<input
|
<input
|
||||||
type='text'
|
type='text'
|
||||||
className='inputMain'
|
className='inputMain'
|
||||||
inputMode='url'
|
|
||||||
placeholder='Mod version (1.0)'
|
placeholder='Mod version (1.0)'
|
||||||
|
name='modVersion'
|
||||||
|
value={download.modVersion}
|
||||||
|
onChange={handleChange}
|
||||||
/>
|
/>
|
||||||
<div className='inputWrapperMainBox'></div>
|
<div className='inputWrapperMainBox'></div>
|
||||||
</div>
|
</div>
|
||||||
@ -194,11 +555,139 @@ const DownloadUrlFields = () => {
|
|||||||
<input
|
<input
|
||||||
type='text'
|
type='text'
|
||||||
className='inputMain'
|
className='inputMain'
|
||||||
inputMode='url'
|
placeholder='Custom note/message'
|
||||||
placeholder='Custome note/message'
|
name='customNote'
|
||||||
|
value={download.customNote}
|
||||||
|
onChange={handleChange}
|
||||||
/>
|
/>
|
||||||
<div className='inputWrapperMainBox'></div>
|
<div className='inputWrapperMainBox'></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className='inputWrapperMain'>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
className='inputMain'
|
||||||
|
inputMode='url'
|
||||||
|
placeholder='We recommend to upload images to https://nostr.build/'
|
||||||
|
value={url}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
className='btn btnMain btnMainRemove'
|
||||||
|
type='button'
|
||||||
|
onClick={() => onRemove(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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
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'>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type='text'
|
||||||
|
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
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className='dropdown-menu dropdownMainMenu'>
|
||||||
|
<List
|
||||||
|
height={500}
|
||||||
|
width={'100%'}
|
||||||
|
itemCount={filteredOptions.length}
|
||||||
|
itemSize={35}
|
||||||
|
>
|
||||||
|
{({ 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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,8 @@ import React from 'react'
|
|||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import { Provider } from 'react-redux'
|
import { Provider } from 'react-redux'
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import { ToastContainer } from 'react-toastify'
|
||||||
|
import 'react-toastify/dist/ReactToastify.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import { store } from './store/index.ts'
|
import { store } from './store/index.ts'
|
||||||
@ -11,6 +13,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
|||||||
<Provider store={store}>
|
<Provider store={store}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<App />
|
<App />
|
||||||
|
<ToastContainer />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</Provider>
|
</Provider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
|
@ -16,11 +16,6 @@ export const SubmitModPage = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className='IBMSMSMBS_Write'>
|
<div className='IBMSMSMBS_Write'>
|
||||||
<ModForm />
|
<ModForm />
|
||||||
<div className='IBMSMSMBS_WriteAction'>
|
|
||||||
<button className='btn btnMain' type='button'>
|
|
||||||
Publish
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ProfileSection />
|
<ProfileSection />
|
||||||
|
@ -7,7 +7,7 @@ export interface IUserState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const initialState: IUserState = {
|
const initialState: IUserState = {
|
||||||
isAuth: localStorage.getItem('login') ? true : false,
|
isAuth: false,
|
||||||
user: {}
|
user: {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
13
src/styles/customQuillStyles.css
Normal file
13
src/styles/customQuillStyles.css
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
.quill .ql-toolbar.ql-snow {
|
||||||
|
border: none;
|
||||||
|
border-bottom: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quill .ql-container.ql-snow {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quill .ql-editor.ql-blank::before {
|
||||||
|
color: #757575;
|
||||||
|
font-size: medium;
|
||||||
|
}
|
@ -1,5 +1,16 @@
|
|||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current time in seconds since the Unix epoch (January 1, 1970).
|
||||||
|
*
|
||||||
|
* This function retrieves the current time in milliseconds since the Unix epoch
|
||||||
|
* using `Date.now()` and then converts it to seconds by dividing by 1000.
|
||||||
|
* Finally, it rounds the result to the nearest whole number using `Math.round()`.
|
||||||
|
*
|
||||||
|
* @returns {number} The current time in seconds since the Unix epoch.
|
||||||
|
*/
|
||||||
|
export const now = () => Math.round(Date.now() / 1000)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts a hexadecimal public key to an npub format.
|
* Converts a hexadecimal public key to an npub format.
|
||||||
* If the input is already in npub format, it returns the input unchanged.
|
* If the input is already in npub format, it returns the input unchanged.
|
||||||
|
Loading…
Reference in New Issue
Block a user