refactor(dashboard): rethink settings, split out subreddits and accounts

The settings page had accumulated five tabs whose names no longer matched
their contents, three different save models, and the whole subreddit
configuration buried under a tab named after a checkbox.

Structure:
- Subreddits becomes its own page: a list showing each community's mode,
  tone, computed share of polling, and live sent/reply stats, with one
  subreddit edited at a time in a drawer.
- Accounts becomes its own page. It is session lifecycle, not config.
- Settings collapses to one scrolling page: Safety, Pacing (Monitoring and
  Rate limits merged), Targeting, Notifications, Advanced.
- The sidebar is grouped, having gone from 9 to 11 destinations.

Saving:
- Every section owns its own draft, validation, risk gate and save via
  useConfigCard, submitting only the keys it owns. A syntax error in the
  advanced JSON can no longer block a rate-limit change, and enabling an
  autonomous policy is its own deliberate commit.
- UnsavedChangesProvider restores the leave-page guard across cards that no
  longer share a draft.

Subreddit editing:
- Errors are keyed by field name instead of array index, removing the
  defensive invalidation that existed because row indices shifted on add
  and remove. Confirming an autonomous mode can no longer be attributed to
  the wrong row.
- Weight is shown as the share it actually produces, live while editing.
- Bulk actions cover start/pause polling, tone, policy and removal, because
  production runs 30-40 subreddits and per-row editing does not scale.
  Bulk policy changes name every subreddit turning autonomous, and
  normalise the hybrid mode's turn budget, which has no form to catch it.

Physique targeting moves into its own Targeting card. It sends only
reddit.physiqueTargeting, which the config route applies independently, so
it cannot clobber the pacing card's fields. Turning screening on now warns
that it will reduce how many posts qualify.

Also surfaces the effective sending rate, since the hourly limit and the
minimum delay both throttle and only the binding one matters.

Legacy ?section= links redirect or resolve to anchors.

Fixes found while verifying in a browser: a radio-card rule was shrinking
nested number inputs; .share-preview had no styles; the throughput readout
reported a rate from a blank field; and the reconcile logic warned about a
server change whenever a sibling card saved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Jamie BruntonandClaude Opus 5 committed 2026-08-11 09:07:31 +01:00
1 parent 11b6300113
commit c43e534cf1
36 files changed
+4414 -1632

No files matched your search

