refactor submit page

This commit is contained in:
Tom (plebeius.eth)
2025-04-04 14:15:10 +02:00
parent 812b1fef1f
commit 5beffbd45b

View File

@@ -1,30 +1,32 @@
import { ChangeEvent, useCallback, useEffect, useState, useRef } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { Trans, useTranslation } from 'react-i18next';
import { Capacitor } from '@capacitor/core';
import { useCallback, useEffect, useState } from 'react';
import { useDropzone } from 'react-dropzone';
import Plebbit from '@plebbit/plebbit-js/dist/browser/index.js';
import { Trans, useTranslation } from 'react-i18next';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { useAccount, usePublishComment, useSubplebbit } from '@plebbit/plebbit-react-hooks';
import Plebbit from '@plebbit/plebbit-js/dist/browser/index.js';
import { Capacitor } from '@capacitor/core';
import FileUploader from '../../plugins/file-uploader';
import { getLinkMediaInfo } from '../../lib/utils/media-utils';
import { isValidURL } from '../../lib/utils/url-utils';
import usePublishPostStore from '../../stores/use-publish-post-store';
import { useDefaultSubplebbitAddresses } from '../../hooks/use-default-subplebbits';
import useIsSubplebbitOffline from '../../hooks/use-is-subplebbit-offline';
import { getLinkMediaInfo } from '../../lib/utils/media-utils';
import { isValidURL } from '../../lib/utils/url-utils';
import Embed from '../../components/post/embed';
import LoadingEllipsis from '../../components/loading-ellipsis';
import Markdown from '../../components/markdown';
import FileUploader from '../../plugins/file-uploader';
import Embed from '../../components/post/embed';
import styles from './submit-page.module.css';
const isAndroid = Capacitor.getPlatform() === 'android';
const isElectron = window.isElectron === true;
const warningMessage =
'This feature cannot work in browsers. It is only available on Seedit Android app, or desktop app (win/mac/linux) versions.\n\nGo to the download links page on GitHub?';
const UrlField = ({ url, setUrl, urlRef }: { url: string; setUrl: (url: string) => void; urlRef: React.RefObject<HTMLInputElement> }) => {
const UrlField = () => {
const { t } = useTranslation();
const { setPublishPostStore } = usePublishPostStore();
const { link: url, setPublishPostStore } = usePublishPostStore();
const [mediaError, setMediaError] = useState(false);
const mediaInfo = getLinkMediaInfo(url);
const mediaInfo = url ? getLinkMediaInfo(url) : null;
const mediaType = mediaInfo?.type;
let mediaComponent;
@@ -38,11 +40,11 @@ const UrlField = ({ url, setUrl, urlRef }: { url: string; setUrl: (url: string)
} else if (mediaType === 'audio') {
mediaComponent = <audio src={url} controls />;
} else if (mediaType === 'iframe') {
mediaComponent = <Embed url={url} />;
mediaComponent = <Embed url={url || ''} />;
}
return (
<>
<div className={styles.box}>
{url && isValidURL(url) ? (
<span className={styles.boxTitleOptional}>{mediaType}</span>
) : (
@@ -53,12 +55,11 @@ const UrlField = ({ url, setUrl, urlRef }: { url: string; setUrl: (url: string)
)}
<div className={styles.boxContent}>
{url && (
<span className={styles.urlCancelButton} onClick={() => setUrl('')}>
<span className={styles.urlCancelButton} onClick={() => setPublishPostStore({ link: undefined })}>
x
</span>
)}
<input
ref={urlRef}
className={`${styles.input} ${styles.inputUrl}`}
type='text'
value={url ?? ''}
@@ -66,9 +67,8 @@ const UrlField = ({ url, setUrl, urlRef }: { url: string; setUrl: (url: string)
autoComplete='off'
spellCheck='false'
onChange={(e) => {
setUrl(e.target.value);
setMediaError(false);
setPublishPostStore({ link: e.target.value });
setMediaError(false);
}}
/>
{url && isValidURL(url) ? (
@@ -77,14 +77,11 @@ const UrlField = ({ url, setUrl, urlRef }: { url: string; setUrl: (url: string)
<div className={styles.description}>{t('submit_url_description')}</div>
)}
</div>
</>
</div>
);
};
const warningMessage =
'This feature cannot work in browsers. It is only available on Seedit Android app, or desktop app (win/mac/linux) versions.\n\nGo to the download links page on GitHub?';
const UploadMediaForm = ({ setUrl }: { setUrl: (url: string) => void }) => {
const UploadMediaForm = () => {
const { t } = useTranslation();
const { setPublishPostStore } = usePublishPostStore();
@@ -129,14 +126,12 @@ const UploadMediaForm = ({ setUrl }: { setUrl: (url: string) => void }) => {
const result = await FileUploader.uploadMedia(fileData as { fileData?: string; fileName: string });
if (result.url) {
setUrl(result.url);
setPublishPostStore({ link: result.url || undefined });
}
} else if (isAndroid) {
// android can handle File objects directly
const result = await FileUploader.uploadMedia(acceptedFiles[0]);
if (result.url) {
setUrl(result.url);
setPublishPostStore({ link: result.url || undefined });
}
}
@@ -150,7 +145,7 @@ const UploadMediaForm = ({ setUrl }: { setUrl: (url: string) => void }) => {
}
}
},
[setUrl, setPublishPostStore, t],
[setPublishPostStore, t],
);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
@@ -189,7 +184,6 @@ const UploadMediaForm = ({ setUrl }: { setUrl: (url: string) => void }) => {
});
if (uploadResult?.url) {
setUrl(uploadResult.url);
setPublishPostStore({ link: uploadResult.url });
} else {
throw new Error('No URL returned from upload');
@@ -208,7 +202,7 @@ const UploadMediaForm = ({ setUrl }: { setUrl: (url: string) => void }) => {
};
return (
<>
<div className={styles.box}>
<span className={styles.boxTitleOptional}>image/video/audio</span>
<div className={styles.boxContent}>
{isUploading ? (
@@ -227,20 +221,228 @@ const UploadMediaForm = ({ setUrl }: { setUrl: (url: string) => void }) => {
</div>
)}
</div>
</>
</div>
);
};
const Submit = () => {
const TitleField = () => {
const { t } = useTranslation();
const { title, setPublishPostStore } = usePublishPostStore();
return (
<div className={styles.box}>
<span className={styles.boxTitleRequired}>{t('title')}</span>
<div className={styles.boxContent}>
<textarea
className={`${styles.input} ${styles.inputTitle}`}
value={title}
onChange={(e) => {
setPublishPostStore({ title: e.target.value });
}}
/>
</div>
</div>
);
};
const ContentField = () => {
const { t } = useTranslation();
const [showPreview, setShowPreview] = useState(false);
const { content, setPublishPostStore } = usePublishPostStore();
return (
<div className={styles.box}>
<span className={styles.boxTitleOptional}>{t('text')}</span>
<span className={styles.optional}> ({t('optional')})</span>
<div className={styles.boxContent}>
{!showPreview ? (
<textarea
className={`${styles.input} ${styles.inputText}`}
value={content || ''}
onChange={(e) => {
setPublishPostStore({ content: e.target.value });
}}
/>
) : (
<div className={styles.contentPreview}>
<div className={styles.contentPreviewMarkdown}>
<Markdown content={content || ''} />
</div>
</div>
)}
<button className={styles.previewButton} disabled={!content} onClick={() => setShowPreview(!showPreview)}>
{showPreview ? t('edit') : t('preview')}
</button>
</div>
</div>
);
};
const SubplebbitAddressField = () => {
const { t } = useTranslation();
const { subscriptions } = useAccount() || {};
const defaultSubplebbitAddresses = useDefaultSubplebbitAddresses();
const { subplebbitAddress: inputAddress, setPublishPostStore } = usePublishPostStore();
const filteredSubplebbitAddresses = defaultSubplebbitAddresses.filter((address) => address?.toLowerCase()?.includes(inputAddress?.toLowerCase() || '')).slice(0, 10);
const [isInputAddressFocused, setIsInputAddressFocused] = useState(false);
const [activeDropdownIndex, setActiveDropdownIndex] = useState<number>(-1);
// show list of random subplebbits only once when the component mounts
const [randomSubplebbitSuggestions, setRandomSubplebbitSuggestions] = useState<string[]>([]);
useEffect(() => {
const generatedSubplebbits = getRandomSubplebbits(defaultSubplebbitAddresses, 10);
setRandomSubplebbitSuggestions(generatedSubplebbits);
}, [defaultSubplebbitAddresses]);
const listSource = subscriptions?.length > 5 ? subscriptions : randomSubplebbitSuggestions;
const defaultSubplebbitsDropdown = inputAddress && (
<ul className={styles.dropdown}>
{filteredSubplebbitAddresses.map((subplebbitAddress, index) => (
<li
key={subplebbitAddress}
className={`${styles.dropdownItem} ${index === activeDropdownIndex ? styles.activeDropdownItem : ''}`}
onClick={() => handleSubplebbitSelect(subplebbitAddress)}
onMouseEnter={() => setActiveDropdownIndex(index)}
>
{subplebbitAddress}
</li>
))}
</ul>
);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'ArrowDown') {
setActiveDropdownIndex((prevIndex) => (prevIndex < filteredSubplebbitAddresses.length - 1 ? prevIndex + 1 : prevIndex));
} else if (e.key === 'ArrowUp') {
setActiveDropdownIndex((prevIndex) => (prevIndex > 0 ? prevIndex - 1 : 0));
} else if (e.key === 'Enter') {
if (activeDropdownIndex !== -1) {
const selectedAddress = filteredSubplebbitAddresses[activeDropdownIndex];
setPublishPostStore({ subplebbitAddress: selectedAddress });
}
setActiveDropdownIndex(-1);
setIsInputAddressFocused(false);
}
},
[filteredSubplebbitAddresses, activeDropdownIndex, setPublishPostStore],
);
useEffect(() => {
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [handleKeyDown]);
const handleSubplebbitSelect = (subplebbitAddress: string) => {
setPublishPostStore({ subplebbitAddress: subplebbitAddress });
setIsInputAddressFocused(false);
setActiveDropdownIndex(-1);
};
const getRandomSubplebbits = (addresses: string[], count: number) => {
let shuffled = addresses.sort(() => 0.5 - Math.random());
return shuffled.slice(0, count);
};
return (
<div className={styles.box}>
<span className={styles.boxTitleRequired}>{t('submit_choose')}</span>
<div className={styles.boxContent}>
<span className={styles.boxSubtitle}>{t('community_address')}:</span>
<input
className={`${styles.input} ${styles.inputCommunity}`}
type='text'
placeholder={`"community.eth/.sol" ${t('or')} "12D3KooW..."`}
value={inputAddress}
onChange={(e) => {
setPublishPostStore({ subplebbitAddress: e.target.value });
}}
autoCorrect='off'
autoComplete='off'
spellCheck='false'
onFocus={() => setIsInputAddressFocused(true)}
onBlur={() => setTimeout(() => setIsInputAddressFocused(false), 100)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
(e.target as HTMLInputElement).blur();
}
}}
/>
{inputAddress && isInputAddressFocused && defaultSubplebbitsDropdown}
<div className={styles.subsDescription}>{subscriptions?.length > 5 ? t('submit_subscriptions') : t('submit_subscriptions_notice')}</div>
<div className={styles.subs}>
{listSource.map((subscription: string) => (
<span
key={subscription}
className={styles.sub}
onClick={() => {
setPublishPostStore({ subplebbitAddress: subscription });
}}
>
{Plebbit.getShortAddress(subscription)}
</span>
))}
</div>
</div>
</div>
);
};
const RulesInfo = ({ shortAddress, rules }: { shortAddress: string; rules: string[] }) => {
const { t } = useTranslation();
return (
<div className={styles.box}>
<span className={`${styles.boxTitle} ${styles.rulesTitle}`}>
{t('rules_for')} p/{shortAddress}
</span>
<div className={styles.boxContent}>
<div className={styles.description}>
<ol className={styles.rules}>{rules?.map((rule: string, index: number) => <li key={index}>{rule}</li>)}</ol>
</div>
</div>
</div>
);
};
const SubmitOptions = () => {
const { t } = useTranslation();
const { setPublishPostStore } = usePublishPostStore();
return (
<div className={styles.box}>
<div className={styles.boxTitle}>{t('options')}</div>
<div className={styles.boxContent}>
<div className={styles.options}>
<div className={styles.option}>
<label>
<input type='checkbox' onChange={(e) => setPublishPostStore({ spoiler: e.target.checked })} />
{t('spoiler')}
</label>
</div>
<div className={styles.option}>
<label>
<input type='checkbox' onChange={(e) => setPublishPostStore({ nsfw: e.target.checked })} />
{t('nsfw')}
</label>
</div>
</div>
</div>
</div>
);
};
const SubmitPage = () => {
const { t } = useTranslation();
const params = useParams();
const navigate = useNavigate();
const { setPublishPostStore, resetPublishPostStore } = usePublishPostStore();
const [inputAddress, setInputAddress] = useState(params.subplebbitAddress || '');
const [selectedSubplebbit, setSelectedSubplebbit] = useState(params.subplebbitAddress || '');
const [url, setUrl] = useState('');
const urlRef = useRef<HTMLInputElement>(null);
const { link, title, subplebbitAddress, publishCommentOptions, setPublishPostStore, resetPublishPostStore } = usePublishPostStore();
useEffect(() => {
return () => {
@@ -249,28 +451,15 @@ const Submit = () => {
}, [resetPublishPostStore]);
useEffect(() => {
setInputAddress(params.subplebbitAddress || '');
setSelectedSubplebbit(params.subplebbitAddress || '');
setPublishPostStore({ subplebbitAddress: params.subplebbitAddress || '' });
}, [params.subplebbitAddress, setPublishPostStore]);
const {
title,
content,
link,
subplebbitAddress,
publishCommentOptions,
setPublishPostStore: setSubmitStoreHook,
resetPublishPostStore: resetSubmitStoreHook,
} = usePublishPostStore();
const { index, publishComment } = usePublishComment(publishCommentOptions);
const { subscriptions } = useAccount() || {};
const defaultSubplebbitAddresses = useDefaultSubplebbitAddresses();
const selectedSubplebbitData = useSubplebbit({ subplebbitAddress: selectedSubplebbit });
const selectedSubplebbitData = useSubplebbit({ subplebbitAddress });
const { shortAddress, rules } = selectedSubplebbitData;
const { isOffline, offlineTitle } = useIsSubplebbitOffline(selectedSubplebbitData);
const { index, publishComment } = usePublishComment(publishCommentOptions);
const onPublish = () => {
if (!title) {
alert(`Missing title`);
@@ -291,86 +480,17 @@ const Submit = () => {
// redirect to pending page when pending comment is created
useEffect(() => {
if (typeof index === 'number') {
resetSubmitStoreHook();
resetPublishPostStore();
navigate(`/profile/${index}`);
}
}, [index, resetSubmitStoreHook, navigate]);
const subsDescription = <div className={styles.subsDescription}>{subscriptions?.length > 5 ? t('submit_subscriptions') : t('submit_subscriptions_notice')}</div>;
const getRandomSubplebbits = (addresses: string[], count: number) => {
let shuffled = addresses.sort(() => 0.5 - Math.random());
return shuffled.slice(0, count);
};
// show list of random subplebbits only once when the component mounts
const [randomSubplebbits, setRandomSubplebbits] = useState<string[]>([]);
useEffect(() => {
const generatedSubplebbits = getRandomSubplebbits(defaultSubplebbitAddresses, 10);
setRandomSubplebbits(generatedSubplebbits);
}, [defaultSubplebbitAddresses]);
const listSource = subscriptions?.length > 5 ? subscriptions : randomSubplebbits;
const [activeDropdownIndex, setActiveDropdownIndex] = useState<number>(-1);
const [isInputAddressFocused, setIsInputAddressFocused] = useState(false);
const filteredSubplebbitAddresses = defaultSubplebbitAddresses.filter((address) => address?.toLowerCase()?.includes(inputAddress?.toLowerCase())).slice(0, 10);
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'ArrowDown') {
setActiveDropdownIndex((prevIndex) => (prevIndex < filteredSubplebbitAddresses.length - 1 ? prevIndex + 1 : prevIndex));
} else if (e.key === 'ArrowUp') {
setActiveDropdownIndex((prevIndex) => (prevIndex > 0 ? prevIndex - 1 : 0));
} else if (e.key === 'Enter') {
if (activeDropdownIndex !== -1) {
const selectedAddress = filteredSubplebbitAddresses[activeDropdownIndex];
setSelectedSubplebbit(selectedAddress);
setSubmitStoreHook({ subplebbitAddress: selectedAddress });
setInputAddress(selectedAddress);
}
setActiveDropdownIndex(-1);
setIsInputAddressFocused(false);
}
},
[filteredSubplebbitAddresses, activeDropdownIndex, setSubmitStoreHook],
);
useEffect(() => {
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, [handleKeyDown]);
const defaultSubplebbitsDropdown = inputAddress && (
<ul className={styles.dropdown}>
{filteredSubplebbitAddresses.map((subplebbitAddress, index) => (
<li
key={subplebbitAddress}
className={`${styles.dropdownItem} ${index === activeDropdownIndex ? styles.activeDropdownItem : ''}`}
onClick={() => handleSubplebbitSelect(subplebbitAddress)}
onMouseEnter={() => setActiveDropdownIndex(index)}
>
{subplebbitAddress}
</li>
))}
</ul>
);
const handleAddressChange = (e: ChangeEvent<HTMLInputElement>) => {
const newValue = e.target.value;
setInputAddress(newValue);
setSelectedSubplebbit(newValue);
setSubmitStoreHook({ subplebbitAddress: newValue });
};
}, [index, resetPublishPostStore, navigate]);
useEffect(() => {
window.scrollTo(0, 0);
}, []);
const documentTitle = t('submit_to_string', {
string: selectedSubplebbitData?.title || selectedSubplebbitData?.shortAddress || 'Seedit',
string: selectedSubplebbitData?.title || shortAddress || 'Seedit',
interpolation: { escapeValue: false },
});
@@ -378,16 +498,6 @@ const Submit = () => {
document.title = documentTitle;
}, [documentTitle]);
const handleSubplebbitSelect = (subplebbitAddress: string) => {
setSelectedSubplebbit(subplebbitAddress);
setInputAddress(subplebbitAddress);
setSubmitStoreHook({ subplebbitAddress: subplebbitAddress });
setIsInputAddressFocused(false);
setActiveDropdownIndex(-1);
};
const [showPreview, setShowPreview] = useState(false);
return (
<div className={styles.content}>
<h1>
@@ -395,135 +505,23 @@ const Submit = () => {
i18nKey='submit_to'
shouldUnescape={true}
values={{
link: selectedSubplebbitData?.title || selectedSubplebbitData?.shortAddress || 'seedit',
link: selectedSubplebbitData?.title || shortAddress || 'seedit',
}}
components={{
1: selectedSubplebbitData?.shortAddress ? (
<Link key={selectedSubplebbit} to={`/p/${selectedSubplebbit}`} className={styles.location} />
) : (
<span key={selectedSubplebbit} />
),
1: shortAddress ? <Link key={subplebbitAddress} to={`/p/${subplebbitAddress}`} className={styles.location} /> : <span key={subplebbitAddress} />,
}}
/>
</h1>
<div className={styles.form}>
<div className={styles.formContent}>
{isOffline && selectedSubplebbit && <div className={styles.infobar}>{offlineTitle}</div>}
<div className={styles.box}>
<UrlField url={url} setUrl={setUrl} urlRef={urlRef} />
</div>
{url.length === 0 && (
<div className={styles.box}>
<UploadMediaForm setUrl={setUrl} />
</div>
)}
<div className={styles.box}>
<span className={styles.boxTitleRequired}>{t('title')}</span>
<div className={styles.boxContent}>
<textarea
className={`${styles.input} ${styles.inputTitle}`}
onChange={(e) => {
setSubmitStoreHook({ title: e.target.value });
}}
/>
</div>
</div>
<div className={styles.box}>
<span className={styles.boxTitleOptional}>{t('text')}</span>
<span className={styles.optional}> ({t('optional')})</span>
<div className={styles.boxContent}>
{!showPreview ? (
<textarea
className={`${styles.input} ${styles.inputText}`}
value={content || ''}
onChange={(e) => {
setSubmitStoreHook({ content: e.target.value });
}}
/>
) : (
<div className={styles.contentPreview}>
<div className={styles.contentPreviewMarkdown}>
<Markdown content={content || ''} />
</div>
</div>
)}
<button className={styles.previewButton} disabled={!content} onClick={() => setShowPreview(!showPreview)}>
{showPreview ? t('edit') : t('preview')}
</button>
</div>
</div>
<div className={styles.box}>
<span className={styles.boxTitleRequired}>{t('submit_choose')}</span>
<div className={styles.boxContent}>
<span className={styles.boxSubtitle}>{t('community_address')}:</span>
<input
className={`${styles.input} ${styles.inputCommunity}`}
type='text'
placeholder={`"community.eth/.sol" ${t('or')} "12D3KooW..."`}
value={inputAddress}
onChange={handleAddressChange}
autoCorrect='off'
autoComplete='off'
spellCheck='false'
onFocus={() => setIsInputAddressFocused(true)}
onBlur={() => setTimeout(() => setIsInputAddressFocused(false), 100)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
(e.target as HTMLInputElement).blur();
}
}}
/>
{inputAddress && isInputAddressFocused && defaultSubplebbitsDropdown}
{subsDescription}
<div className={styles.subs}>
{listSource.map((subscription: string) => (
<span
key={subscription}
className={styles.sub}
onClick={() => {
setSelectedSubplebbit(subscription);
setInputAddress(subscription);
setSubmitStoreHook({ subplebbitAddress: subscription });
}}
>
{Plebbit.getShortAddress(subscription)}
</span>
))}
</div>
</div>
</div>
{selectedSubplebbitData?.rules?.length > 0 && (
<div className={styles.box}>
<span className={`${styles.boxTitle} ${styles.rulesTitle}`}>
{t('rules_for')} p/{selectedSubplebbitData.shortAddress}
</span>
<div className={styles.boxContent}>
<div className={styles.description}>
<ol className={styles.rules}>{selectedSubplebbitData.rules?.map((rule: string, index: number) => <li key={index}>{rule}</li>)}</ol>
</div>
</div>
</div>
)}
<div className={styles.box}>
<div className={styles.boxTitle}>{t('options')}</div>
<div className={styles.boxContent}>
<div className={styles.options}>
<div className={styles.option}>
<label>
<input type='checkbox' onChange={(e) => setSubmitStoreHook({ spoiler: e.target.checked })} />
{t('spoiler')}
</label>
</div>
<div className={styles.option}>
<label>
<input type='checkbox' onChange={(e) => setSubmitStoreHook({ nsfw: e.target.checked })} />
{t('nsfw')}
</label>
</div>
</div>
</div>
</div>
{isOffline && subplebbitAddress && <div className={styles.infobar}>{offlineTitle}</div>}
<UrlField />
{link?.length === 0 && <UploadMediaForm />}
<TitleField />
<ContentField />
<SubplebbitAddressField />
{rules?.length > 0 && <RulesInfo shortAddress={shortAddress} rules={rules} />}
<SubmitOptions />
<div className={`${styles.box} ${styles.notice}`}>{t('submit_notice')}</div>
<div>*{t('required')}</div>
<div className={styles.submit}>
@@ -537,4 +535,4 @@ const Submit = () => {
);
};
export default Submit;
export default SubmitPage;