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": {
|
||||
"@nostr-dev-kit/ndk": "2.8.2",
|
||||
"@reduxjs/toolkit": "2.2.6",
|
||||
"lodash": "4.17.21",
|
||||
"nostr-login": "1.5.2",
|
||||
"nostr-tools": "2.7.1",
|
||||
"papaparse": "5.4.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-quill": "2.0.0",
|
||||
"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": {
|
||||
"@types/lodash": "4.17.7",
|
||||
"@types/papaparse": "5.3.14",
|
||||
"@types/react": "^18.3.3",
|
||||
"@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/parser": "^7.13.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,85 +1,121 @@
|
||||
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 {
|
||||
label: string
|
||||
description?: string
|
||||
type?: 'text' | 'textarea'
|
||||
type?: 'text' | 'textarea' | 'richtext'
|
||||
placeholder: string
|
||||
name: string
|
||||
inputMode?: 'url'
|
||||
value: string
|
||||
onChange: (name: string, value: string) => void
|
||||
}
|
||||
|
||||
export const InputField = ({
|
||||
label,
|
||||
description,
|
||||
type = 'text',
|
||||
placeholder,
|
||||
name,
|
||||
inputMode
|
||||
}: InputFieldProps) => (
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label className='form-label labelMain'>{label}</label>
|
||||
{description && <p className='labelDescriptionMain'>{description}</p>}
|
||||
{type === 'textarea' ? (
|
||||
<textarea
|
||||
className='inputMain'
|
||||
placeholder={placeholder}
|
||||
name={name}
|
||||
></textarea>
|
||||
) : (
|
||||
<input
|
||||
type={type}
|
||||
className='inputMain'
|
||||
placeholder={placeholder}
|
||||
name={name}
|
||||
inputMode={inputMode}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
export const InputField = React.memo(
|
||||
({
|
||||
label,
|
||||
description,
|
||||
type = 'text',
|
||||
placeholder,
|
||||
name,
|
||||
inputMode,
|
||||
value,
|
||||
onChange
|
||||
}: InputFieldProps) => {
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
|
||||
) => {
|
||||
onChange(name, e.target.value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<label className='form-label labelMain'>{label}</label>
|
||||
{description && <p className='labelDescriptionMain'>{description}</p>}
|
||||
{type === 'textarea' ? (
|
||||
<textarea
|
||||
className='inputMain'
|
||||
placeholder={placeholder}
|
||||
name={name}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
></textarea>
|
||||
) : type === 'richtext' ? (
|
||||
<ReactQuill
|
||||
className='inputMain'
|
||||
formats={editorFormats}
|
||||
modules={editorModules}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(content) => onChange(name, content)}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type={type}
|
||||
className='inputMain'
|
||||
placeholder={placeholder}
|
||||
name={name}
|
||||
inputMode={inputMode}
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
interface CheckboxFieldProps {
|
||||
label: string
|
||||
name: string
|
||||
isChecked: boolean
|
||||
handleChange: (e: React.ChangeEvent<HTMLInputElement>) => void
|
||||
}
|
||||
|
||||
export const CheckboxField = ({ label, name }: CheckboxFieldProps) => (
|
||||
<div className='inputLabelWrapperMain inputLabelWrapperMainAlt inputLabelWrapperMainAltStylized'>
|
||||
<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'>
|
||||
export const CheckboxField = React.memo(
|
||||
({ label, name, isChecked, handleChange }: CheckboxFieldProps) => (
|
||||
<div className='inputLabelWrapperMain inputLabelWrapperMainAlt inputLabelWrapperMainAltStylized'>
|
||||
<label className='form-label labelMain'>{label}</label>
|
||||
<input
|
||||
type='text'
|
||||
className='inputMain'
|
||||
inputMode='url'
|
||||
placeholder='We recommend to upload images to https://nostr.build/'
|
||||
type='checkbox'
|
||||
className='CheckboxMain'
|
||||
name={name}
|
||||
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>
|
||||
)
|
||||
)
|
||||
|
@ -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 { 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 = () => {
|
||||
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 (
|
||||
<>
|
||||
<InputField
|
||||
label='Game'
|
||||
description="Can't find the game you're looking for? Send us a DM mentioning it so we can add it."
|
||||
placeholder='The mod is for a game called...'
|
||||
name='game'
|
||||
<GameDropdown
|
||||
options={gameOptions}
|
||||
selected={formState.game}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
label='Title'
|
||||
placeholder='Return the banana mod'
|
||||
name='title'
|
||||
value={formState.title}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
label='Body'
|
||||
type='textarea'
|
||||
type='richtext'
|
||||
placeholder="Here's what this mod is all about"
|
||||
name='body'
|
||||
value={formState.body}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
|
||||
<InputField
|
||||
label='Featured Image URL'
|
||||
description='We recommend to upload images to https://nostr.build/'
|
||||
@ -28,29 +310,71 @@ export const ModForm = () => {
|
||||
inputMode='url'
|
||||
placeholder='Image URL'
|
||||
name='featuredImageUrl'
|
||||
value={formState.featuredImageUrl}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
<InputField
|
||||
label='Summary'
|
||||
type='textarea'
|
||||
placeholder='This is a quick description of my mod'
|
||||
name='summary'
|
||||
value={formState.summary}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
<CheckboxField label='This mod not safe for work (NSFW)' name='nsfw' />
|
||||
<ImageUploadField
|
||||
label='Screenshots URLs'
|
||||
description='We recommend to upload images to https://nostr.build/'
|
||||
<CheckboxField
|
||||
label='This mod not safe for work (NSFW)'
|
||||
name='nsfw'
|
||||
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
|
||||
label='Tags'
|
||||
description='Separate each tag with a comma. (Example: tag1, tag2, tag3)'
|
||||
placeholder='Tags'
|
||||
name='tags'
|
||||
value={formState.tags}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
|
||||
<div className='inputLabelWrapperMain'>
|
||||
<div className='labelWrapperMain'>
|
||||
<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
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-32 0 512 512'
|
||||
@ -64,29 +388,213 @@ export const ModForm = () => {
|
||||
</div>
|
||||
<p className='labelDescriptionMain'>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<DownloadUrlFields />
|
||||
<DownloadUrlFields />
|
||||
{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>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
type DownloadUrlFieldsProps = {
|
||||
index: number
|
||||
download: DownloadUrl
|
||||
onUrlChange: (index: number, field: keyof DownloadUrl, value: string) => void
|
||||
onRemove: (index: number) => void
|
||||
}
|
||||
|
||||
const DownloadUrlFields = () => {
|
||||
return (
|
||||
<div className='inputWrapperMainWrapper'>
|
||||
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)}
|
||||
>
|
||||
<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 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>
|
||||
</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='https://...'
|
||||
value='https://github.com/'
|
||||
placeholder='We recommend to upload images to https://nostr.build/'
|
||||
value={url}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<button className='btn btnMain btnMainRemove' type='button'>
|
||||
<button
|
||||
className='btn btnMain btnMainRemove'
|
||||
type='button'
|
||||
onClick={() => onRemove(index)}
|
||||
>
|
||||
<svg
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
viewBox='-32 0 512 512'
|
||||
@ -94,110 +602,91 @@ const DownloadUrlFields = () => {
|
||||
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>
|
||||
<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>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
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'
|
||||
inputMode='url'
|
||||
placeholder='SHA-256 Hash'
|
||||
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='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'
|
||||
<div className='dropdown-menu dropdownMainMenu'>
|
||||
<List
|
||||
height={500}
|
||||
width={'100%'}
|
||||
itemCount={filteredOptions.length}
|
||||
itemSize={35}
|
||||
>
|
||||
<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>
|
||||
{({ 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>
|
||||
<input
|
||||
type='text'
|
||||
className='inputMain'
|
||||
inputMode='url'
|
||||
placeholder='Signature public key'
|
||||
value='npub18n4ysp43ux5c98fs6h9c57qpr4p8r3j8f6e32v0vj8egzy878aqqyzzk9r'
|
||||
/>
|
||||
<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'
|
||||
inputMode='url'
|
||||
placeholder='Malware scan link'
|
||||
/>
|
||||
<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'
|
||||
inputMode='url'
|
||||
placeholder='Mod version (1.0)'
|
||||
/>
|
||||
<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'
|
||||
inputMode='url'
|
||||
placeholder='Custome note/message'
|
||||
/>
|
||||
<div className='inputWrapperMainBox'></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
@ -2,6 +2,8 @@ import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { Provider } from 'react-redux'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { ToastContainer } from 'react-toastify'
|
||||
import 'react-toastify/dist/ReactToastify.css'
|
||||
import App from './App.tsx'
|
||||
import './index.css'
|
||||
import { store } from './store/index.ts'
|
||||
@ -11,6 +13,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<Provider store={store}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
<ToastContainer />
|
||||
</BrowserRouter>
|
||||
</Provider>
|
||||
</React.StrictMode>
|
||||
|
@ -16,11 +16,6 @@ export const SubmitModPage = () => {
|
||||
</div>
|
||||
<div className='IBMSMSMBS_Write'>
|
||||
<ModForm />
|
||||
<div className='IBMSMSMBS_WriteAction'>
|
||||
<button className='btn btnMain' type='button'>
|
||||
Publish
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ProfileSection />
|
||||
|
@ -7,7 +7,7 @@ export interface IUserState {
|
||||
}
|
||||
|
||||
const initialState: IUserState = {
|
||||
isAuth: localStorage.getItem('login') ? true : false,
|
||||
isAuth: false,
|
||||
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'
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* If the input is already in npub format, it returns the input unchanged.
|
||||
|
Loading…
Reference in New Issue
Block a user