Files
RedditBot/apps/dashboard/src/features/settings/access-panel.tsx
T
Jamie BruntonandClaude Fable 5 e984bf20ac feat: dashboard UI/UX overhaul from full review
Shared foundation:
- New Modal component on native <dialog>.showModal() (focus trap, inert
  background, Escape, focus restore, scroll lock); all eight dialogs and
  both globalThis.confirm calls migrated to it
- Design tokens for success/warning/danger surfaces; pill danger variant;
  .sr-only utility; toast layering above dialog backdrops; not-allowed
  cursor for validity-disabled buttons

Conversations/attention: auto-scroll with jump-to-latest, per-conversation
draft persistence, suggestion undo, 30s auto-refresh with refresh controls,
unread emphasis and counts, blur-consistent share dialog, empty-accounts
guidance, collapsed user lookup, relative timestamps, danger pills for
critical items

Media/library/outreach: click-blurred-to-reveal, lightbox with arrow
navigation and captions, dirty-form preservation across refetches, upload
validation with thumbnail preview, save feedback, result counts with
load-more and clear-filters, ticking countdowns from a single clock

Overview/performance/operations/settings: mutation errors inside the
confirm dialog, live sending-window countdown, offline-aware loading
states, per-panel loading/error with retry, 7/30/90-day range toggle,
console scroll yielding with shown/retained counts, risky-save matching
by subreddit name instead of index, useBlocker unsaved-changes guard,
single-pass validation with focus-to-first-error, access panel JSON
validation and input reset

Tests: 806 -> 844 passing; both typechecks clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 09:07:42 +01:00

113 lines
6.1 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { RedditUsernameLink } from '../../components/reddit-username-link.js';
import {
useImportRedditSessionMutation,
useLoginStatusQuery,
useRedditAccountsQuery,
} from './settings-queries.js';
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 AccessPanel() {
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);
}
if (statusQuery.isPending) {
return <section aria-label="Loading access" className="panel panel-loading" role="status"><span className="spinner" />Loading Reddit access...</section>;
}
if (statusQuery.isError) {
return (
<section className="panel error-panel" 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>
</section>
);
}
const session = statusQuery.data;
const username = session.ready && 'loggedIn' in session && session.loggedIn ? session.username ?? null : null;
return (
<section aria-labelledby="access-heading" className="panel settings-section">
<div className="panel-heading"><div><span className="eyebrow">Reddit accounts</span><h2 id="access-heading">Access</h2></div><span className={session.ready && 'status' in session && session.loggedIn ? 'pill success' : 'pill warning'}>{sessionLabel(session)}</span></div>
<p className="settings-copy">Add each Reddit account with its own extension session dump. Content and sync state stay owned by that account in the database. Any account with an expired login pauses while waiting for a fresh session dump; other accounts keep running.</p>
{username ? <p className="settings-copy">Verified account: <strong><RedditUsernameLink username={username} /></strong>.</p> : null}
<div aria-label="Reddit accounts" className="reddit-account-list">
{accountsQuery.isPending ? <p className="settings-copy" role="status"><span className="spinner" /> Loading Reddit accounts…</p> : null}
{accountsQuery.isError ? (
<div className="settings-notice error" role="alert">
<span>Could not load Reddit accounts: {errorMessage(accountsQuery.error)}</span>{' '}
<button className="button secondary" onClick={() => void accountsQuery.refetch()} type="button">Retry accounts</button>
</div>
) : null}
{(accountsQuery.data ?? []).map((account) => (
<div className={account.isRunning ? 'reddit-account-row is-active' : 'reddit-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 && accountsQuery.data.length === 0 ? <p className="settings-copy">No Reddit accounts have been added yet.</p> : null}
</div>
<div className="access-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 className="button primary" disabled={importMutation.isPending} onClick={() => importInput.current?.click()} type="button">{importMutation.isPending ? 'Adding account...' : 'Add or refresh account'}</button>
<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>
</div>
<p className="settings-copy">Open Reddit Chat in Chrome for the account you want to add, wait for the extension badge to show OK, then upload its JSON dump. It contains login credentials; do not share it.</p>
{importFileError ? <div className="settings-notice error" role="alert">{importFileError}</div> : null}
{importMutation.isSuccess ? <div className="settings-notice success" role="status"><span>Reddit session imported and verified.</span> <span>Account runtime is running independently.</span></div> : null}
{importMutation.isError ? <div className="settings-notice error" role="alert">{errorMessage(importMutation.error)}</div> : null}
</section>
);
}