feat(settings): move account json editing to dedicated full editor route

This commit is contained in:
plebeius
2026-02-24 20:33:07 +08:00
parent dc43a8438e
commit fb9443a0f8
47 changed files with 852 additions and 219 deletions
+16 -10
View File
@@ -34,6 +34,7 @@ import PostForm from './components/post-form';
import BoardBlotter from './components/board-blotter';
import BoardsBar from './components/boardsbar';
const AccountDataEditor = lazy(() => import('./views/account-data-editor'));
const BoardsBarEditModal = lazy(() => import('./components/boardsbar-edit-modal'));
const CreateBoardModal = lazy(() => import('./components/create-board-modal'));
const DirectoryModal = lazy(() => import('./components/directory-modal'));
@@ -47,7 +48,7 @@ preloadThemeAssets();
const hasModQueueAccessRole = (role?: string): boolean => role === 'admin' || role === 'owner' || role === 'moderator';
const BoardLayout = () => {
const { accountCommentIndex, boardIdentifier } = useParams();
const { accountCommentIndex, boardIdentifier, pageNumber } = useParams();
const location = useLocation();
const isMobile = useIsMobile();
const isInAllView = isAllView(location.pathname);
@@ -83,6 +84,10 @@ const BoardLayout = () => {
? `${subplebbitAddress}-${location.pathname.replace(/\/settings$/, '')}`
: `${subplebbitAddress}-${location.pathname}`;
if (pageNumber === '1') {
return <Navigate to='/not-found' replace />;
}
return (
<div className={styles.boardLayout}>
<BoardsBar />
@@ -180,14 +185,6 @@ const CatalogFeedRoute = () => {
return <Catalog viewType={viewType} boardIdentifier={params.boardIdentifier} />;
};
const PageOneGuard = () => {
const { pageNumber } = useParams();
if (pageNumber === '1') {
return <Navigate to='/not-found' replace />;
}
return null;
};
const ModQueueRoute = () => {
const { boardIdentifier } = useParams();
const account = useAccount();
@@ -244,6 +241,14 @@ const App = () => {
<Route path='/faq' element={<FAQ />} />
<Route path='/rules/:boardIdentifier?' element={<Rules />} />
<Route path='/blotter' element={<Blotter />} />
<Route
path='/settings/account-data'
element={
<Suspense fallback={null}>
<AccountDataEditor />
</Suspense>
}
/>
<Route element={<BoardLayout />}>
{/* Canonical multiboard routes (no time filter) */}
<Route path='/all' element={boardFeedElement} />
@@ -269,7 +274,8 @@ const App = () => {
<Route path='/subs/*' element={<Navigate to='/not-found' replace />} />
<Route path='/mod/*' element={<Navigate to='/not-found' replace />} />
<Route path='/:boardIdentifier/:pageNumber' element={<PageOneGuard />} />
<Route path='/:boardIdentifier/:pageNumber' element={boardFeedElement} />
<Route path='/:boardIdentifier/:pageNumber/settings' element={boardFeedElement} />
<Route path='/:boardIdentifier' element={boardFeedElement} />
<Route path='/:boardIdentifier/settings' element={boardFeedElement} />
<Route path='/:boardIdentifier/catalog' element={catalogFeedElement} />
@@ -0,0 +1,87 @@
import * as React from 'react';
import { createElement } from 'react';
import { createRoot, Root } from 'react-dom/client';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import AccountSettings from '../account-settings';
(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,
i18n: { changeLanguage: vi.fn(), language: 'en' },
}),
}));
vi.mock('@plebbit/plebbit-react-hooks', () => ({
useAccount: () => ({ id: 'test-id', name: 'Account 1', author: { address: '0x123', shortAddress: '0x1...3' } }),
useAccounts: () => ({ accounts: [{ id: 'test-id', name: 'Account 1', author: { shortAddress: '0x1...3' } }] }),
createAccount: vi.fn(),
deleteAccount: vi.fn(),
exportAccount: vi.fn(),
importAccount: vi.fn(),
setActiveAccount: vi.fn(),
}));
vi.mock('@capacitor/core', () => ({
Capacitor: { getPlatform: () => 'web' },
}));
let root: Root;
let container: HTMLDivElement;
const render = (children: React.ReactNode) => {
act(() => {
root.render(createElement(MemoryRouter, { initialEntries: ['/subs/settings'] }, children));
});
};
describe('AccountSettings', () => {
beforeEach(() => {
vi.clearAllMocks();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it('does not render an inline textarea', () => {
render(createElement(AccountSettings));
expect(container.querySelector('textarea')).toBeNull();
});
it('does not render save_changes or reset_changes buttons', () => {
render(createElement(AccountSettings));
const buttons = Array.from(container.querySelectorAll('button'));
const buttonTexts = buttons.map((b) => b.textContent ?? '');
expect(buttonTexts.some((t) => t.includes('save_changes'))).toBe(false);
expect(buttonTexts.some((t) => t.includes('reset_changes'))).toBe(false);
});
it('renders an edit button', () => {
render(createElement(AccountSettings));
const buttons = Array.from(container.querySelectorAll('button'));
expect(buttons.some((b) => (b.textContent ?? '').includes('edit'))).toBe(true);
});
it('renders create, import, export buttons', () => {
render(createElement(AccountSettings));
const buttons = Array.from(container.querySelectorAll('button'));
const texts = buttons.map((b) => b.textContent ?? '');
expect(texts.some((t) => t.includes('create'))).toBe(true);
expect(texts.some((t) => t.includes('import'))).toBe(true);
expect(texts.some((t) => t.includes('export'))).toBe(true);
});
it('renders delete_account button', () => {
render(createElement(AccountSettings));
const buttons = Array.from(container.querySelectorAll('button'));
expect(buttons.some((b) => (b.textContent ?? '').includes('delete_account'))).toBe(true);
});
});
@@ -3,34 +3,10 @@
margin-bottom: 10px;
}
.setting textarea {
display: block;
font-family: dejavu sans mono, consolas, andale mono, lucida console, monospace;
outline: none;
border: 1px solid #aaa;
font-size: 11px;
margin: 0 2px 0 0;
padding: 2px 4px 3px;
min-height: 150px;
width: 95%;
}
.setting textarea:focus {
border-color: 1px solid #98e;
}
.setting button {
cursor: pointer;
}
.saveResetAccount {
margin-top: 15px;
}
.setting textarea {
margin-bottom: 5px;
}
.setting div {
margin-top: 5px;
}
@@ -1,7 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { createAccount, deleteAccount, exportAccount, importAccount, setAccount, setActiveAccount, useAccount, useAccounts } from '@plebbit/plebbit-react-hooks';
import stringify from 'json-stringify-pretty-compact';
import { createAccount, deleteAccount, exportAccount, importAccount, setActiveAccount, useAccount, useAccounts } from '@plebbit/plebbit-react-hooks';
import styles from './account-settings.module.css';
import { Capacitor } from '@capacitor/core';
import { useLocation, useNavigate } from 'react-router-dom';
@@ -33,24 +32,6 @@ const AccountSettingsEditor = ({
}) => {
const { t } = useTranslation();
const location = useLocation();
const accountJson = useMemo(
() =>
stringify({
account: {
...account,
author: { ...account?.author, avatar: undefined },
plebbit: undefined,
karma: undefined,
plebbitReactOptions: undefined,
unreadNotificationCount: undefined,
},
}),
[account],
);
const [text, setText] = useState(() => accountJson);
const { accounts } = useAccounts();
const switchToNewAccountRef = useRef(false);
const navigate = useNavigate();
@@ -93,29 +74,6 @@ const AccountSettingsEditor = ({
}
};
const saveAccount = async () => {
const parsed = safeParseJSON<{ account: Record<string, unknown> }>(text);
if (!parsed?.account) {
alert('Invalid JSON');
return;
}
const newAccount = parsed.account;
const result = await withErrorHandling(
() => setAccount({ ...newAccount, id: account?.id }),
(error) => {
if (error instanceof Error) {
alert(error.message);
console.log(error);
} else {
console.error('An unknown error occurred:', error);
}
},
);
if (result !== undefined) {
alert(`Saved ${newAccount.name}`);
}
};
const handleExportAccount = async () => {
const accountString = await withErrorHandling(
() => exportAccount(),
@@ -247,10 +205,8 @@ const AccountSettingsEditor = ({
})}
</div>
</div>
<div></div>
<textarea value={text} onChange={(e) => setText(e.target.value)} autoCorrect='off' autoComplete='off' spellCheck='false' />
<div>
<button onClick={saveAccount}>{t('save_changes')}</button> <button onClick={() => setText(accountJson)}>{t('reset_changes')}</button>
<button onClick={() => navigate('/settings/account-data', { state: { returnTo: location.pathname + location.hash } })}>{t('edit')}</button>
<button className={styles.deleteAccount} onClick={() => _deleteAccount(account?.name ?? '')}>
{t('delete_account')}
</button>
@@ -0,0 +1,67 @@
import { describe, it, expect } from 'vitest';
import { buildEditableAccountJson, safeParseAccountJson, buildSavePayload } from '../account-editor-utils';
describe('buildEditableAccountJson', () => {
it('strips runtime-only fields from account', () => {
const account = {
id: 'abc',
name: 'Account 1',
author: { address: '0x123', shortAddress: '0x1...3', avatar: { url: 'https://example.com' } },
plebbit: { someOption: true },
karma: 42,
plebbitReactOptions: { foo: 'bar' },
unreadNotificationCount: 5,
};
const result = JSON.parse(buildEditableAccountJson(account));
expect(result.account.id).toBe('abc');
expect(result.account.name).toBe('Account 1');
expect(result.account.author.address).toBe('0x123');
expect(result.account.author.avatar).toBeUndefined();
expect(result.account.plebbit).toBeUndefined();
expect(result.account.karma).toBeUndefined();
expect(result.account.plebbitReactOptions).toBeUndefined();
expect(result.account.unreadNotificationCount).toBeUndefined();
});
it('handles undefined account', () => {
const result = buildEditableAccountJson(undefined);
expect(result).toBeTruthy();
const parsed = JSON.parse(result);
expect(parsed.account).toBeDefined();
});
});
describe('safeParseAccountJson', () => {
it('parses valid account JSON', () => {
const result = safeParseAccountJson('{"account": {"name": "test"}}');
expect(result).toEqual({ account: { name: 'test' } });
});
it('returns null for invalid JSON', () => {
expect(safeParseAccountJson('not json')).toBeNull();
});
it('returns null for JSON without account key', () => {
expect(safeParseAccountJson('{"name": "test"}')).toBeNull();
});
it('returns null for non-object account', () => {
expect(safeParseAccountJson('{"account": "string"}')).toBeNull();
});
});
describe('buildSavePayload', () => {
it('preserves original account id', () => {
const parsed = { account: { name: 'updated', id: 'wrong-id' } };
const result = buildSavePayload(parsed, 'original-id');
expect(result.id).toBe('original-id');
expect(result.name).toBe('updated');
});
it('handles undefined original id', () => {
const parsed = { account: { name: 'test' } };
const result = buildSavePayload(parsed, undefined);
expect(result.id).toBeUndefined();
expect(result.name).toBe('test');
});
});
+47
View File
@@ -0,0 +1,47 @@
import stringify from 'json-stringify-pretty-compact';
type AccountLike = {
id?: string;
name?: string;
author?: { address?: string; shortAddress?: string; avatar?: unknown };
plebbit?: unknown;
karma?: unknown;
plebbitReactOptions?: unknown;
unreadNotificationCount?: unknown;
[key: string]: unknown;
};
/**
* Build the editable JSON string for an account, stripping runtime-only fields.
*/
export const buildEditableAccountJson = (account: AccountLike | undefined): string =>
stringify({
account: {
...account,
author: { ...account?.author, avatar: undefined },
plebbit: undefined,
karma: undefined,
plebbitReactOptions: undefined,
unreadNotificationCount: undefined,
},
});
export const safeParseAccountJson = (text: string): { account: Record<string, unknown> } | null => {
try {
const parsed = JSON.parse(text);
if (parsed && typeof parsed === 'object' && parsed.account && typeof parsed.account === 'object') {
return parsed as { account: Record<string, unknown> };
}
return null;
} catch {
return null;
}
};
/**
* Build a save-ready account payload, preserving the original account id.
*/
export const buildSavePayload = (parsed: { account: Record<string, unknown> }, originalId: string | undefined): Record<string, unknown> => ({
...parsed.account,
id: originalId,
});
@@ -0,0 +1,79 @@
import * as React from 'react';
import { createElement } from 'react';
import { createRoot, Root } from 'react-dom/client';
import { MemoryRouter } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import AccountDataEditor from '../account-data-editor';
(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,
i18n: { changeLanguage: vi.fn(), language: 'en' },
}),
}));
vi.mock('../../../lib/utils/account-editor-utils', () => ({
buildEditableAccountJson: () => '{"account": {"name": "test"}}',
safeParseAccountJson: vi.fn((text: string) => {
try {
const p = JSON.parse(text);
return p?.account ? p : null;
} catch {
return null;
}
}),
buildSavePayload: vi.fn((parsed: { account: Record<string, unknown> }, id: string) => ({ ...parsed.account, id })),
}));
vi.mock('@plebbit/plebbit-react-hooks', () => ({
useAccount: () => ({ id: 'test-id', name: 'Account 1', author: { address: '0x123', shortAddress: '0x1...3' } }),
setAccount: vi.fn(),
}));
let root: Root;
let container: HTMLDivElement;
const render = (children: React.ReactNode) => {
act(() => {
root.render(createElement(MemoryRouter, { initialEntries: ['/settings/account-data'] }, children));
});
};
describe('AccountDataEditor', () => {
beforeEach(() => {
vi.clearAllMocks();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
it('renders warning gate initially', () => {
render(createElement(AccountDataEditor));
expect(container.textContent).toContain('private_key_warning_title');
});
it('shows go_back and continue buttons in warning gate', () => {
render(createElement(AccountDataEditor));
const buttons = Array.from(container.querySelectorAll('button'));
const texts = buttons.map((b) => b.textContent ?? '');
expect(texts.some((t) => t.includes('go_back'))).toBe(true);
expect(texts.some((t) => t.includes('continue'))).toBe(true);
});
it('does not show editor controls in warning phase', () => {
render(createElement(AccountDataEditor));
const buttons = Array.from(container.querySelectorAll('button'));
const texts = buttons.map((b) => b.textContent ?? '');
expect(texts.some((t) => t.includes('save'))).toBe(false);
expect(texts.some((t) => t.includes('reset_changes'))).toBe(false);
expect(texts.some((t) => t.includes('return_to_settings'))).toBe(false);
});
});
@@ -0,0 +1,54 @@
.container {
max-width: 900px;
margin: 0 auto;
padding: 20px;
}
.warningGate {
text-align: center;
padding: 40px 20px;
}
.warningTitle {
font-weight: 700;
font-size: 1.2em;
margin-bottom: 12px;
}
.warningDescription {
color: #666;
margin-bottom: 24px;
line-height: 1.5;
}
.warningButtons {
display: flex;
gap: 12px;
justify-content: center;
flex-wrap: wrap;
}
.editorContainer {
font-family: monospace;
font-size: 13px;
}
.controls {
display: flex;
gap: 12px;
margin-top: 16px;
flex-wrap: wrap;
}
.fallbackWarning {
color: #b8860b;
background: #fff8e0;
border: 1px solid #e0c000;
padding: 12px;
margin-bottom: 12px;
}
.loadingMessage {
padding: 20px;
text-align: center;
}
@@ -0,0 +1,119 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router-dom';
import { setAccount, useAccount } from '@plebbit/plebbit-react-hooks';
import { buildEditableAccountJson, safeParseAccountJson, buildSavePayload } from '../../lib/utils/account-editor-utils';
import styles from './account-data-editor.module.css';
const DEFAULT_RETURN_TO = '/subs/settings#account-settings';
const loadAce = async () => {
const [aceModule] = await Promise.all([import('react-ace'), import('ace-builds/src-noconflict/mode-json'), import('ace-builds/src-noconflict/theme-monokai')]);
return aceModule.default;
};
const AccountDataEditor = () => {
const { t } = useTranslation();
const location = useLocation();
const navigate = useNavigate();
const account = useAccount();
const returnTo = (location.state as { returnTo?: string } | null)?.returnTo ?? DEFAULT_RETURN_TO;
const [phase, setPhase] = useState<'warning' | 'loading' | 'editor' | 'fallback'>('warning');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [AceEditor, setAceEditor] = useState<React.ComponentType<any> | null>(null);
const [text, setText] = useState('');
useEffect(() => {
if (phase !== 'loading') return;
loadAce()
.then((Editor) => {
setAceEditor(() => Editor);
setText(buildEditableAccountJson(account));
setPhase('editor');
})
.catch(() => {
setText(buildEditableAccountJson(account));
setPhase('fallback');
});
}, [phase, account]);
const handleGoBack = () => navigate(returnTo);
const handleContinue = () => setPhase('loading');
const handleReset = () => setText(buildEditableAccountJson(account));
const handleReturn = () => navigate(returnTo);
const handleSave = async () => {
const parsed = safeParseAccountJson(text);
if (!parsed) {
alert('Invalid JSON');
return;
}
const payload = buildSavePayload(parsed, account?.id);
try {
await setAccount(payload);
navigate(returnTo);
window.location.reload();
} catch (e) {
alert(e instanceof Error ? e.message : 'Error saving');
}
};
if (phase === 'warning') {
return (
<div className={styles.container}>
<div className={styles.warningGate}>
<div className={styles.warningTitle}>{t('private_key_warning_title')}</div>
<div className={styles.warningDescription}>{t('private_key_warning_description')}</div>
<div className={styles.warningButtons}>
<button type='button' onClick={handleGoBack}>
{t('go_back')}
</button>
<button type='button' onClick={handleContinue}>
{t('continue')}
</button>
</div>
</div>
</div>
);
}
if (phase === 'loading') {
return (
<div className={styles.container}>
<div className={styles.loadingMessage}>{t('loading_editor')}</div>
</div>
);
}
return (
<div className={styles.container}>
{phase === 'fallback' && <div className={styles.fallbackWarning}>{t('editor_fallback_warning')}</div>}
<div className={styles.editorContainer}>
{phase === 'editor' && AceEditor ? (
<AceEditor mode='json' theme='monokai' width='100%' height='500px' fontSize={13} showPrintMargin={false} value={text} onChange={setText} />
) : (
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
style={{ width: '100%', height: '500px', fontFamily: 'monospace', fontSize: 13 }}
spellCheck={false}
/>
)}
</div>
<div className={styles.controls}>
<button type='button' onClick={handleSave}>
{t('save')}
</button>
<button type='button' onClick={handleReset}>
{t('reset_changes')}
</button>
<button type='button' onClick={handleReturn}>
{t('return_to_settings')}
</button>
</div>
</div>
);
};
export default AccountDataEditor;
+1
View File
@@ -0,0 +1 @@
export { default } from './account-data-editor';