Skip to content
Merged
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
1d8d529
feat: shadcn/ui theme setup with dark mode toggle
Mar 1, 2026
eec474f
feat: migrate dashboard to shadcn components (Switch, Input, Label, F…
Mar 1, 2026
a2f4719
feat: add RoleSelector and ChannelSelector components with auto-fill
Mar 1, 2026
77f734f
fix: apply Biome formatting (single quotes + semicolons) to shadcn UI…
Mar 1, 2026
6ba21bf
fix: add accessible labels and connect htmlFor/id pairs for screen re…
Mar 1, 2026
e73a841
fix: form context null guards, PopoverTitle h2, stale roles clear, re…
Mar 1, 2026
e6141b2
fix: move DialogHeader inside DialogContent and fix invalid Tailwind …
Mar 1, 2026
c1cc229
fix(channel-selector): clear stale channels on guild change
Mar 1, 2026
9c4166b
fix(channel-selector): show removable chips for unknown selected IDs
Mar 1, 2026
2eaed47
fix(role-selector): show removable chips for unknown selected role IDs
Mar 1, 2026
90ea779
fix(channel-selector): guard setChannels/setError against stale requests
BillChirico Mar 1, 2026
5e0eb28
fix(form): import LabelPrimitive as value not type
BillChirico Mar 1, 2026
bdb08b1
fix(dialog): add type="button" to footer close button
BillChirico Mar 1, 2026
389d176
fix(providers): wire Toaster theme to resolvedTheme
BillChirico Mar 1, 2026
a166a4f
fix(role-selector): guard setRoles/setError against stale requests
BillChirico Mar 1, 2026
d0afd59
fix: resolve merge conflicts with main
BillChirico Mar 1, 2026
fb97c1b
fix(form): protect FormControl a11y attrs from consumer override
Mar 1, 2026
a73e1b1
fix(form): protect FormLabel htmlFor from consumer override
Mar 1, 2026
458e281
fix(dialog): remove data-slot from non-rendered Radix primitives
Mar 1, 2026
a02c3d7
docs(providers): update JSDoc to reflect resolved theme usage
Mar 1, 2026
c6ffe2e
test(setup): add window.matchMedia polyfill for next-themes in jsdom
Mar 1, 2026
17ab20b
fix(deps): regenerate pnpm lockfile to sync with package.json changes
Mar 1, 2026
e1fa5e4
fix: add missing next-themes dependency to web package.json
Mar 1, 2026
4b6bc14
fix(deps): add react-hook-form dependency to web/package.json
Mar 1, 2026
00001c9
fix: use type-only import for LabelPrimitive in form.tsx
Mar 1, 2026
bddcb85
fix(lint): auto-fix all Biome errors (import type, organize imports, …
Mar 1, 2026
a8742e0
fix(tests): guard matchMedia polyfill with existence check and config…
Mar 1, 2026
cf92f21
feat: implement RoleSelector and ChannelSelector across dashboard config
Mar 1, 2026
05978bc
📝 Add docstrings to `feat/selector-implementation-v2`
coderabbitai[bot] Mar 1, 2026
b8d09b3
fix: address CodeRabbit review comments
Mar 1, 2026
75670a8
fix: resolve merge conflicts with main
BillChirico Mar 1, 2026
cca50e4
fix: address PR #175 critical review comments
Mar 1, 2026
f00deac
fix: complete all 20 review thread fixes for PR #175
Mar 1, 2026
fadff03
fix: lint errors - type import and label htmlFor
Mar 1, 2026
4c76a4d
fix(selectors): label accessibility, remove dead code, format (#179)
BillChirico Mar 1, 2026
e632486
fix(a11y): remove dangling htmlFor on Label in ModerationSection no-g…
BillChirico Mar 1, 2026
cedb2df
fix(a11y): remove duplicate id=admin-role from RoleSelector in option…
BillChirico Mar 1, 2026
a98a75e
fix(a11y): fix typo in htmlFor/id — comma-separat → comma-separated
BillChirico Mar 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 1 addition & 2 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,7 @@
"server-only": "^0.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"next-themes": "^0.4.6",
"react-hook-form": "^7.56.4"
"zustand": "^5.0.11"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.1",
Expand Down
181 changes: 120 additions & 61 deletions web/src/components/dashboard/config-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { RoleSelector } from '@/components/ui/role-selector';
import { GUILD_SELECTED_EVENT, SELECTED_GUILD_KEY } from '@/lib/guild-selection';
import type { BotConfig, DeepPartial } from '@/types/config';
import { SYSTEM_PROMPT_MAX_LENGTH } from '@/types/config';
Expand All @@ -27,7 +28,14 @@ const DEFAULT_ACTIVITY_BADGES = [
{ days: 0, label: '🌱 Newcomer' },
] as const;

/** Parse a number input value, enforcing optional min/max constraints. Returns undefined if invalid. */
/**
* Parse a numeric text input into a number, applying optional minimum/maximum bounds.
*
* @param raw - The input string to parse; an empty string yields `undefined`.
* @param min - Optional lower bound; if the parsed value is less than `min`, `min` is returned.
* @param max - Optional upper bound; if the parsed value is greater than `max`, `max` is returned.
* @returns `undefined` if `raw` is empty or cannot be parsed as a finite number, otherwise the parsed number (clamped to `min`/`max` when provided).
*/
function parseNumberInput(raw: string, min?: number, max?: number): number | undefined {
if (raw === '') return undefined;
const num = Number(raw);
Expand All @@ -37,32 +45,6 @@ function parseNumberInput(raw: string, min?: number, max?: number): number | und
return num;
}

function parseRoleMenuOptions(raw: string) {
return raw
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [label = '', roleId = '', ...descParts] = line.split('|');
const description = descParts.join('|').trim();
return {
label: label.trim(),
roleId: roleId.trim(),
...(description ? { description } : {}),
};
})
.filter((opt) => opt.label && opt.roleId)
.slice(0, 25);
}

function stringifyRoleMenuOptions(
options: Array<{ label?: string; roleId?: string; description?: string }> = [],
) {
return options
.map((opt) => [opt.label ?? '', opt.roleId ?? '', opt.description ?? ''].join('|'))
.join('\n');
}

/**
* Type guard that checks whether a value is a guild configuration object returned by the API.
*
Expand Down Expand Up @@ -110,11 +92,13 @@ function isGuildConfig(data: unknown): data is GuildConfig {
}

/**
* Renders the configuration editor for a selected guild, allowing viewing and editing of AI, welcome, moderation, and triage settings.
* Edit a guild's bot configuration through a multi-section UI.
*
* The component loads the guild's authoritative config from the API, keeps a mutable draft for user edits, computes and applies patch updates per top-level section, warns on unsaved changes, and provides keyboard and UI controls for saving or discarding edits.
* Loads the authoritative config for the selected guild, maintains a mutable draft for user edits,
* computes and applies per-section patches to persist changes, and provides controls to save,
* discard, and validate edits (including an unsaved-changes warning and keyboard shortcut).
*
* @returns The editor UI as JSX when a guild is selected and the draft config is available; `null` while no draft is present (or when rendering is handled by loading/error/no-selection states).
* @returns The editor UI as JSX when a guild is selected and a draft config exists; `null` otherwise.
*/
export function ConfigEditor() {
const [guildId, setGuildId] = useState<string>('');
Expand All @@ -128,7 +112,6 @@ export function ConfigEditor() {
const [draftConfig, setDraftConfig] = useState<GuildConfig | null>(null);

/** Raw textarea strings — kept separate so partial input isn't stripped on every keystroke. */
const [roleMenuRaw, setRoleMenuRaw] = useState('');
const [dmStepsRaw, setDmStepsRaw] = useState('');

const abortRef = useRef<AbortController | null>(null);
Expand Down Expand Up @@ -197,9 +180,15 @@ export function ConfigEditor() {
throw new Error('Invalid config response');
}

// Ensure role menu options have stable IDs
if (data.welcome?.roleMenu?.options) {
data.welcome.roleMenu.options = data.welcome.roleMenu.options.map((opt) => ({
...opt,
id: opt.id || crypto.randomUUID(),
}));
}
setSavedConfig(data);
setDraftConfig(structuredClone(data));
setRoleMenuRaw(stringifyRoleMenuOptions(data.welcome?.roleMenu?.options ?? []));
setDmStepsRaw((data.welcome?.dmSequence?.steps ?? []).join('\n'));
} catch (err) {
if ((err as Error).name === 'AbortError') return;
Expand All @@ -226,6 +215,12 @@ export function ConfigEditor() {
// Currently only validates system prompt length; extend with additional checks as needed.
const hasValidationErrors = useMemo(() => {
if (!draftConfig) return false;
// Role menu validation: all options must have non-empty label and roleId
const roleMenuOptions = draftConfig.welcome?.roleMenu?.options ?? [];
const hasRoleMenuErrors = roleMenuOptions.some(
(opt) => !opt.label?.trim() || !opt.roleId?.trim(),
);
if (hasRoleMenuErrors) return true;
const promptLength = draftConfig.ai?.systemPrompt?.length ?? 0;
return promptLength > SYSTEM_PROMPT_MAX_LENGTH;
}, [draftConfig]);
Expand Down Expand Up @@ -371,7 +366,6 @@ export function ConfigEditor() {
const discardChanges = useCallback(() => {
if (!savedConfig) return;
setDraftConfig(structuredClone(savedConfig));
setRoleMenuRaw(stringifyRoleMenuOptions(savedConfig.welcome?.roleMenu?.options ?? []));
setDmStepsRaw((savedConfig.welcome?.dmSequence?.steps ?? []).join('\n'));
toast.success('Changes discarded.');
}, [savedConfig]);
Expand Down Expand Up @@ -790,21 +784,80 @@ export function ConfigEditor() {
label="Role Menu"
/>
</div>
<textarea
value={roleMenuRaw}
onChange={(e) => setRoleMenuRaw(e.target.value)}
onBlur={() => {
const parsed = parseRoleMenuOptions(roleMenuRaw);
updateWelcomeRoleMenu('options', parsed);
setRoleMenuRaw(stringifyRoleMenuOptions(parsed));
}}
rows={5}
disabled={saving}
className={inputClasses}
placeholder={
'Format: Label|RoleID|Description (optional)\nOne option per line (max 25).'
}
/>
<div className="space-y-3">
{(draftConfig.welcome?.roleMenu?.options ?? []).map((opt, i) => (
<div key={opt.id} className="flex flex-col gap-2 rounded-md border p-2">
<div className="flex items-center gap-2">
<input
type="text"
value={opt.label ?? ''}
onChange={(e) => {
const opts = [...(draftConfig.welcome?.roleMenu?.options ?? [])];
opts[i] = { ...opts[i], label: e.target.value };
updateWelcomeRoleMenu('options', opts);
}}
disabled={saving}
className={`${inputClasses} flex-1`}
placeholder="Label (shown in menu)"
/>
<Button
variant="ghost"
size="sm"
onClick={() => {
const opts = [...(draftConfig.welcome?.roleMenu?.options ?? [])].filter(
(o) => o.id !== opt.id,
);
updateWelcomeRoleMenu('options', opts);
}}
disabled={
saving || (draftConfig.welcome?.roleMenu?.options ?? []).length <= 1
}
aria-label={`Remove role option ${opt.label || i + 1}`}
>
</Button>
</div>
<RoleSelector
guildId={guildId}
selected={opt.roleId ? [opt.roleId] : []}
onChange={(selected) => {
const opts = [...(draftConfig.welcome?.roleMenu?.options ?? [])];
opts[i] = { ...opts[i], roleId: selected[0] ?? '' };
updateWelcomeRoleMenu('options', opts);
}}
placeholder="Select role"
disabled={saving}
maxSelections={1}
/>
<input
type="text"
value={opt.description ?? ''}
onChange={(e) => {
const opts = [...(draftConfig.welcome?.roleMenu?.options ?? [])];
opts[i] = { ...opts[i], description: e.target.value || undefined };
updateWelcomeRoleMenu('options', opts);
}}
disabled={saving}
className={inputClasses}
placeholder="Description (optional)"
/>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={() => {
const opts = [
...(draftConfig.welcome?.roleMenu?.options ?? []),
{ id: crypto.randomUUID(), label: '', roleId: '' },
];
updateWelcomeRoleMenu('options', opts);
}}
disabled={saving || (draftConfig.welcome?.roleMenu?.options ?? []).length >= 25}
>
+ Add Role Option
</Button>
</div>
</fieldset>

<fieldset className="space-y-2 rounded-md border p-3">
Expand Down Expand Up @@ -1336,26 +1389,32 @@ export function ConfigEditor() {
<CardContent className="space-y-4">
<label className="space-y-2">
<span className="text-sm font-medium">Admin Role ID</span>
<input
type="text"
value={draftConfig.permissions?.adminRoleId ?? ''}
onChange={(e) => updatePermissionsField('adminRoleId', e.target.value.trim() || null)}
<RoleSelector
guildId={guildId}
selected={
draftConfig.permissions?.adminRoleId ? [draftConfig.permissions.adminRoleId] : []
}
onChange={(selected) => updatePermissionsField('adminRoleId', selected[0] ?? null)}
placeholder="Select admin role"
disabled={saving}
className={inputClasses}
placeholder="Discord role ID for admins"
maxSelections={1}
/>
</label>
<label className="space-y-2">
<span className="text-sm font-medium">Moderator Role ID</span>
<input
type="text"
value={draftConfig.permissions?.moderatorRoleId ?? ''}
onChange={(e) =>
updatePermissionsField('moderatorRoleId', e.target.value.trim() || null)
<RoleSelector
guildId={guildId}
selected={
draftConfig.permissions?.moderatorRoleId
? [draftConfig.permissions.moderatorRoleId]
: []
}
onChange={(selected) =>
updatePermissionsField('moderatorRoleId', selected[0] ?? null)
}
placeholder="Select moderator role"
disabled={saving}
className={inputClasses}
placeholder="Discord role ID for moderators"
maxSelections={1}
/>
</label>
<label className="space-y-2">
Expand Down
47 changes: 37 additions & 10 deletions web/src/components/dashboard/config-sections/ModerationSection.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
'use client';

import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { ChannelSelector } from '@/components/ui/channel-selector';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { useGuildSelection } from '@/hooks/use-guild-selection';
import type { GuildConfig } from '@/lib/config-utils';

interface ModerationSectionProps {
Expand All @@ -15,6 +16,18 @@ interface ModerationSectionProps {
onEscalationChange: (enabled: boolean) => void;
}

/**
* Render the Moderation settings section, including alert channel selection, auto-delete,
* DM notification toggles, and escalation controls.
*
* @param draftConfig - The current draft guild configuration containing moderation settings.
* @param saving - Whether a save operation is in progress; when true, interactive controls are disabled.
* @param onEnabledChange - Callback invoked with the new enabled state when moderation is toggled.
* @param onFieldChange - Generic field update callback, called with field name and new value (e.g., 'alertChannelId', 'autoDelete').
* @param onDmNotificationChange - Callback invoked with an action ('warn' | 'timeout' | 'kick' | 'ban') and boolean to toggle DM notifications for that action.
* @param onEscalationChange - Callback invoked with the new escalation enabled state.
* @returns The rendered moderation Card element, or `null` if `draftConfig.moderation` is not present.
*/
export function ModerationSection({
draftConfig,
saving,
Expand All @@ -23,8 +36,17 @@ export function ModerationSection({
onDmNotificationChange,
onEscalationChange,
}: ModerationSectionProps) {
const guildId = useGuildSelection();

if (!draftConfig.moderation) return null;

const alertChannelId = draftConfig.moderation?.alertChannelId ?? '';
const selectedChannels = alertChannelId ? [alertChannelId] : [];

const handleChannelChange = (channels: string[]) => {
onFieldChange('alertChannelId', channels[0] ?? '');
};

return (
<Card>
<CardHeader>
Expand All @@ -45,15 +67,20 @@ export function ModerationSection({
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="alert-channel">Alert Channel ID</Label>
<Input
id="alert-channel"
type="text"
value={draftConfig.moderation?.alertChannelId ?? ''}
onChange={(e) => onFieldChange('alertChannelId', e.target.value)}
disabled={saving}
placeholder="Channel ID for moderation alerts"
/>
<Label>Alert Channel</Label>
{guildId ? (
<ChannelSelector
guildId={guildId}
selected={selectedChannels}
onChange={handleChannelChange}
placeholder="Select alert channel..."
disabled={saving}
maxSelections={1}
filter="text"
/>
) : (
<p className="text-muted-foreground text-sm">Select a server first</p>
)}
</div>
<div className="flex items-center justify-between">
<Label htmlFor="auto-delete" className="text-sm font-medium">
Expand Down
Loading
Loading