|
1 |
| -import { memo } from "react" |
| 1 | +import { Fragment, memo, useEffect, useState } from "react" |
2 | 2 | import { Tiptap } from "./Tiptap"
|
3 | 3 | import TextareaAutosize from 'react-textarea-autosize'
|
| 4 | +import * as yup from "yup" |
| 5 | +import { useForm } from "react-hook-form" |
| 6 | +import { yupResolver } from '@hookform/resolvers/yup' |
| 7 | +import { OnChangeValue } from "react-select" |
| 8 | +import CreatableSelect from "react-select/creatable" |
| 9 | +import { useLocalStorageValue } from "@react-hookz/web" |
| 10 | +import { CREATE_CACHE, CREATE_USED_AUTHORS, CREATE_USED_TAGS } from "../constants" |
| 11 | +import { addToIPFS } from "../services/IPFSHttpClient" |
| 12 | +import { addNFTToNFTStorage } from "../services/NFTStorage" |
| 13 | +import axios from "axios" |
| 14 | +import router from "next/router" |
| 15 | +import { Dialog, Menu, Transition } from '@headlessui/react' |
| 16 | +import { ExclamationIcon } from "@heroicons/react/outline" |
4 | 17 |
|
5 | 18 | interface EditorProps {
|
6 | 19 | account: string
|
7 | 20 | }
|
8 | 21 |
|
| 22 | +interface IFormInputs { |
| 23 | + price: string |
| 24 | + name: string |
| 25 | + description: string |
| 26 | + s_tags: string |
| 27 | + author: string |
| 28 | + files: FileList |
| 29 | +} |
| 30 | +const customStyles = { |
| 31 | + menu: (provided, state) => ({ |
| 32 | + ...provided, |
| 33 | + width: '200px', |
| 34 | + borderBottom: '1px dotted pink', |
| 35 | + color: state.selectProps.menuColor, |
| 36 | + padding: '5px 10px', |
| 37 | + }), |
| 38 | + control: (provided, state) => ({ |
| 39 | + ...provided, |
| 40 | + border: 'none', |
| 41 | + boxShadow: 'none' |
| 42 | + }), |
| 43 | + valueContainer: (provided, state) => ({ |
| 44 | + ...provided, |
| 45 | + }), |
| 46 | + dropdownIndicator: (provided, state) => ({ |
| 47 | + ...provided, |
| 48 | + display: 'none' |
| 49 | + }), |
| 50 | + indicatorsContainer: (provided, state) => ({ |
| 51 | + ...provided, |
| 52 | + display: 'none' |
| 53 | + }), |
| 54 | + multiValue: (provided, state) => { |
| 55 | + const opacity = state.isDisabled ? 0.5 : 1 |
| 56 | + const transition = 'opacity 300ms' |
| 57 | + |
| 58 | + return { |
| 59 | + ...provided, |
| 60 | + opacity, |
| 61 | + transition, |
| 62 | + background: 'transparent', |
| 63 | + borderRadius: '100px', |
| 64 | + '& > div': { |
| 65 | + color: 'rgb(107 114 128 / var(--tw-text-opacity))', |
| 66 | + fontSize: '0.875rem', |
| 67 | + lineHeight: '1.25rem', |
| 68 | + } |
| 69 | + } |
| 70 | + } |
| 71 | +} |
| 72 | + |
| 73 | + |
| 74 | +const schema = yup.object({ |
| 75 | + price: yup.string(), |
| 76 | + name: yup.string().required('Title is not optional'), |
| 77 | + description: yup.string().required("Content is not optional"), |
| 78 | + s_tags: yup.string().required("Tags is not optional"), |
| 79 | + author: yup.string().required("Authors Name is not optional"), |
| 80 | + files: yup.mixed().test({ test: (value) => value.length, message: "Feature Image is not optional" }), |
| 81 | +}).required() |
| 82 | + |
| 83 | +type Option = { label: string, value: string, __isNew__: boolean } |
9 | 84 | export const Editor = memo<EditorProps>(({ account }) => {
|
| 85 | + const [isOpen, setIsOpen] = useState(false) |
| 86 | + const [cachedTags, setCachedTags] = useState<Option[]>([]) |
| 87 | + const [cachedAuthors, setCachedAuthors] = useState<Option>() |
| 88 | + const [tagsInLocal, setLocalTags] = useLocalStorageValue<Omit<Option, '__isNew__'>[]>(CREATE_USED_TAGS) |
| 89 | + const [authorsInLocal, setLocalAuthors] = useLocalStorageValue<Omit<Option, '__isNew__'>[]>(CREATE_USED_AUTHORS) |
| 90 | + const [preview, setPreview] = useState<string>() |
| 91 | + const { |
| 92 | + register, |
| 93 | + handleSubmit, |
| 94 | + setValue, |
| 95 | + formState: { errors, isSubmitting, isValid }, |
| 96 | + watch, |
| 97 | + trigger |
| 98 | + } = useForm<IFormInputs>({ |
| 99 | + resolver: yupResolver(schema) |
| 100 | + }) |
| 101 | + |
| 102 | + // update preview image |
| 103 | + const watchedFiles = watch("files", null) |
| 104 | + useEffect(() => { |
| 105 | + if (!watchedFiles) return |
| 106 | + if (!watchedFiles[0]) return |
| 107 | + |
| 108 | + const url = URL.createObjectURL(watchedFiles[0]) |
| 109 | + setPreview(url) |
| 110 | + }, [watchedFiles]) |
| 111 | + |
| 112 | + const handleAuthorsChange = ( |
| 113 | + newValue: Option, |
| 114 | + ) => { |
| 115 | + setValue('author', newValue.value) |
| 116 | + setCachedAuthors(newValue) |
| 117 | + } |
| 118 | + |
| 119 | + const handleTagsChange = ( |
| 120 | + newValue: OnChangeValue<Option, true>, |
| 121 | + ) => { |
| 122 | + setValue('s_tags', newValue.map(x => x.value).join(',')) |
| 123 | + setCachedTags([...newValue]) |
| 124 | + } |
| 125 | + |
| 126 | + const onSubmit = async(data: IFormInputs) => { |
| 127 | + const file = data.files[0] |
| 128 | + const { type: filetype, size: filesize, name: filename } = file |
| 129 | + const addedImage = await addToIPFS(file) |
| 130 | + const imageURL = `https://ipfs.infura.io/ipfs/${addedImage.path}` |
| 131 | + const license = "CC-BY-SA" |
| 132 | + const license_url = "https://creativecommons.org/licenses/by-sa/4.0/" |
| 133 | + const tags = data.s_tags.split(',') |
| 134 | + const authors = [{ |
| 135 | + name: data.author, |
| 136 | + wallet: { |
| 137 | + eth: account, |
| 138 | + }, |
| 139 | + }] |
| 140 | + const nftData = JSON.stringify({ |
| 141 | + name: data.name, |
| 142 | + description: data.description, |
| 143 | + image: imageURL, |
| 144 | + license, |
| 145 | + license_url, |
| 146 | + filesize, |
| 147 | + filename, |
| 148 | + filetype, |
| 149 | + tags, |
| 150 | + authors, |
| 151 | + }) |
| 152 | + |
| 153 | + await addNFTToNFTStorage(nftData) |
| 154 | + |
| 155 | + const addedNFT = await addToIPFS(nftData) |
| 156 | + // TODO: need fix this url? |
| 157 | + const dweb_search_url = `https://dweb-search-api.anwen.cc/add_meta` |
| 158 | + const sig_login = localStorage.getItem("sig_login") |
| 159 | + const aethAccount = localStorage.getItem("ethAccount") |
| 160 | + axios.defaults.headers.common['authorization'] = `Bearer ${sig_login}` |
| 161 | + axios.defaults.headers.common['address'] = aethAccount |
| 162 | + const ret = await axios.post(dweb_search_url, { |
| 163 | + path: addedNFT.path, |
| 164 | + eth: account, |
| 165 | + name: data.name, |
| 166 | + image: imageURL, |
| 167 | + tags: data.s_tags, |
| 168 | + authors: data.author |
| 169 | + }) // TODO |
| 170 | + if (ret.status == 200 && !('error' in ret.data)) { |
| 171 | + updateLocalCache() |
| 172 | + await router.push("/articles-my") |
| 173 | + } else { |
| 174 | + const err = ret.data['error'] |
| 175 | + throw new Error(err) |
| 176 | + } |
| 177 | + } |
| 178 | + |
| 179 | + const updateLocalCache = () => { |
| 180 | + const newTags = [ |
| 181 | + ...tagsInLocal ?? [], |
| 182 | + ...cachedTags.filter(x => x.__isNew__) |
| 183 | + ].map(x => ({ label: x.label, value: x.value })) |
| 184 | + const newAuthors = [ |
| 185 | + ...authorsInLocal ?? [], |
| 186 | + cachedAuthors.__isNew__ ? cachedAuthors : undefined |
| 187 | + ].filter(x => x).map(x => ({ label: x.label, value: x.value })) |
| 188 | + setLocalTags(newTags) |
| 189 | + setLocalAuthors(newAuthors) |
| 190 | + |
| 191 | + localStorage.removeItem(CREATE_CACHE) |
| 192 | + } |
| 193 | + |
| 194 | + const onError = (error) => { |
| 195 | + if (Object.keys(error)[0]) { |
| 196 | + alert(`Error uploading to dweb-search: ${error[Object.keys(error)[0]].message}`) |
| 197 | + } else { |
| 198 | + alert("Sorry! Publish failed, server error. we are fixing...") |
| 199 | + } |
| 200 | + } |
| 201 | + |
10 | 202 | return (
|
11 |
| - <div style={{ width: '720px' }}> |
12 |
| - <div> |
13 |
| - <span className="ml-2 bg-gray-200 text-gray-500 rounded-full inline-block p-1 px-2 text-sm">+ Add Image</span> |
14 |
| - </div> |
15 |
| - <div className="pt-12"> |
16 |
| - <TextareaAutosize placeholder="Give a title" className="border-0 outline-0 text-5xl w-full resize-none" /> |
17 |
| - </div> |
18 |
| - <div className="my-6 text-gray-800 flex items-center"> |
19 |
| - <div className="w-8 h-8 rounded-full inline-flex items-center justify-center bg-gray-200 text-gray-400"> |
20 |
| - <svg fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" |
21 |
| - className="w-4 h-4" viewBox="0 0 24 24"> |
22 |
| - <path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/> |
23 |
| - <circle cx="12" cy="7" r="4"/> |
24 |
| - </svg> |
| 203 | + <div className='relative w-full flex justify-center'> |
| 204 | + <form style={{ width: '720px' }} onSubmit={handleSubmit(onSubmit, onError)}> |
| 205 | + <div className='inline-flex absolute -top-10 right-2 items-center'> |
| 206 | + <Menu as="div" className="relative inline-block text-left"> |
| 207 | + <div> |
| 208 | + <Menu.Button className="px-2 flex items-center"> |
| 209 | + <ExclamationIcon |
| 210 | + className="w-8 h-8" |
| 211 | + aria-hidden="true" |
| 212 | + /> |
| 213 | + </Menu.Button> |
| 214 | + </div> |
| 215 | + <Transition |
| 216 | + as={Fragment} |
| 217 | + enter="transition ease-out duration-100" |
| 218 | + enterFrom="transform opacity-0 scale-95" |
| 219 | + enterTo="transform opacity-100 scale-100" |
| 220 | + leave="transition ease-in duration-75" |
| 221 | + leaveFrom="transform opacity-100 scale-100" |
| 222 | + leaveTo="transform opacity-0 scale-95" |
| 223 | + > |
| 224 | + <Menu.Items |
| 225 | + className="absolute right-0 w-80 mt-2 origin-top-right bg-white divide-y divide-gray-100 rounded-md shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none"> |
| 226 | + <div className="px-6 py-8"> |
| 227 | + <Menu.Item> |
| 228 | + <div className="w-full"> |
| 229 | + <h2>Attention: </h2> |
| 230 | + <p className="mt-2"> |
| 231 | + - All your published data and metadata is open to public and with{" "} |
| 232 | + <a href="https://creativecommons.org/licenses/by-sa/4.0/">CC-BY-SA</a>{" "} |
| 233 | + License.{" "} |
| 234 | + </p> |
| 235 | + <p className="mt-2"> |
| 236 | + - They will be on IPFS and Dweb Search Engine too. |
| 237 | + </p> |
| 238 | + <p className="mt-2"> |
| 239 | + - It’s forbidden to mint anything which doesn’t belong to you. |
| 240 | + </p> |
| 241 | + </div> |
| 242 | + </Menu.Item> |
| 243 | + </div> |
| 244 | + </Menu.Items> |
| 245 | + </Transition> |
| 246 | + </Menu> |
| 247 | + <button |
| 248 | + disabled={isSubmitting} |
| 249 | + type="submit" |
| 250 | + className="font-bold bg-pink-500 text-white rounded px-4 py-2 cursor-pointer" |
| 251 | + onClick={() => { |
| 252 | + const cache = JSON.parse(localStorage.getItem(CREATE_CACHE) as any) |
| 253 | + if (cache?.content?.[0]?.content) { |
| 254 | + setValue('description', JSON.stringify(cache)) |
| 255 | + trigger() |
| 256 | + } |
| 257 | + }} |
| 258 | + > |
| 259 | + {`Publish${isSubmitting ? '...' :''}`} |
| 260 | + </button> |
| 261 | + </div> |
| 262 | + <div> |
| 263 | + <label> |
| 264 | + { |
| 265 | + !preview && |
| 266 | + <div className="inline-flex bg-gray-200 text-gray-500 rounded-full px-2 cursor-pointer"> |
| 267 | + <svg viewBox="0 0 24 24" className="w-3"> |
| 268 | + <path |
| 269 | + d="M12 1.5v21M1.5 12h21" |
| 270 | + stroke="#343F44" |
| 271 | + strokeLinecap="round" |
| 272 | + strokeLinejoin="round" |
| 273 | + fill="none" |
| 274 | + fillRule="evenodd" |
| 275 | + /> |
| 276 | + </svg> |
| 277 | + <span className="inline-block p-1 text-sm"> |
| 278 | + Add feature image |
| 279 | + </span> |
| 280 | + </div> |
| 281 | + } |
| 282 | + <input type="file" name="Asset" className="hidden" {...register("files")} /> |
| 283 | + { |
| 284 | + preview && |
| 285 | + <img className="lg:h-48 md:h-36 w-full object-cover object-center cursor-pointer" src={preview} /> |
| 286 | + } |
| 287 | + </label> |
25 | 288 | </div>
|
26 |
| - <span className="ml-2 text-gray-800">Author</span> |
27 |
| - <span className="ml-2 bg-gray-200 text-gray-500 rounded-full inline-block p-1 px-2 text-sm">0xEddsad</span> |
28 |
| - </div> |
29 |
| - <Tiptap /> |
| 289 | + <div className="pt-12"> |
| 290 | + <TextareaAutosize |
| 291 | + placeholder="Give a title" |
| 292 | + className="border-0 outline-0 text-5xl w-full resize-none" {...register("name")} |
| 293 | + /> |
| 294 | + </div> |
| 295 | + <div className="my-6 mb-4"> |
| 296 | + <div className="text-gray-800 flex items-center"> |
| 297 | + <div className="w-8 h-8 rounded-full inline-flex items-center justify-center bg-gray-200 text-gray-400"> |
| 298 | + <svg fill="none" stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" |
| 299 | + className="w-4 h-4" viewBox="0 0 24 24"> |
| 300 | + <path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/> |
| 301 | + <circle cx="12" cy="7" r="4"/> |
| 302 | + </svg> |
| 303 | + </div> |
| 304 | + <span |
| 305 | + className="mx-2 bg-gray-200 text-gray-500 rounded-full inline-block p-1 px-2 text-sm"> |
| 306 | + { account } |
| 307 | + </span> |
| 308 | + <CreatableSelect |
| 309 | + id='create-authors' |
| 310 | + styles={customStyles} |
| 311 | + placeholder="Input Authors" |
| 312 | + onChange={handleAuthorsChange} |
| 313 | + options={authorsInLocal ?? []} |
| 314 | + /> |
| 315 | + </div> |
| 316 | + <div className="pt-3 flex justify-start items-center text-gray-500"> |
| 317 | + <span>Tags:</span> |
| 318 | + <CreatableSelect |
| 319 | + id='create-tags' |
| 320 | + styles={customStyles} |
| 321 | + isMulti |
| 322 | + placeholder="Input Post Tags" |
| 323 | + onChange={handleTagsChange} |
| 324 | + // @ts-ignore |
| 325 | + options={tagsInLocal ?? []} |
| 326 | + /> |
| 327 | + </div> |
| 328 | + </div> |
| 329 | + <Tiptap /> |
| 330 | + </form> |
| 331 | + <Dialog as='div' open={isOpen} onClose={() => setIsOpen(false)}> |
| 332 | + <Dialog.Overlay /> |
| 333 | + |
| 334 | + <Dialog.Title>Deactivate account</Dialog.Title> |
| 335 | + <Dialog.Description> |
| 336 | + This will permanently deactivate your account |
| 337 | + </Dialog.Description> |
| 338 | + |
| 339 | + <p> |
| 340 | + Are you sure you want to deactivate your account? All of your data will |
| 341 | + be permanently removed. This action cannot be undone. |
| 342 | + </p> |
| 343 | + |
| 344 | + <button onClick={() => setIsOpen(false)}>Deactivate</button> |
| 345 | + <button onClick={() => setIsOpen(false)}>Cancel</button> |
| 346 | + </Dialog> |
30 | 347 | </div>
|
31 | 348 | )
|
32 | 349 | })
|
|
0 commit comments