feat: add settings and access dashboard slice

This commit is contained in:
jjbrunton committed 2026-08-08 15:25:34 +01:00
1 parent dcf2a1200c
commit 79de6aa2a1
11 files changed
+1066

No files matched your search

@@ -0,0 +1,59 @@
import { useLoginInfoQuery, useLoginStatusQuery, useStartLoginMutation } 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];
}
function noVncUrl(publicUrl: string | null, port: number): string {
if (publicUrl) return publicUrl;
return `${window.location.protocol}//${window.location.hostname}:${port}/vnc.html?autoconnect=true&resize=scale`;
}
export function AccessPanel() {
const infoQuery = useLoginInfoQuery();
const statusQuery = useLoginStatusQuery();
const loginMutation = useStartLoginMutation();
if (infoQuery.isPending || statusQuery.isPending) {
return <section aria-label="Loading access" className="panel panel-loading" role="status"><span className="spinner" />Loading Reddit access…</section>;
}
if (infoQuery.isError || statusQuery.isError) {
const error = infoQuery.error ?? statusQuery.error;
return (
<section className="panel error-panel" role="alert">
<strong>Reddit access unavailable</strong><span>{errorMessage(error)}</span>
<button className="button secondary" onClick={() => void Promise.all([infoQuery.refetch(), statusQuery.refetch()])} type="button">Retry access</button>
</section>
);
}
const info = infoQuery.data;
return (
<section aria-labelledby="access-heading" className="panel settings-section">
<div className="panel-heading"><div><span className="eyebrow">Browser session</span><h2 id="access-heading">Access</h2></div><span className={statusQuery.data.ready && 'status' in statusQuery.data && statusQuery.data.loggedIn ? 'pill success' : 'pill warning'}>{sessionLabel(statusQuery.data)}</span></div>
<p className="settings-copy">Open Reddit in the managed browser, complete login through noVNC, then recheck the session. Credentials are never entered or displayed here.</p>
<div className="access-actions">
<button className="button primary" disabled={loginMutation.isPending} onClick={() => loginMutation.mutate()} type="button">{loginMutation.isPending ? 'Opening login…' : 'Open Reddit login'}</button>
<button className="button secondary" disabled={statusQuery.isFetching} onClick={() => void statusQuery.refetch()} type="button">{statusQuery.isFetching ? 'Checking session…' : 'Recheck session'}</button>
{info.vncEnabled ? <a className="button secondary" href={noVncUrl(info.publicUrl, info.novncPort)} rel="noreferrer" target="_blank">Open noVNC login window</a> : null}
</div>
{!info.vncEnabled ? <div className="settings-notice warning" role="status">noVNC is not available in this deployment.</div> : null}
{info.passwordRequired ? <p className="settings-copy">A VNC password is required.</p> : null}
{loginMutation.isSuccess ? <div className="settings-notice success" role="status">Login page opened.</div> : null}
{loginMutation.isError ? <div className="settings-notice error" role="alert">{errorMessage(loginMutation.error)}</div> : null}
</section>
);
}
@@ -0,0 +1,34 @@
import { useEffect, useRef } from 'react';
interface RiskDialogProps {
reasons: string[];
isPending: boolean;
onCancel(): void;
onConfirm(): void;
}
export function RiskDialog({ reasons, isPending, onCancel, onConfirm }: RiskDialogProps) {
const cancelButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
cancelButtonRef.current?.focus();
}, []);
return (
<div aria-labelledby="risk-settings-title" aria-modal="true" className="dialog-backdrop" role="dialog">
<div className="dialog-card">
<span className="eyebrow">Confirm operational risk</span>
<h2 id="risk-settings-title">Confirm risky settings</h2>
<ul className="risk-list">
{reasons.map((reason) => <li key={reason}>{reason}</li>)}
</ul>
<div className="dialog-actions">
<button className="button secondary" disabled={isPending} onClick={onCancel} ref={cancelButtonRef} type="button">Cancel</button>
<button className="button danger" disabled={isPending} onClick={onConfirm} type="button">
{isPending ? 'Applying…' : 'Apply risky settings'}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,58 @@
import type { ZodType } from 'zod';
import {
ConfigResponseSchema,
ConfigUpdateRequestSchema,
ConfigUpdateResponseSchema,
ErrorResponseSchema,
LoginInfoResponseSchema,
LoginStartResponseSchema,
LoginStatusResponseSchema,
type ConfigResponse,
type ConfigUpdateRequest,
type LoginStatusResponse,
} from '../../../../../packages/api-contracts/src/index.js';
async function parseResponse<T>(response: Response, schema: ZodType<T>): Promise<T> {
const payload: unknown = await response.json();
if (!response.ok) {
const parsedError = ErrorResponseSchema.safeParse(payload);
throw new Error(parsedError.success ? parsedError.data.error : `Request failed (${response.status})`);
}
return schema.parse(payload);
}
function request(path: string, init?: RequestInit): Promise<Response> {
return globalThis.fetch(path, { credentials: 'same-origin', ...init });
}
export function fetchSettings(): Promise<ConfigResponse> {
return request('/api/config').then((response) => parseResponse(response, ConfigResponseSchema));
}
export async function saveSettings(updates: ConfigUpdateRequest) {
const body = ConfigUpdateRequestSchema.parse(updates);
return parseResponse(
await request('/api/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}),
ConfigUpdateResponseSchema,
);
}
export function fetchLoginInfo() {
return request('/api/login/info').then((response) => parseResponse(response, LoginInfoResponseSchema));
}
export function fetchLoginStatus(): Promise<LoginStatusResponse> {
return request('/api/login/status').then((response) => parseResponse(response, LoginStatusResponseSchema));
}
export async function startLogin(): Promise<void> {
const response = await parseResponse(
await request('/api/login/start', { method: 'POST' }),
LoginStartResponseSchema,
);
if (!response.ok) throw new Error(response.error);
}
@@ -0,0 +1,117 @@
import { useEffect, useState } from 'react';
import type { ConfigResponse, ConfigUpdateRequest } from '../../../../../packages/api-contracts/src/index.js';
import { AccessPanel } from './access-panel.js';
import { RiskDialog } from './risk-dialog.js';
import { useSaveSettingsMutation } from './settings-queries.js';
import { createSettingsDraft, isDraftDirty, validateSettingsDraft, type SettingsDraft } from './settings-model.js';
import { BehaviourSection, MonitoringSection, RateLimitsSection } from './settings-sections.js';
import type { SettingsSection } from './settings-page.js';
interface SettingsEditorProps {
config: ConfigResponse;
section: SettingsSection;
}
interface PendingRiskySave {
payload: ConfigUpdateRequest;
reasons: string[];
}
function errorMessage(error: Error | null): string {
return error?.message ?? 'Something went wrong.';
}
export function SettingsEditor({ config, section }: SettingsEditorProps) {
const [persistedDraft, setPersistedDraft] = useState<SettingsDraft>(() => createSettingsDraft(config));
const [draft, setDraft] = useState<SettingsDraft>(persistedDraft);
const [errors, setErrors] = useState<Record<string, string>>({});
const [pendingRiskySave, setPendingRiskySave] = useState<PendingRiskySave | null>(null);
const saveMutation = useSaveSettingsMutation();
const isDirty = isDraftDirty(draft, persistedDraft);
useEffect(() => {
function warnBeforeUnload(event: BeforeUnloadEvent) {
if (!isDirty) return;
event.preventDefault();
event.returnValue = '';
}
window.addEventListener('beforeunload', warnBeforeUnload);
return () => window.removeEventListener('beforeunload', warnBeforeUnload);
}, [isDirty]);
function applySavedResponse(response: Awaited<ReturnType<typeof saveMutation.mutateAsync>>) {
const nextConfig: ConfigResponse = {
...response.config,
subreddits: response.subreddits,
tones: config.tones,
};
const nextDraft = createSettingsDraft(nextConfig);
setPersistedDraft(nextDraft);
setDraft(nextDraft);
setPendingRiskySave(null);
}
function submit(payload: ConfigUpdateRequest) {
saveMutation.mutate(payload, { onSuccess: applySavedResponse });
}
function updateDraft(nextDraft: SettingsDraft) {
saveMutation.reset();
setErrors({});
setDraft(nextDraft);
}
function prepareSave() {
saveMutation.reset();
const validation = validateSettingsDraft(draft);
setErrors(validation.errors);
if (!validation.data) return;
const reasons: string[] = [];
if (persistedDraft.dryRun && !validation.data.dryRun) {
reasons.push('Automated messages will be sent to Reddit because dry run mode is being disabled.');
}
const persistedLimit = Number(persistedDraft.maxMessagesPerHour);
if (validation.data.maxMessagesPerHour > 20 && validation.data.maxMessagesPerHour > persistedLimit) {
reasons.push(`The limit will allow ${validation.data.maxMessagesPerHour} messages per hour.`);
}
if (reasons.length > 0) {
setPendingRiskySave({ payload: validation.data.payload, reasons });
return;
}
submit(validation.data.payload);
}
return (
<>
{section === 'monitoring' ? <MonitoringSection draft={draft} errors={errors} onChange={updateDraft} /> : null}
{section === 'rate-limits' ? <RateLimitsSection draft={draft} errors={errors} onChange={updateDraft} /> : null}
{section === 'behaviour' ? <BehaviourSection config={config} draft={draft} errors={errors} onChange={updateDraft} /> : null}
{section === 'access' ? <AccessPanel /> : null}
{section !== 'access' ? (
<div className="settings-save-bar">
<div aria-live="polite">
{isDirty ? <span role="status">You have unsaved changes.</span> : <span>All changes saved.</span>}
{saveMutation.isSuccess ? <strong className="save-success">Settings saved.</strong> : null}
{saveMutation.isError ? <strong className="save-error" role="alert">{errorMessage(saveMutation.error)}</strong> : null}
</div>
<div className="header-actions">
<button className="button secondary" disabled={!isDirty || saveMutation.isPending} onClick={() => { setDraft(persistedDraft); setErrors({}); saveMutation.reset(); }} type="button">Reset changes</button>
<button aria-label={saveMutation.isPending ? 'Saving settings' : 'Save settings'} className="button primary" disabled={!isDirty || saveMutation.isPending} onClick={prepareSave} type="button">{saveMutation.isPending ? 'Saving…' : 'Save settings'}</button>
</div>
</div>
) : null}
{pendingRiskySave ? (
<RiskDialog
isPending={saveMutation.isPending}
onCancel={() => setPendingRiskySave(null)}
onConfirm={() => submit(pendingRiskySave.payload)}
reasons={pendingRiskySave.reasons}
/>
) : null}
</>
);
}
@@ -0,0 +1,142 @@
import { z } from 'zod';
import {
ConfigUpdateRequestSchema,
SubredditConfigSchema,
ToneKeySchema,
type ConfigResponse,
type ConfigUpdateRequest,
} from '../../../../../packages/api-contracts/src/index.js';
export interface SubredditDraft {
name: string;
type: 'image' | 'chat';
weight: string;
tone: z.infer<typeof ToneKeySchema>;
scripted: string;
}
export interface SettingsDraft {
minDelayMinutes: string;
maxMessagesPerHour: string;
jitterSeconds: string;
dryRun: boolean;
prioritizeRecentPosts: boolean;
recentWindowMinutes: string;
subreddits: SubredditDraft[];
}
export interface ValidatedSettings {
payload: ConfigUpdateRequest;
dryRun: boolean;
maxMessagesPerHour: number;
}
export interface SettingsValidationResult {
data?: ValidatedSettings;
errors: Record<string, string>;
}
const EditableSubredditSchema = SubredditConfigSchema.extend({
name: z.string().trim().min(1, 'Subreddit name is required.'),
weight: z.number().positive('Subreddit weight must be greater than 0.').optional(),
});
const ValidatedSettingsSchema = z.strictObject({
minDelayMinutes: z.number().finite().min(0, 'Minimum delay cannot be negative.'),
maxMessagesPerHour: z.number().int().min(1, 'Maximum messages per hour must be at least 1.'),
jitterSeconds: z.number().finite().min(0, 'Jitter cannot be negative.'),
dryRun: z.boolean(),
prioritizeRecentPosts: z.boolean(),
recentWindowMinutes: z.number().finite().positive('Recent window must be greater than 0 minutes.'),
subreddits: z.array(EditableSubredditSchema).min(1, 'At least one subreddit is required.'),
}).superRefine((value, context) => {
const names = value.subreddits.map((subreddit) => subreddit.name.trim().toLowerCase());
if (new Set(names).size !== names.length) {
context.addIssue({ code: 'custom', path: ['subreddits'], message: 'Subreddit names must be unique.' });
}
});
export function createSettingsDraft(config: ConfigResponse): SettingsDraft {
return {
minDelayMinutes: String(config.rateLimit.minDelayBetweenMessagesMs / 60_000),
maxMessagesPerHour: String(config.rateLimit.maxMessagesPerHour),
jitterSeconds: String(config.rateLimit.jitterMs / 1_000),
dryRun: config.browser.dryRun,
prioritizeRecentPosts: config.reddit.prioritizeRecentPosts,
recentWindowMinutes: String(config.reddit.recentWindowMinutes),
subreddits: config.subreddits.map((subreddit) => ({
name: subreddit.name,
type: subreddit.type,
weight: subreddit.weight === undefined ? '' : String(subreddit.weight),
tone: subreddit.tone,
scripted: subreddit.scripted ?? '',
})),
};
}
export function validateSettingsDraft(draft: SettingsDraft): SettingsValidationResult {
const requiredErrors: Record<string, string> = {};
if (draft.minDelayMinutes.trim() === '') requiredErrors.minDelayMinutes = 'Minimum delay is required.';
if (draft.maxMessagesPerHour.trim() === '') requiredErrors.maxMessagesPerHour = 'Maximum messages per hour is required.';
if (draft.jitterSeconds.trim() === '') requiredErrors.jitterSeconds = 'Jitter is required.';
if (draft.recentWindowMinutes.trim() === '') requiredErrors.recentWindowMinutes = 'Recent window is required.';
draft.subreddits.forEach((subreddit, index) => {
if (subreddit.name.trim() === '') requiredErrors[`subreddits.${index}.name`] = 'Subreddit name is required.';
if (subreddit.weight.trim() !== '' && Number(subreddit.weight) <= 0) {
requiredErrors[`subreddits.${index}.weight`] = 'Subreddit weight must be greater than 0.';
}
});
if (Object.keys(requiredErrors).length > 0) return { errors: requiredErrors };
const parsed = ValidatedSettingsSchema.safeParse({
minDelayMinutes: Number(draft.minDelayMinutes),
maxMessagesPerHour: Number(draft.maxMessagesPerHour),
jitterSeconds: Number(draft.jitterSeconds),
dryRun: draft.dryRun,
prioritizeRecentPosts: draft.prioritizeRecentPosts,
recentWindowMinutes: Number(draft.recentWindowMinutes),
subreddits: draft.subreddits.map((subreddit) => ({
name: subreddit.name.trim(),
type: subreddit.type,
weight: subreddit.weight.trim() === '' ? undefined : Number(subreddit.weight),
tone: subreddit.tone,
scripted: subreddit.scripted.trim() === '' ? undefined : subreddit.scripted,
})),
});
if (!parsed.success) {
const errors: Record<string, string> = {};
for (const issue of parsed.error.issues) {
const path = issue.path.join('.');
if (!(path in errors)) errors[path] = issue.message;
}
return { errors };
}
const payload = ConfigUpdateRequestSchema.parse({
rateLimit: {
minDelayBetweenMessagesMs: parsed.data.minDelayMinutes * 60_000,
maxMessagesPerHour: parsed.data.maxMessagesPerHour,
jitterMs: parsed.data.jitterSeconds * 1_000,
},
browser: { dryRun: parsed.data.dryRun },
reddit: {
prioritizeRecentPosts: parsed.data.prioritizeRecentPosts,
recentWindowMinutes: parsed.data.recentWindowMinutes,
},
subreddits: parsed.data.subreddits,
});
return {
data: {
payload,
dryRun: parsed.data.dryRun,
maxMessagesPerHour: parsed.data.maxMessagesPerHour,
},
errors: {},
};
}
export function isDraftDirty(draft: SettingsDraft, persisted: SettingsDraft): boolean {
return JSON.stringify(draft) !== JSON.stringify(persisted);
}
@@ -0,0 +1,44 @@
import { SettingsEditor } from './settings-editor.js';
import { useSettingsQuery } from './settings-queries.js';
import './settings.css';
export type SettingsSection = 'monitoring' | 'rate-limits' | 'behaviour' | 'access';
interface SettingsPageProps {
section: SettingsSection;
onSectionChange(section: SettingsSection): void;
}
const sections: ReadonlyArray<{ id: SettingsSection; label: string; description: string }> = [
{ id: 'monitoring', label: 'Monitoring', description: 'Post discovery and recency' },
{ id: 'rate-limits', label: 'Rate limits', description: 'Sending pace and safeguards' },
{ id: 'behaviour', label: 'Behaviour', description: 'Dry run and subreddits' },
{ id: 'access', label: 'Access', description: 'Reddit browser session' },
];
function errorMessage(error: Error | null): string {
return error?.message ?? 'Something went wrong.';
}
export function SettingsPage({ section, onSectionChange }: SettingsPageProps) {
const settingsQuery = useSettingsQuery();
return (
<div className="page settings-page">
<header className="page-header">
<div><span className="eyebrow">Platform configuration</span><h1>Settings</h1><p>Manage supported runtime behaviour and Reddit browser access.</p></div>
</header>
<nav aria-label="Settings sections" className="settings-navigation">
{sections.map((item) => (
<button aria-current={section === item.id ? 'page' : undefined} aria-label={item.label} className={section === item.id ? 'is-active' : ''} key={item.id} onClick={() => onSectionChange(item.id)} type="button"><strong>{item.label}</strong><small>{item.description}</small></button>
))}
</nav>
{settingsQuery.isPending ? <div aria-label="Loading settings" className="page-state settings-loading" role="status"><span className="spinner" />Loading settings…</div> : null}
{settingsQuery.isError ? (
<div className="page-state error-state settings-loading" role="alert"><strong>Settings unavailable</strong><span>{errorMessage(settingsQuery.error)}</span><button className="button secondary" onClick={() => void settingsQuery.refetch()} type="button">Retry settings</button></div>
) : null}
{settingsQuery.data ? <SettingsEditor config={settingsQuery.data} section={section} /> : null}
</div>
);
}
@@ -0,0 +1,45 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { ConfigResponse } from '../../../../../packages/api-contracts/src/index.js';
import { fetchLoginInfo, fetchLoginStatus, fetchSettings, saveSettings, startLogin } from './settings-api.js';
export const settingsQueryKeys = {
config: ['dashboard', 'config'] as const,
loginInfo: ['dashboard', 'login', 'info'] as const,
loginStatus: ['dashboard', 'login', 'status'] as const,
};
export function useSettingsQuery() {
return useQuery({ queryKey: settingsQueryKeys.config, queryFn: fetchSettings });
}
export function useSaveSettingsMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: saveSettings,
onSuccess(response) {
queryClient.setQueryData<ConfigResponse>(settingsQueryKeys.config, (current) => ({
...response.config,
subreddits: response.subreddits,
tones: current?.tones ?? [],
}));
},
});
}
export function useLoginInfoQuery() {
return useQuery({ queryKey: settingsQueryKeys.loginInfo, queryFn: fetchLoginInfo });
}
export function useLoginStatusQuery() {
return useQuery({ queryKey: settingsQueryKeys.loginStatus, queryFn: fetchLoginStatus });
}
export function useStartLoginMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: startLogin,
onSuccess() {
void queryClient.invalidateQueries({ queryKey: settingsQueryKeys.loginStatus });
},
});
}
@@ -0,0 +1,19 @@
import { useSearchParams } from 'react-router-dom';
import { SettingsPage, type SettingsSection } from './settings-page.js';
const validSections: readonly SettingsSection[] = ['monitoring', 'rate-limits', 'behaviour', 'access'];
function parseSection(value: string | null): SettingsSection {
return validSections.find((section) => section === value) ?? 'monitoring';
}
export function SettingsRoute() {
const [searchParams, setSearchParams] = useSearchParams();
const section = parseSection(searchParams.get('section'));
function selectSection(nextSection: SettingsSection) {
setSearchParams({ section: nextSection }, { replace: true });
}
return <SettingsPage onSectionChange={selectSection} section={section} />;
}
@@ -0,0 +1,209 @@
import type { ConfigResponse } from '../../../../../packages/api-contracts/src/index.js';
import type { SettingsDraft, SubredditDraft } from './settings-model.js';
interface SectionProps {
draft: SettingsDraft;
errors: Record<string, string>;
onChange(next: SettingsDraft): void;
}
interface NumberFieldProps {
error?: string;
label: string;
name: string;
min?: number;
value: string;
onChange(value: string): void;
}
function NumberField({ error, label, name, min, value, onChange }: NumberFieldProps) {
const errorId = `${name}-error`;
return (
<label className="settings-field">
<span>{label}</span>
<input
aria-describedby={error ? errorId : undefined}
aria-invalid={error ? 'true' : undefined}
aria-label={label}
min={min}
name={name}
onChange={(event) => onChange(event.target.value)}
step="1"
type="number"
value={value}
/>
{error ? <small className="field-error" id={errorId}>{error}</small> : null}
</label>
);
}
export function MonitoringSection({ draft, errors, onChange }: SectionProps) {
return (
<section aria-labelledby="monitoring-heading" className="panel settings-section">
<div className="panel-heading"><div><span className="eyebrow">Discovery</span><h2 id="monitoring-heading">Monitoring</h2></div></div>
<label className="toggle-field">
<input
aria-label="Prioritise recent posts"
checked={draft.prioritizeRecentPosts}
onChange={(event) => onChange({ ...draft, prioritizeRecentPosts: event.target.checked })}
type="checkbox"
/>
<span><strong>Prioritise recent posts</strong><small>Prefer posts inside the configured recent window.</small></span>
</label>
<NumberField
error={errors.recentWindowMinutes}
label="Recent window in minutes"
min={1}
name="recent-window"
onChange={(recentWindowMinutes) => onChange({ ...draft, recentWindowMinutes })}
value={draft.recentWindowMinutes}
/>
</section>
);
}
export function RateLimitsSection({ draft, errors, onChange }: SectionProps) {
return (
<section aria-labelledby="rate-limits-heading" className="panel settings-section">
<div className="panel-heading"><div><span className="eyebrow">Sending safeguards</span><h2 id="rate-limits-heading">Rate limits</h2></div></div>
<div className="settings-grid">
<NumberField
error={errors.minDelayMinutes}
label="Minimum delay in minutes"
min={0}
name="minimum-delay"
onChange={(minDelayMinutes) => onChange({ ...draft, minDelayMinutes })}
value={draft.minDelayMinutes}
/>
<NumberField
error={errors.maxMessagesPerHour}
label="Maximum messages per hour"
min={1}
name="hourly-limit"
onChange={(maxMessagesPerHour) => onChange({ ...draft, maxMessagesPerHour })}
value={draft.maxMessagesPerHour}
/>
<NumberField
error={errors.jitterSeconds}
label="Random jitter in seconds"
min={0}
name="jitter"
onChange={(jitterSeconds) => onChange({ ...draft, jitterSeconds })}
value={draft.jitterSeconds}
/>
</div>
</section>
);
}
function updateSubreddit(draft: SettingsDraft, index: number, next: SubredditDraft): SettingsDraft {
return { ...draft, subreddits: draft.subreddits.map((item, itemIndex) => itemIndex === index ? next : item) };
}
export function BehaviourSection({ config, draft, errors, onChange }: SectionProps & { config: ConfigResponse }) {
return (
<section aria-labelledby="behaviour-heading" className="panel settings-section">
<div className="panel-heading"><div><span className="eyebrow">Runtime behaviour</span><h2 id="behaviour-heading">Behaviour</h2></div></div>
<label className="toggle-field">
<input
aria-label="Dry run mode"
checked={draft.dryRun}
onChange={(event) => onChange({ ...draft, dryRun: event.target.checked })}
type="checkbox"
/>
<span><strong>Dry run mode</strong><small>Evaluate posts without sending messages.</small></span>
</label>
<div className="subreddit-heading">
<div><h3>Subreddits</h3><p>Configure monitored communities and their supported message style.</p></div>
<button
className="button secondary"
onClick={() => onChange({
...draft,
subreddits: [
...draft.subreddits,
{ name: '', type: 'image', weight: '1', tone: 'default', scripted: '' },
],
})}
type="button"
>
Add subreddit
</button>
</div>
{errors.subreddits ? <p className="field-error" role="alert">{errors.subreddits}</p> : null}
<div className="subreddit-list">
{draft.subreddits.map((subreddit, index) => (
<fieldset className="subreddit-row" key={index}>
<legend>Subreddit {index + 1}</legend>
<label className="settings-field">
<span>Name</span>
<input
aria-describedby={errors[`subreddits.${index}.name`] ? `subreddit-${index}-name-error` : undefined}
aria-invalid={errors[`subreddits.${index}.name`] ? 'true' : undefined}
aria-label={`Subreddit ${index + 1} name`}
onChange={(event) => onChange(updateSubreddit(draft, index, { ...subreddit, name: event.target.value }))}
type="text"
value={subreddit.name}
/>
{errors[`subreddits.${index}.name`] ? <small className="field-error" id={`subreddit-${index}-name-error`}>{errors[`subreddits.${index}.name`]}</small> : null}
</label>
<label className="settings-field">
<span>Type</span>
<select
aria-label={`Subreddit ${index + 1} type`}
onChange={(event) => onChange(updateSubreddit(draft, index, { ...subreddit, type: event.target.value as SubredditDraft['type'] }))}
value={subreddit.type}
>
<option value="image">Image</option><option value="chat">Chat</option>
</select>
</label>
<label className="settings-field">
<span>Weight</span>
<input
aria-describedby={errors[`subreddits.${index}.weight`] ? `subreddit-${index}-weight-error` : undefined}
aria-invalid={errors[`subreddits.${index}.weight`] ? 'true' : undefined}
aria-label={`Subreddit ${index + 1} weight`}
min="0.01"
onChange={(event) => onChange(updateSubreddit(draft, index, { ...subreddit, weight: event.target.value }))}
step="0.01"
type="number"
value={subreddit.weight}
/>
{errors[`subreddits.${index}.weight`] ? <small className="field-error" id={`subreddit-${index}-weight-error`}>{errors[`subreddits.${index}.weight`]}</small> : null}
</label>
<label className="settings-field">
<span>Tone</span>
<select
aria-label={`Subreddit ${index + 1} tone`}
onChange={(event) => onChange(updateSubreddit(draft, index, { ...subreddit, tone: event.target.value as SubredditDraft['tone'] }))}
value={subreddit.tone}
>
{config.tones.map((tone) => <option key={tone} value={tone}>{tone}</option>)}
</select>
</label>
<label className="settings-field scripted-field">
<span>Scripted message (optional)</span>
<textarea
aria-label={`Subreddit ${index + 1} scripted message`}
onChange={(event) => onChange(updateSubreddit(draft, index, { ...subreddit, scripted: event.target.value }))}
rows={2}
value={subreddit.scripted}
/>
</label>
<button
className="button secondary remove-subreddit"
disabled={draft.subreddits.length === 1}
onClick={() => onChange({
...draft,
subreddits: draft.subreddits.filter((_, itemIndex) => itemIndex !== index),
})}
type="button"
>
Remove subreddit {index + 1}
</button>
</fieldset>
))}
</div>
</section>
);
}
@@ -0,0 +1,48 @@
.settings-page { max-width: 72rem; }
.settings-navigation { display: grid; grid-template-columns: 1fr; gap: .5rem; margin-bottom: 1rem; }
.settings-navigation button { display: grid; gap: .15rem; padding: .8rem; color: var(--muted); text-align: left; background: var(--surface); border: 1px solid var(--border); border-radius: .75rem; }
.settings-navigation button strong { color: #e9f1ec; }
.settings-navigation button small { font-size: .72rem; }
.settings-navigation button.is-active { background: var(--accent-dark); border-color: #28593d; }
.settings-navigation button:focus-visible, .settings-field input:focus-visible, .settings-field select:focus-visible, .settings-field textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.settings-loading { min-height: 24rem; }
.settings-section { display: grid; gap: 1rem; }
.settings-copy { color: var(--muted); }
.settings-grid { display: grid; gap: 1rem; }
.settings-field { display: grid; gap: .4rem; color: var(--muted); font-size: .8rem; font-weight: 750; }
.settings-field input, .settings-field select, .settings-field textarea { width: 100%; min-height: 2.7rem; padding: .55rem .7rem; color: #e9f1ec; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .6rem; }
.settings-field textarea { resize: vertical; }
.settings-field [aria-invalid="true"] { border-color: var(--danger); }
.field-error, .save-error { color: var(--danger); }
.toggle-field { display: flex; align-items: flex-start; gap: .8rem; padding: .9rem; background: var(--surface-raised); border: 1px solid var(--border); border-radius: .75rem; }
.toggle-field input { width: 1.15rem; height: 1.15rem; margin-top: .15rem; accent-color: var(--accent); }
.toggle-field span { display: grid; gap: .2rem; }
.toggle-field small { color: var(--muted); }
.subreddit-heading { display: flex; flex-wrap: wrap; align-items: flex-end; justify-content: space-between; gap: 1rem; padding-top: .5rem; }
.subreddit-heading h3 { margin: 0 0 .25rem; }
.subreddit-heading p { margin: 0; color: var(--muted); }
.subreddit-list { display: grid; gap: .75rem; }
.subreddit-row { display: grid; gap: .75rem; min-width: 0; padding: 1rem; border: 1px solid var(--border); border-radius: .75rem; }
.subreddit-row legend { padding: 0 .4rem; color: var(--accent); font-weight: 800; }
.remove-subreddit { align-self: end; }
.settings-save-bar { position: sticky; bottom: 1rem; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 1rem; padding: .9rem 1rem; margin-top: 1rem; background: rgba(23, 35, 30, .97); border: 1px solid var(--border); border-radius: .8rem; box-shadow: 0 1rem 3rem rgba(0, 0, 0, .3); }
.settings-save-bar > div:first-child { display: grid; gap: .15rem; color: var(--muted); }
.save-success { color: var(--accent); }
.risk-list { display: grid; gap: .5rem; padding-left: 1.2rem; color: var(--warning); }
.access-actions { display: flex; flex-wrap: wrap; gap: .75rem; }
.access-actions a { display: inline-flex; align-items: center; text-decoration: none; }
.settings-notice { padding: .75rem; border-radius: .65rem; }
.settings-notice.success { color: #aaf0c4; background: #163a26; }
.settings-notice.warning { color: #ffe1a0; background: #42351b; }
.settings-notice.error { color: #ffd1ce; background: #461b18; }
@media (min-width: 42rem) {
.settings-navigation { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.settings-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.subreddit-row { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.scripted-field { grid-column: 1 / -1; }
}
@media (min-width: 70rem) {
.settings-navigation { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}