@@ -0,0 +1,213 @@
import { useEffect, useRef, useState } from 'react';
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
import {
useImportRedditSessionMutation,
useLoginStatusQuery,
useRedditAccountsQuery,
} from '../settings/settings-queries.js';
import './accounts.css';
function errorMessage(error: Error | null): string {
return error?.message ?? 'Something went wrong.';
}
function sessionLabel(status: ReturnType<typeof useLoginStatusQuery>['data']): string {
if (!status) return 'Unknown';
if (!status.ready) return 'Browser unavailable';
if ('error' in status) return 'Check failed';
return {
unknown: 'Unknown',
authenticated: 'Authenticated',
'login-required': 'Login required',
unreachable: 'Reddit unreachable',
}[status.status];
}
export function AccountsPage() {
const statusQuery = useLoginStatusQuery();
const accountsQuery = useRedditAccountsQuery();
const importMutation = useImportRedditSessionMutation();
const importInput = useRef<HTMLInputElement>(null);
const [importFileError, setImportFileError] = useState<string | null>(null);
const resetImportMutation = importMutation.reset;
useEffect(() => {
if (!importMutation.isSuccess) return;
const timer = globalThis.setTimeout(() => resetImportMutation(), 5_000);
return () => globalThis.clearTimeout(timer);
}, [importMutation.isSuccess, resetImportMutation]);
async function importSession(file: File | undefined) {
if (!file) return;
setImportFileError(null);
let exported: unknown;
try {
exported = JSON.parse(await file.text());
} catch {
importMutation.reset();
setImportFileError("That file isn't valid JSON. Export a fresh session dump from the extension and try again.");
return;
}
importMutation.mutate(exported);
}
const session = statusQuery.data;
const verifiedUsername = session?.ready && 'loggedIn' in session && session.loggedIn
? session.username ?? null
: null;
const accounts = accountsQuery.data ?? [];
const pausedCount = accounts.filter((account) => !account.isRunning).length;
return (
<div className="page accounts-page">
<header className="page-header">
<div>
<span className="eyebrow">Reddit access</span>
<h1>Accounts</h1>
<p>Each Reddit account runs on its own imported browser session. Content and sync state stay owned by the account that produced them.</p>
</div>
{session ? (
<span className={session.ready && 'status' in session && session.loggedIn ? 'pill success' : 'pill warning'}>
{sessionLabel(session)}
</span>
) : null}
</header>
{pausedCount > 0 ? (
<div className="state-banner is-warning" role="status">
<strong>
{pausedCount === 1 ? 'One account is paused.' : `${pausedCount} accounts are paused.`}
</strong>
<span>A paused account is waiting for a fresh session dump. Other accounts keep running normally.</span>
</div>
) : null}
{statusQuery.isPending ? (
<div aria-label="Loading access" className="page-state" role="status">
<span className="spinner" />Loading Reddit access…
</div>
) : null}
{statusQuery.isError ? (
<div className="page-state error-state" role="alert">
<strong>Reddit access unavailable</strong>
<span>{errorMessage(statusQuery.error)}</span>
<button className="button secondary" onClick={() => void statusQuery.refetch()} type="button">Retry access</button>
</div>
) : null}
{session ? (
<>
<section aria-labelledby="accounts-heading" className="panel settings-card">
<div className="settings-card-heading">
<div>
<span className="eyebrow">Connected</span>
<h2 id="accounts-heading">Reddit accounts</h2>
{verifiedUsername ? (
<p>Verified as <strong><RedditUsernameLink username={verifiedUsername} /></strong>.</p>
) : <p>No account is currently verified as logged in.</p>}
</div>
</div>
<div className="settings-card-body">
<div aria-label="Reddit accounts" className="account-list">
{accountsQuery.isPending ? (
<p className="field-hint" role="status"><span className="spinner" /> Loading Reddit accounts…</p>
) : null}
{accountsQuery.isError ? (
<div className="state-banner is-error" role="alert">
<strong>Could not load Reddit accounts</strong>
<span>{errorMessage(accountsQuery.error)}</span>
<button className="button secondary" onClick={() => void accountsQuery.refetch()} type="button">Retry accounts</button>
</div>
) : null}
{accounts.map((account) => (
<div className={account.isRunning ? 'account-row is-active' : 'account-row'} key={account.accountId}>
<div>
<strong><RedditUsernameLink username={account.redditUsername} /></strong>
<span>{account.isRunning ? 'Running independently' : 'Waiting for a refreshed session'}</span>
</div>
<span className={account.isRunning ? 'pill success' : 'pill warning'}>
{account.isRunning ? 'Running' : 'Paused'}
</span>
</div>
))}
{accountsQuery.isSuccess && accounts.length === 0 ? (
<div className="empty-state">
<strong>No accounts yet</strong>
<span>Import a session dump below to add the first one.</span>
</div>
) : null}
</div>
</div>
<footer className="settings-card-footer">
<div aria-live="polite" className="settings-card-status">
{importFileError ? <strong className="save-error" role="alert">{importFileError}</strong> : null}
{importMutation.isSuccess ? (
<strong className="save-success" role="status">Session imported and verified — the account is running.</strong>
) : null}
{importMutation.isError ? (
<strong className="save-error" role="alert">{errorMessage(importMutation.error)}</strong>
) : null}
</div>
<div className="header-actions">
<input
accept="application/json"
aria-label="Reddit session export"
className="sr-only"
onChange={(event) => {
const file = event.target.files?.[0];
// Reset the input so picking the same file again re-triggers change.
event.target.value = '';
void importSession(file);
}}
ref={importInput}
type="file"
/>
<button
aria-busy={statusQuery.isFetching ? 'true' : undefined}
className="button secondary"
disabled={statusQuery.isFetching}
onClick={() => void statusQuery.refetch()}
type="button"
>
{statusQuery.isFetching ? 'Checking sessions…' : 'Recheck accounts'}
</button>
<button
className="button primary"
disabled={importMutation.isPending}
onClick={() => importInput.current?.click()}
type="button"
>
{importMutation.isPending ? 'Adding account…' : 'Add or refresh account'}
</button>
</div>
</footer>
</section>
<section aria-labelledby="import-help-heading" className="panel settings-card">
<div className="settings-card-heading">
<div>
<span className="eyebrow">How to import</span>
<h2 id="import-help-heading">Getting a session dump</h2>
</div>
</div>
<div className="settings-card-body">
<ol className="numbered-steps">
<li>Open Reddit Chat in Chrome, signed in as the account you want to add.</li>
<li>Wait for the extension badge to show <strong>OK</strong>.</li>
<li>Export the JSON dump from the extension.</li>
<li>Upload it with <strong>Add or refresh account</strong> above.</li>
</ol>
<p className="state-banner is-warning">
<strong>A session dump contains login credentials.</strong>
<span>Anyone holding the file can act as that Reddit account. Do not share it or commit it anywhere.</span>
</p>
</div>
</section>
</>
) : null}
</div>
);
}
@@ -0,0 +1,10 @@
.accounts-page { max-width: 58rem; }
.accounts-page .state-banner { margin-bottom: 1rem; }
.accounts-page .settings-card + .settings-card { margin-top: 1rem; }
.account-list { display: grid; gap: .6rem; }
.account-row { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 1rem; padding: .85rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .75rem; }
.account-row.is-active { border-color: var(--accent); }
.account-row > div { display: grid; gap: .2rem; min-width: 0; }
.account-row span { color: var(--muted); font-size: .82rem; }
.account-row .pill { font-size: .7rem; }