mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
feat(settings): add media hosting settings and provider-based upload gating
This commit is contained in:
@@ -16,6 +16,7 @@ import useIsSubplebbitOffline from '../../hooks/use-is-subplebbit-offline';
|
||||
import usePublishPost from '../../hooks/use-publish-post';
|
||||
import usePublishReply from '../../hooks/use-publish-reply';
|
||||
import { useFileUpload } from '../../hooks/use-file-upload';
|
||||
import useMediaHostingStore from '../../stores/use-media-hosting-store';
|
||||
import styles from './post-form.module.css';
|
||||
import { capitalize, debounce } from 'lodash';
|
||||
|
||||
@@ -209,6 +210,8 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
||||
}
|
||||
},
|
||||
});
|
||||
const selectedProvider = useMediaHostingStore((state) => state.selectedProvider);
|
||||
const showUploadControls = selectedProvider !== 'none';
|
||||
|
||||
const hasInitializedDisplayName = useRef(false);
|
||||
useEffect(() => {
|
||||
@@ -289,15 +292,17 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
|
||||
<span className={styles.linkType}> {url && <LinkTypePreviewer link={url} />}</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr className={styles.uploadButton}>
|
||||
<td>{t('file')}</td>
|
||||
<td>
|
||||
<button onClick={handleUpload} disabled={isUploading}>
|
||||
{t('choose_file')}
|
||||
</button>
|
||||
<span>{isUploading ? t('uploading') : uploadedFileName || t('no_file_chosen')}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{showUploadControls && (
|
||||
<tr className={styles.uploadButton}>
|
||||
<td>{t('file')}</td>
|
||||
<td>
|
||||
<button onClick={handleUpload} disabled={isUploading}>
|
||||
{t('choose_file')}
|
||||
</button>
|
||||
<span>{isUploading ? t('uploading') : uploadedFileName || t('no_file_chosen')}</span>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{((isInPostView && showSpoilerForReply) || (!isInPostView && showSpoilerForPost)) && (
|
||||
<tr className={styles.spoilerButton}>
|
||||
<td>{t('options')}</td>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { isValidURL } from '../../lib/utils/url-utils';
|
||||
import { isAllView, isSubscriptionsView } from '../../lib/utils/view-utils';
|
||||
import useSelectedTextStore from '../../stores/use-selected-text-store';
|
||||
import useReplyModalStore from '../../stores/use-reply-modal-store';
|
||||
import useMediaHostingStore from '../../stores/use-media-hosting-store';
|
||||
import { useDirectoryByAddress } from '../../hooks/use-directories';
|
||||
import usePublishReply from '../../hooks/use-publish-reply';
|
||||
import useIsMobile from '../../hooks/use-is-mobile';
|
||||
@@ -271,6 +272,8 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
}
|
||||
},
|
||||
});
|
||||
const selectedProvider = useMediaHostingStore((state) => state.selectedProvider);
|
||||
const showUploadControls = selectedProvider !== 'none';
|
||||
|
||||
const hasInitializedDisplayName = useRef(false);
|
||||
useEffect(() => {
|
||||
@@ -336,16 +339,18 @@ const ReplyModal = ({ closeModal, showReplyModal, parentCid, parentNumber, threa
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.footer}>
|
||||
<span className={styles.uploadContainer}>
|
||||
<span className={styles.uploadButton}>
|
||||
<button onClick={handleUpload} disabled={isUploading}>
|
||||
{t('choose_file')}
|
||||
</button>
|
||||
{showUploadControls && (
|
||||
<span className={styles.uploadContainer}>
|
||||
<span className={styles.uploadButton}>
|
||||
<button onClick={handleUpload} disabled={isUploading}>
|
||||
{t('choose_file')}
|
||||
</button>
|
||||
</span>
|
||||
<span className={styles.uploadFileName} title={uploadedFileName || t('no_file_chosen')}>
|
||||
{isUploading ? t('uploading') : uploadedFileName || t('no_file_chosen')}
|
||||
</span>
|
||||
</span>
|
||||
<span className={styles.uploadFileName} title={uploadedFileName || t('no_file_chosen')}>
|
||||
{isUploading ? t('uploading') : uploadedFileName || t('no_file_chosen')}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{showSpoilerForReply && (
|
||||
<span className={styles.spoilerButton}>
|
||||
[
|
||||
|
||||
@@ -22,6 +22,17 @@
|
||||
text-decoration: var(--post-content-link-text-decoration-hover);
|
||||
}
|
||||
|
||||
.setting a {
|
||||
color: var(--post-link-text-color);
|
||||
text-decoration: var(--post-content-link-text-decoration);
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.setting a:hover {
|
||||
color: var(--post-link-text-color-hover);
|
||||
text-decoration: var(--post-content-link-text-decoration-hover);
|
||||
}
|
||||
|
||||
.interfaceSettings {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
@@ -30,6 +41,10 @@
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.setting input[type="radio"] {
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.settingTip {
|
||||
font-size: 0.85em;
|
||||
margin: 2px 0 5px 0;
|
||||
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
import * as React from 'react';
|
||||
import { createElement } from 'react';
|
||||
import { createRoot, Root } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import MediaHostingSettings from '../media-hosting-settings';
|
||||
import { MEDIA_HOSTING_PROVIDERS } from '../../../../stores/use-media-hosting-store';
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const act = (React as { act?: (cb: () => void | Promise<void>) => void | Promise<void> }).act as (cb: () => void | Promise<void>) => void | Promise<void>;
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
|
||||
const mockSetSelectedProvider = vi.fn();
|
||||
const selectedProviderRef = vi.hoisted(() => ({ value: 'catbox' as string }));
|
||||
vi.mock('../../../../stores/use-media-hosting-store', async (importOriginal) => {
|
||||
const mod = await importOriginal<typeof import('../../../../stores/use-media-hosting-store')>();
|
||||
return {
|
||||
...mod,
|
||||
default: (selector: (state: { selectedProvider: string; setSelectedProvider: (provider: string) => void }) => unknown) =>
|
||||
selector({
|
||||
selectedProvider: selectedProviderRef.value,
|
||||
setSelectedProvider: mockSetSelectedProvider,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
let root: Root;
|
||||
let container: HTMLDivElement;
|
||||
|
||||
const render = () => {
|
||||
act(() => {
|
||||
root.render(createElement(MediaHostingSettings));
|
||||
});
|
||||
};
|
||||
|
||||
describe('MediaHostingSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
selectedProviderRef.value = 'catbox';
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
});
|
||||
|
||||
it('renders None option and all providers', () => {
|
||||
render();
|
||||
const radios = container.querySelectorAll('input[type="radio"]');
|
||||
expect(radios.length).toBe(MEDIA_HOSTING_PROVIDERS.length + 1);
|
||||
expect(container.textContent).toContain('media_hosting_none');
|
||||
for (const provider of MEDIA_HOSTING_PROVIDERS) {
|
||||
expect(container.textContent).toContain(provider.name);
|
||||
expect(container.textContent).toContain(provider.url);
|
||||
}
|
||||
});
|
||||
|
||||
it('default selected is catbox', () => {
|
||||
render();
|
||||
const catboxRadio = container.querySelector<HTMLInputElement>('input[value="catbox"]');
|
||||
expect(catboxRadio).not.toBeNull();
|
||||
expect(catboxRadio?.checked).toBe(true);
|
||||
const noneRadio = container.querySelector<HTMLInputElement>('input[value="none"]');
|
||||
expect(noneRadio?.checked).toBe(false);
|
||||
});
|
||||
|
||||
it('clicking a radio updates store selection', async () => {
|
||||
render();
|
||||
const noneRadio = container.querySelector<HTMLInputElement>('input[value="none"]');
|
||||
expect(noneRadio).not.toBeNull();
|
||||
await act(async () => {
|
||||
noneRadio?.click();
|
||||
});
|
||||
expect(mockSetSelectedProvider).toHaveBeenCalledWith('none');
|
||||
|
||||
mockSetSelectedProvider.mockClear();
|
||||
selectedProviderRef.value = 'none';
|
||||
render();
|
||||
|
||||
const providerRadio = container.querySelector<HTMLInputElement>(`input[value="${MEDIA_HOSTING_PROVIDERS[0].id}"]`);
|
||||
expect(providerRadio).not.toBeNull();
|
||||
await act(async () => {
|
||||
providerRadio?.click();
|
||||
});
|
||||
expect(mockSetSelectedProvider).toHaveBeenCalledWith(MEDIA_HOSTING_PROVIDERS[0].id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './media-hosting-settings';
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import useMediaHostingStore, { MEDIA_HOSTING_PROVIDERS } from '../../../stores/use-media-hosting-store';
|
||||
import styles from '../interface-settings/interface-settings.module.css';
|
||||
|
||||
const RADIO_NAME = 'media-hosting-provider';
|
||||
|
||||
const MediaHostingSettings = () => {
|
||||
const { t } = useTranslation();
|
||||
const selectedProvider = useMediaHostingStore((state) => state.selectedProvider);
|
||||
const setSelectedProvider = useMediaHostingStore((state) => state.setSelectedProvider);
|
||||
|
||||
return (
|
||||
<div className={styles.interfaceSettings}>
|
||||
<div role='radiogroup' aria-label={t('media_hosting')}>
|
||||
{MEDIA_HOSTING_PROVIDERS.map((provider) => (
|
||||
<div key={provider.id} className={styles.setting}>
|
||||
<label>
|
||||
<input type='radio' name={RADIO_NAME} value={provider.id} checked={selectedProvider === provider.id} onChange={() => setSelectedProvider(provider.id)} />
|
||||
{provider.name} (
|
||||
<a href={provider.url} target='_blank' rel='noopener noreferrer'>
|
||||
{provider.url}
|
||||
</a>
|
||||
)
|
||||
</label>
|
||||
</div>
|
||||
))}
|
||||
<div className={styles.setting}>
|
||||
<label>
|
||||
<input type='radio' name={RADIO_NAME} value='none' checked={selectedProvider === 'none'} onChange={() => setSelectedProvider('none')} />
|
||||
{t('media_hosting_none')}
|
||||
</label>
|
||||
<div className={styles.settingTip}>{t('media_hosting_none_tip')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MediaHostingSettings;
|
||||
@@ -8,6 +8,7 @@ import BlockedAddressesSetting from './blocked-addresses-setting';
|
||||
import CryptoAddressSetting from './crypto-address-setting';
|
||||
import CryptoWalletsSetting from './crypto-wallets-setting';
|
||||
import InterfaceSettings from './interface-settings';
|
||||
import MediaHostingSettings from './media-hosting-settings';
|
||||
import P2pOptions from './p2p-options';
|
||||
import SubscriptionsSetting from './subscriptions-setting';
|
||||
|
||||
@@ -36,6 +37,7 @@ const SettingsModal = () => {
|
||||
}, [closeModal]);
|
||||
|
||||
const [showInterfaceSettings, setShowInterfaceSettings] = useState(false);
|
||||
const [showMediaHostingSettings, setShowMediaHostingSettings] = useState(false);
|
||||
const [showAccountSettings, setShowAccountSettings] = useState(false);
|
||||
const [showAvatarSettings, setShowAvatarSettings] = useState(false);
|
||||
const [showCryptoAddressSetting, setShowCryptoAddressSetting] = useState(false);
|
||||
@@ -48,6 +50,7 @@ const SettingsModal = () => {
|
||||
const getExpandedCount = () => {
|
||||
return (
|
||||
Number(showInterfaceSettings) +
|
||||
Number(showMediaHostingSettings) +
|
||||
Number(showAccountSettings) +
|
||||
Number(showAvatarSettings) +
|
||||
Number(showCryptoAddressSetting) +
|
||||
@@ -60,6 +63,7 @@ const SettingsModal = () => {
|
||||
|
||||
const getExpandedCategoryId = (excludeCategoryId?: string) => {
|
||||
if (showInterfaceSettings && 'interface-settings' !== excludeCategoryId) return 'interface-settings';
|
||||
if (showMediaHostingSettings && 'media-hosting-settings' !== excludeCategoryId) return 'media-hosting-settings';
|
||||
if (showAccountSettings && 'account-settings' !== excludeCategoryId) return 'account-settings';
|
||||
if (showAvatarSettings && 'avatar-settings' !== excludeCategoryId) return 'avatar-settings';
|
||||
if (showCryptoAddressSetting && 'crypto-address-settings' !== excludeCategoryId) return 'crypto-address-settings';
|
||||
@@ -100,6 +104,7 @@ const SettingsModal = () => {
|
||||
useEffect(() => {
|
||||
if (hash) {
|
||||
setShowInterfaceSettings(hash === 'interface-settings');
|
||||
setShowMediaHostingSettings(hash === 'media-hosting-settings');
|
||||
setShowAccountSettings(hash === 'account-settings');
|
||||
setShowAvatarSettings(hash === 'avatar-settings');
|
||||
setShowCryptoAddressSetting(hash === 'crypto-address-settings');
|
||||
@@ -114,6 +119,7 @@ const SettingsModal = () => {
|
||||
const newExpandState = !expandAll;
|
||||
setExpandAll(newExpandState);
|
||||
setShowInterfaceSettings(newExpandState);
|
||||
setShowMediaHostingSettings(newExpandState);
|
||||
setShowAccountSettings(newExpandState);
|
||||
setShowAvatarSettings(newExpandState);
|
||||
setShowCryptoAddressSetting(newExpandState);
|
||||
@@ -144,6 +150,13 @@ const SettingsModal = () => {
|
||||
</label>
|
||||
</div>
|
||||
{showInterfaceSettings && <InterfaceSettings />}
|
||||
<div id='media-hosting-settings' className={`${styles.setting} ${styles.category}`}>
|
||||
<label onClick={() => handleCategoryClick('media-hosting-settings', showMediaHostingSettings, setShowMediaHostingSettings)}>
|
||||
<span className={showMediaHostingSettings ? styles.hideButton : styles.showButton} />
|
||||
{t('media_hosting')}
|
||||
</label>
|
||||
</div>
|
||||
{showMediaHostingSettings && <MediaHostingSettings />}
|
||||
<div id='account-settings' className={`${styles.setting} ${styles.category}`}>
|
||||
<label onClick={() => handleCategoryClick('account-settings', showAccountSettings, setShowAccountSettings)}>
|
||||
<span className={showAccountSettings ? styles.hideButton : styles.showButton} />
|
||||
|
||||
Reference in New Issue
Block a user