diff --git a/src/assets/categories/categories.json b/src/assets/categories/categories.json index 15f8314..9166cb5 100644 --- a/src/assets/categories/categories.json +++ b/src/assets/categories/categories.json @@ -1,13 +1,19 @@ [ - { "name": "gameplay ", "sub": ["difficulty"]}, - { "name": "input", "sub": ["key mapping", "macro"]}, - { "name": "visual", "sub": ["textures", "lighting", "character models", "environment models"] }, + { "name": "gameplay ", "sub": ["difficulty"] }, + { "name": "input", "sub": ["key mapping", "macro"] }, + { + "name": "visual", + "sub": ["textures", "lighting", "character models", "environment models"] + }, { "name": "audio", "sub": ["sfx", "music", "voice"] }, { "name": "user interface", "sub": ["hud", "menu"] }, - { "name": "quality of life", "sub": ["bug fixes", "performance", "accessibility"] }, + { + "name": "quality of life", + "sub": ["bug fixes", "performance", "accessibility"] + }, "total conversions", "translation", "multiplayer", "clothing", - "Mod Manager" -] \ No newline at end of file + "mod manager" +] diff --git a/src/components/ModForm.tsx b/src/components/ModForm.tsx index 914db43..a7c3699 100644 --- a/src/components/ModForm.tsx +++ b/src/components/ModForm.tsx @@ -22,7 +22,7 @@ import { ModFormState, ModPageLoaderResult } from '../types' -import { initializeFormState } from '../utils' +import { initializeFormState, MOD_DRAFT_CACHE_KEY } from '../utils' import { CheckboxField, InputField, InputFieldWithImageUpload } from './Inputs' import { OriginalAuthor } from './OriginalAuthor' import { CategoryAutocomplete } from './CategoryAutocomplete' @@ -31,6 +31,7 @@ import { Editor, EditorRef } from './Markdown/Editor' import { MEDIA_OPTIONS } from 'controllers' import { InputError } from './Inputs/Error' import { ImageUpload } from './Inputs/ImageUpload' +import { useLocalCache } from 'hooks/useLocalCache' interface GameOption { value: string @@ -45,9 +46,19 @@ export const ModForm = () => { const submit = useSubmit() const games = useGames() const [gameOptions, setGameOptions] = useState<GameOption[]>([]) + + // Enable cache for the new mod + const isEditing = typeof mod !== 'undefined' + const [cache, setCache, clearCache] = + useLocalCache<ModFormState>(MOD_DRAFT_CACHE_KEY) const [formState, setFormState] = useState<ModFormState>( - initializeFormState(mod) + isEditing ? initializeFormState(mod) : cache ? cache : initializeFormState() ) + + useEffect(() => { + !isEditing && setCache(formState) + }, [formState, isEditing, setCache]) + const editorRef = useRef<EditorRef>(null) useEffect(() => { @@ -145,45 +156,42 @@ export const ModForm = () => { ) const [showConfirmPopup, setShowConfirmPopup] = useState<boolean>(false) - const handleReset = () => { + const handleReset = useCallback(() => { setShowConfirmPopup(true) - } - const handleResetConfirm = (confirm: boolean) => { - setShowConfirmPopup(false) + }, []) + const handleResetConfirm = useCallback( + (confirm: boolean) => { + setShowConfirmPopup(false) - // Cancel if not confirmed - if (!confirm) return + // Cancel if not confirmed + if (!confirm) return - // Editing - if (mod) { - const initial = initializeFormState(mod) + // Reset fields to the initial or original existing data + const initialState = initializeFormState(mod) // Reset editor - editorRef.current?.setMarkdown(initial.body) + editorRef.current?.setMarkdown(initialState.body) + setFormState(initialState) - // Reset fields to the original existing data - setFormState(initial) - return - } + // Clear cache + !isEditing && clearCache() + }, + [clearCache, isEditing, mod] + ) - // New - set form state to the initial (clear form state) - setFormState(initializeFormState()) - } - const handlePublish = () => { - submit(JSON.stringify(formState), { - method: mod ? 'put' : 'post', - encType: 'application/json' - }) - } + const handlePublish = useCallback( + (e: React.FormEvent<HTMLFormElement>) => { + e.preventDefault() + submit(JSON.stringify(formState), { + method: isEditing ? 'put' : 'post', + encType: 'application/json' + }) + }, + [formState, isEditing, submit] + ) return ( - <form - className='IBMSMSMBS_Write' - onSubmit={(e) => { - e.preventDefault() - handlePublish() - }} - > + <form className='IBMSMSMBS_Write' onSubmit={handlePublish}> <GameDropdown options={gameOptions} selected={formState?.game} @@ -406,7 +414,7 @@ export const ModForm = () => { navigation.state === 'loading' || navigation.state === 'submitting' } > - {mod ? 'Reset' : 'Clear fields'} + {isEditing ? 'Reset' : 'Clear fields'} </button> <button className='btn btnMain' diff --git a/src/hooks/index.ts b/src/hooks/index.ts index c01237e..e0f6038 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -9,3 +9,4 @@ export * from './useNDKContext' export * from './useScrollDisable' export * from './useLocalStorage' export * from './useSessionStorage' +export * from './useLocalCache' diff --git a/src/hooks/useLocalCache.tsx b/src/hooks/useLocalCache.tsx new file mode 100644 index 0000000..7dc1d9a --- /dev/null +++ b/src/hooks/useLocalCache.tsx @@ -0,0 +1,37 @@ +import { useCallback, useEffect, useState } from 'react' +import { setLocalStorageItem, removeLocalStorageItem } from 'utils' + +export function useLocalCache<T>( + key: string +): [ + T | undefined, + React.Dispatch<React.SetStateAction<T | undefined>>, + () => void +] { + const [cache, setCache] = useState<T | undefined>(() => { + const storedValue = window.localStorage.getItem(key) + if (storedValue === null) return undefined + + // Parse the value + const parsedStoredValue = JSON.parse(storedValue) + return parsedStoredValue + }) + + useEffect(() => { + try { + if (cache) { + setLocalStorageItem(key, JSON.stringify(cache)) + } else { + removeLocalStorageItem(key) + } + } catch (e) { + console.warn(e) + } + }, [cache, key]) + + const clearCache = useCallback(() => { + setCache(undefined) + }, []) + + return [cache, setCache, clearCache] +} diff --git a/src/hooks/useLocalStorage.tsx b/src/hooks/useLocalStorage.tsx index 10579cd..da1c641 100644 --- a/src/hooks/useLocalStorage.tsx +++ b/src/hooks/useLocalStorage.tsx @@ -1,6 +1,7 @@ import React, { useMemo } from 'react' import { getLocalStorageItem, + mergeWithInitialValue, removeLocalStorageItem, setLocalStorageItem } from 'utils' @@ -10,17 +11,6 @@ const useLocalStorageSubscribe = (callback: () => void) => { return () => window.removeEventListener('storage', callback) } -function mergeWithInitialValue<T>(storedValue: T, initialValue: T): T { - if ( - !Array.isArray(storedValue) && - typeof storedValue === 'object' && - storedValue !== null - ) { - return { ...initialValue, ...storedValue } - } - return storedValue -} - export function useLocalStorage<T>( key: string, initialValue: T diff --git a/src/hooks/useSessionStorage.tsx b/src/hooks/useSessionStorage.tsx index cc0756f..ca194e6 100644 --- a/src/hooks/useSessionStorage.tsx +++ b/src/hooks/useSessionStorage.tsx @@ -1,6 +1,7 @@ import React, { useMemo } from 'react' import { getSessionStorageItem, + mergeWithInitialValue, removeSessionStorageItem, setSessionStorageItem } from 'utils' @@ -10,17 +11,6 @@ const useSessionStorageSubscribe = (callback: () => void) => { return () => window.removeEventListener('sessionStorage', callback) } -function mergeWithInitialValue<T>(storedValue: T, initialValue: T): T { - if ( - !Array.isArray(storedValue) && - typeof storedValue === 'object' && - storedValue !== null - ) { - return { ...initialValue, ...storedValue } - } - return storedValue -} - export function useSessionStorage<T>( key: string, initialValue: T diff --git a/src/pages/submitMod/action.ts b/src/pages/submitMod/action.ts index a731d0c..ab5af31 100644 --- a/src/pages/submitMod/action.ts +++ b/src/pages/submitMod/action.ts @@ -12,7 +12,9 @@ import { isValidUrl, log, LogType, - now + MOD_DRAFT_CACHE_KEY, + now, + removeLocalStorageItem } from 'utils' import { v4 as uuidv4 } from 'uuid' import { T_TAG_VALUE } from '../../constants' @@ -141,6 +143,8 @@ export const submitModRouteAction = )}` ) + !isEditing && removeLocalStorageItem(MOD_DRAFT_CACHE_KEY) + const naddr = nip19.naddrEncode({ identifier: aTag, pubkey: signedEvent.pubkey, @@ -209,13 +213,6 @@ const validateState = async ( } } - if ( - formState.repost && - (!formState.originalAuthor || formState.originalAuthor === '') - ) { - errors.originalAuthor = 'Original author field can not be empty' - } - if (!formState.tags || formState.tags === '') { errors.tags = 'Tags field can not be empty' } diff --git a/src/pages/write/action.ts b/src/pages/write/action.ts index e0f33d3..f08a3fb 100644 --- a/src/pages/write/action.ts +++ b/src/pages/write/action.ts @@ -3,12 +3,13 @@ import { ActionFunctionArgs, redirect } from 'react-router-dom' import { getBlogPageRoute } from 'routes' import { BlogFormErrors, BlogEventSubmitForm, BlogEventEditForm } from 'types' import { + BLOG_DRAFT_CACHE_KEY, isReachable, isValidImageUrl, log, LogType, now, - parseFormData + removeLocalStorageItem } from 'utils' import { kinds, UnsignedEvent, Event, nip19 } from 'nostr-tools' import { toast } from 'react-toastify' @@ -43,12 +44,9 @@ export const writeRouteAction = } // Get the form data from submit request - const formData = await request.formData() - - // Parse the the data - const formSubmit = parseFormData<BlogEventSubmitForm | BlogEventEditForm>( - formData - ) + const formSubmit = (await request.json()) as + | BlogEventSubmitForm + | BlogEventEditForm // Check for errors const formErrors = await validateFormData(formSubmit) @@ -80,7 +78,7 @@ export const writeRouteAction = const tTags = formSubmit .tags!.toLowerCase() .split(',') - .map((t) => ['t', t]) + .map((t) => ['t', t.trim()]) const tags = [ ['d', uuid], @@ -95,7 +93,7 @@ export const writeRouteAction = // Add NSFW tag, L label namespace standardized tag // https://github.com/nostr-protocol/nips/blob/2838e3bd51ac00bd63c4cef1601ae09935e7dd56/README.md#standardized-tags - if (formSubmit.nsfw === 'on') tags.push(['L', 'content-warning']) + if (formSubmit.nsfw) tags.push(['L', 'content-warning']) const unsignedEvent: UnsignedEvent = { kind: kinds.LongFormArticle, @@ -128,6 +126,9 @@ export const writeRouteAction = '\n' )}` ) + + !isEditing && removeLocalStorageItem(BLOG_DRAFT_CACHE_KEY) + const naddr = nip19.naddrEncode({ identifier: uuid, pubkey: signedEvent.pubkey, diff --git a/src/pages/write/index.tsx b/src/pages/write/index.tsx index fe4f586..1d505b9 100644 --- a/src/pages/write/index.tsx +++ b/src/pages/write/index.tsx @@ -1,60 +1,123 @@ -import { useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { - Form, useActionData, useLoaderData, - useNavigation + useNavigation, + useSubmit } from 'react-router-dom' import { - CheckboxFieldUncontrolled, - InputFieldUncontrolled, + CheckboxField, + InputField, InputFieldWithImageUpload -} from '../../components/Inputs' -import { ProfileSection } from '../../components/ProfileSection' -import { useAppSelector } from '../../hooks' -import { BlogFormErrors, BlogPageLoaderResult } from 'types' -import '../../styles/innerPage.css' -import '../../styles/styles.css' -import '../../styles/write.css' +} from 'components/Inputs' +import { ProfileSection } from 'components/ProfileSection' +import { useAppSelector, useLocalCache } from 'hooks' +import { + BlogEventEditForm, + BlogEventSubmitForm, + BlogFormErrors, + BlogPageLoaderResult +} from 'types' import { LoadingSpinner } from 'components/LoadingSpinner' import { AlertPopup } from 'components/AlertPopup' import { Editor, EditorRef } from 'components/Markdown/Editor' import { InputError } from 'components/Inputs/Error' +import { BLOG_DRAFT_CACHE_KEY, initializeBlogForm } from 'utils' +import 'styles/innerPage.css' +import 'styles/styles.css' +import 'styles/write.css' export const WritePage = () => { const userState = useAppSelector((state) => state.user) const data = useLoaderData() as BlogPageLoaderResult + const formErrors = useActionData() as BlogFormErrors const navigation = useNavigation() + const submit = useSubmit() const blog = data?.blog - const title = data?.blog ? 'Edit blog post' : 'Submit a blog post' - const [content, setContent] = useState(blog?.content || '') - const [image, setImage] = useState(blog?.image || '') - const formRef = useRef<HTMLFormElement>(null) + // Enable cache for the new blog + const isEditing = typeof data?.blog !== 'undefined' + const [cache, setCache, clearCache] = + useLocalCache<BlogEventSubmitForm>(BLOG_DRAFT_CACHE_KEY) + + const title = isEditing ? 'Edit blog post' : 'Submit a blog post' + const [formState, setFormState] = useState< + BlogEventSubmitForm | BlogEventEditForm + >(isEditing ? initializeBlogForm(blog) : cache ? cache : initializeBlogForm()) + + useEffect(() => { + !isEditing && setCache(formState) + }, [formState, isEditing, setCache]) + const editorRef = useRef<EditorRef>(null) const [showConfirmPopup, setShowConfirmPopup] = useState<boolean>(false) - const handleReset = () => { + const handleReset = useCallback(() => { setShowConfirmPopup(true) - } - const handleResetConfirm = (confirm: boolean) => { - setShowConfirmPopup(false) + }, []) + const handleResetConfirm = useCallback( + (confirm: boolean) => { + setShowConfirmPopup(false) - // Cancel if not confirmed - if (!confirm) return + // Cancel if not confirmed + if (!confirm) return - // Reset featured image - setImage(blog?.image || '') + const initialState = initializeBlogForm(blog) - // Reset editor - if (blog?.content) { - editorRef.current?.setMarkdown(blog?.content) - } + // Reset editor + editorRef.current?.setMarkdown(initialState.content) + setFormState(initialState) - formRef.current?.reset() - } + // Clear cache + !isEditing && clearCache() + }, + [blog, clearCache, isEditing] + ) + + const handleImageChange = useCallback((_name: string, value: string) => { + setFormState((prev) => ({ + ...prev, + image: value + })) + }, []) + + const handleInputChange = useCallback((name: string, value: string) => { + setFormState((prevState) => ({ + ...prevState, + [name]: value + })) + }, []) + + const handleEditorChange = useCallback( + (md: string) => { + handleInputChange('content', md) + }, + [handleInputChange] + ) + + const handleCheckboxChange = useCallback( + (e: React.ChangeEvent<HTMLInputElement>) => { + const { name, checked } = e.target + setFormState((prevState) => ({ + ...prevState, + [name]: checked + })) + }, + [] + ) + + const handleFormSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault() + submit(JSON.stringify(formState), { + method: isEditing ? 'put' : 'post', + encType: 'application/json' + }) + }, + [formState, isEditing, submit] + ) return ( <div className='InnerBodyMain'> @@ -71,82 +134,63 @@ export const WritePage = () => { {navigation.state === 'submitting' && ( <LoadingSpinner desc='Publishing blog to relays' /> )} - <Form - ref={formRef} - className='IBMSMSMBS_Write' - method={blog ? 'put' : 'post'} - > - <InputFieldUncontrolled + <form className='IBMSMSMBS_Write' onSubmit={handleFormSubmit}> + <InputField label='Title' name='title' - defaultValue={blog?.title} + value={formState.title} error={formErrors?.title} + onChange={handleInputChange} + placeholder='Blog title' /> <div className='inputLabelWrapperMain'> <label className='form-label labelMain'>Content</label> <div className='inputMain'> <Editor ref={editorRef} - markdown={content} - onChange={(md) => { - setContent(md) - }} + markdown={formState.content} + onChange={handleEditorChange} /> </div> {typeof formErrors?.content !== 'undefined' && ( <InputError message={formErrors?.content} /> )} - {/* encode to keep the markdown formatting */} - <input - name='content' - hidden - value={encodeURIComponent(content)} - readOnly - /> </div> <InputFieldWithImageUpload label='Featured Image URL' name='image' inputMode='url' - value={image} + value={formState.image} error={formErrors?.image} - onInputChange={(_, value) => setImage(value)} + onInputChange={handleImageChange} placeholder='Image URL' /> - <InputFieldUncontrolled + <InputField label='Summary' name='summary' type='textarea' - defaultValue={blog?.summary} + value={formState.summary} error={formErrors?.summary} + onChange={handleInputChange} + placeholder={'This is a quick description of my blog'} /> - <InputFieldUncontrolled + <InputField label='Tags' description='Separate each tag with a comma. (Example: tag1, tag2, tag3)' placeholder='Tags' name='tags' - defaultValue={blog?.tTags?.join(', ')} + value={formState.tags} error={formErrors?.tags} + onChange={handleInputChange} /> - <CheckboxFieldUncontrolled + <CheckboxField label='This post is not safe for work (NSFW)' name='nsfw' - defaultChecked={blog?.nsfw} + isChecked={formState.nsfw} + handleChange={handleCheckboxChange} + type='stylized' /> - {typeof blog?.dTag !== 'undefined' && ( - <input name='dTag' hidden value={blog.dTag} readOnly /> - )} - {typeof blog?.rTag !== 'undefined' && ( - <input name='rTag' hidden value={blog.rTag} readOnly /> - )} - {typeof blog?.published_at !== 'undefined' && ( - <input - name='published_at' - hidden - value={blog.published_at} - readOnly - /> - )} + <div className='IBMSMSMBS_WriteAction'> <button className='btn btnMain' @@ -157,7 +201,7 @@ export const WritePage = () => { navigation.state === 'submitting' } > - {blog ? 'Reset' : 'Clear fields'} + {isEditing ? 'Reset' : 'Clear fields'} </button> <button className='btn btnMain' @@ -178,13 +222,13 @@ export const WritePage = () => { handleClose={() => setShowConfirmPopup(false)} header={'Are you sure?'} label={ - blog + isEditing ? `Are you sure you want to clear all changes?` : `Are you sure you want to clear all field data?` } /> )} - </Form> + </form> </div> {userState.auth && userState.user?.pubkey && ( <ProfileSection pubkey={userState.user.pubkey as string} /> diff --git a/src/types/blog.ts b/src/types/blog.ts index 075a63c..813c76c 100644 --- a/src/types/blog.ts +++ b/src/types/blog.ts @@ -20,9 +20,7 @@ export interface BlogDetails extends BlogForm { tTags: string[] } -export interface BlogEventSubmitForm extends Omit<BlogForm, 'nsfw'> { - nsfw: string -} +export interface BlogEventSubmitForm extends BlogForm {} export interface BlogEventEditForm extends BlogEventSubmitForm { dTag: string diff --git a/src/utils/blog.ts b/src/utils/blog.ts index 089e058..8d54f65 100644 --- a/src/utils/blog.ts +++ b/src/utils/blog.ts @@ -1,5 +1,10 @@ import { NDKEvent } from '@nostr-dev-kit/ndk' -import { BlogCardDetails, BlogDetails } from 'types' +import { + BlogCardDetails, + BlogDetails, + BlogEventEditForm, + BlogEventSubmitForm +} from 'types' import { getFirstTagValue, getFirstTagValueAsInt, getTagValues } from './nostr' import { kinds, nip19 } from 'nostr-tools' @@ -50,3 +55,25 @@ export const extractBlogCardDetails = ( : undefined } } + +export const initializeBlogForm = ( + blog?: Partial<BlogDetails> +): BlogEventSubmitForm | BlogEventEditForm => ({ + content: blog?.content || '', + image: blog?.image || '', + nsfw: blog?.nsfw || false, + summary: blog?.summary || '', + title: blog?.title || '', + tags: blog?.tTags?.join(', ') || '', + ...(blog?.aTag && { + aTag: blog.aTag + }), + ...(blog?.dTag && { + dTag: blog.dTag + }), + ...(blog?.published_at && { + published_at: blog.published_at + }) +}) + +export const BLOG_DRAFT_CACHE_KEY = 'draft-blog' diff --git a/src/utils/mod.ts b/src/utils/mod.ts index 71ceb39..dfa15ba 100644 --- a/src/utils/mod.ts +++ b/src/utils/mod.ts @@ -1,3 +1,4 @@ +import _ from 'lodash' import { NDKEvent } from '@nostr-dev-kit/ndk' import { Event } from 'nostr-tools' import { ModDetails, ModFormState } from '../types' @@ -131,16 +132,20 @@ export const initializeFormState = ( originalAuthor: existingModData?.originalAuthor || undefined, screenshotsUrls: existingModData?.screenshotsUrls || [''], tags: existingModData?.tags.join(',') || '', - lTags: existingModData?.lTags || [], - LTags: existingModData?.LTags || [], - downloadUrls: existingModData?.downloadUrls || [ - { - url: '', - hash: '', - signatureKey: '', - malwareScanLink: '', - modVersion: '', - customNote: '' - } - ] + lTags: existingModData ? _.clone(existingModData.lTags) : [], + LTags: existingModData ? _.clone(existingModData.lTags) : [], + downloadUrls: existingModData + ? _.cloneDeep(existingModData.downloadUrls) + : [ + { + url: '', + hash: '', + signatureKey: '', + malwareScanLink: '', + modVersion: '', + customNote: '' + } + ] }) + +export const MOD_DRAFT_CACHE_KEY = 'draft-mod' diff --git a/src/utils/utils.ts b/src/utils/utils.ts index e28027d..2f19e8e 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -180,3 +180,14 @@ export const getFallbackPubkey = () => { // Silently ignore } } + +export function mergeWithInitialValue<T>(storedValue: T, initialValue: T): T { + if ( + !Array.isArray(storedValue) && + typeof storedValue === 'object' && + storedValue !== null + ) { + return { ...initialValue, ...storedValue } + } + return storedValue +}