Files
RedditBot/apps/dashboard/src/features/settings/settings-sections.tsx
T

236 lines
9.8 KiB
TypeScript

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: '', enabled: true, 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="toggle-field">
<input
aria-label={`Subreddit ${index + 1} enabled`}
checked={subreddit.enabled}
onChange={(event) => onChange(updateSubreddit(draft, index, { ...subreddit, enabled: event.target.checked }))}
type="checkbox"
/>
<span><strong>Enabled</strong><small>Disabled subreddits are not polled.</small></span>
</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>
{config.advanced ? (
<div className="advanced-settings">
<div><h3>Advanced application configuration</h3><p>Models, prompts, filtering, media sync, and strategy settings. Some changes take effect after a restart.</p></div>
<label className="settings-field">
<span>Advanced configuration (JSON)</span>
<textarea
aria-invalid={errors.advancedConfigJson ? 'true' : undefined}
aria-label="Advanced configuration JSON"
onChange={(event) => onChange({ ...draft, advancedConfigJson: event.target.value })}
rows={24}
spellCheck={false}
value={draft.advancedConfigJson}
/>
{errors.advancedConfigJson ? <small className="field-error">{errors.advancedConfigJson}</small> : null}
</label>
</div>
) : null}
</section>
);
}