Skip to content
Merged
Show file tree
Hide file tree
Changes from 35 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
4 changes: 1 addition & 3 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,7 @@
"recharts": "^3.7.0",
"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"
"tailwind-merge": "^3.5.0"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed next-themes but it's still imported in providers.tsx, theme-provider.tsx, and theme-toggle.tsx - this will cause build failure

Suggested change
"tailwind-merge": "^3.5.0"
"tailwind-merge": "^3.5.0",
"next-themes": "^0.4.6",
"react-hook-form": "^7.56.4"
Prompt To Fix With AI
This is a comment left during a code review.
Path: web/package.json
Line: 36

Comment:
Removed `next-themes` but it's still imported in `providers.tsx`, `theme-provider.tsx`, and `theme-toggle.tsx` - this will cause build failure

```suggestion
    "tailwind-merge": "^3.5.0",
    "next-themes": "^0.4.6",
    "react-hook-form": "^7.56.4"
```

How can I resolve this? If you propose a fix, please make it concise.

},
"devDependencies": {
"@tailwindcss/postcss": "^4.2.1",
Expand Down
325 changes: 222 additions & 103 deletions web/src/components/dashboard/config-editor.tsx

Large diffs are not rendered by default.

48 changes: 38 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,21 @@ 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 htmlFor="alert-channel">Alert Channel</Label>
{guildId ? (
<ChannelSelector
id="alert-channel"
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
46 changes: 37 additions & 9 deletions web/src/components/dashboard/config-sections/TriageSection.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
'use client';

import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { ChannelSelector } from '@/components/ui/channel-selector';
import { Input } from '@/components/ui/input';
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';
import { NumberField } from './NumberField';

Expand All @@ -14,14 +16,34 @@ interface TriageSectionProps {
onFieldChange: (field: string, value: unknown) => void;
}

/**
* Renders the Triage configuration UI for editing classifier, responder, budget, timing, toggles, and moderation log channel.
*
* Renders nothing if `draftConfig.triage` is not present.
*
* @param draftConfig - Guild configuration draft containing the `triage` settings to display and edit.
* @param saving - When true, input controls are disabled to prevent changes during a save operation.
* @param onEnabledChange - Invoked with the new enabled state when the Triage master switch is toggled.
* @param onFieldChange - Invoked with `(field, value)` for individual field updates; used for all editable triage fields including `moderationLogChannel`.
* @returns The Triage configuration card element, or `null` when triage configuration is absent.
*/
export function TriageSection({
draftConfig,
saving,
onEnabledChange,
onFieldChange,
}: TriageSectionProps) {
const guildId = useGuildSelection();

if (!draftConfig.triage) return null;

const moderationLogChannel = draftConfig.triage?.moderationLogChannel ?? '';
const selectedChannels = moderationLogChannel ? [moderationLogChannel] : [];

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

return (
<Card>
<CardHeader>
Expand Down Expand Up @@ -150,15 +172,21 @@ export function TriageSection({
/>
</div>
<div className="space-y-2">
<Label htmlFor="mod-log-channel">Moderation Log Channel</Label>
<Input
id="mod-log-channel"
type="text"
value={draftConfig.triage?.moderationLogChannel ?? ''}
onChange={(e) => onFieldChange('moderationLogChannel', e.target.value)}
disabled={saving}
placeholder="Channel ID for moderation logs"
/>
<Label htmlFor="moderation-log-channel">Moderation Log Channel</Label>
{guildId ? (
<ChannelSelector
id="moderation-log-channel"
guildId={guildId}
selected={selectedChannels}
onChange={handleChannelChange}
placeholder="Select moderation log channel..."
disabled={saving}
maxSelections={1}
filter="text"
/>
) : (
<p className="text-muted-foreground text-sm">Select a server first</p>
)}
</div>
</CardContent>
</Card>
Expand Down
7 changes: 7 additions & 0 deletions web/src/components/layout/header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@ import {
import { Skeleton } from '@/components/ui/skeleton';
import { MobileSidebar } from './mobile-sidebar';

/**
* Renders the top navigation header for the Bill Bot Dashboard, including branding, a theme toggle, and a session-aware user menu.
*
* If the session reports a `RefreshTokenError`, initiates sign-out and redirects to `/login`; a guard prevents duplicate sign-out attempts.
*
* @returns The header element for the dashboard
*/
export function Header() {
const { data: session, status } = useSession();
const signingOut = useRef(false);
Expand Down
5 changes: 5 additions & 0 deletions web/src/components/providers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ import type { ReactNode } from 'react';
import { Toaster } from 'sonner';
import { ThemeProvider } from '@/components/theme-provider';

/**
* Render a global Toaster whose visual theme follows the resolved app theme.
*
* @returns A React element mounting a Toaster at the bottom-right with its `theme` set to the resolved theme (`'light'` or `'dark'`, falling back to `'system'` if unresolved) and `richColors` enabled.
*/
function ThemedToaster() {
const { resolvedTheme } = useTheme();
return (
Expand Down
20 changes: 20 additions & 0 deletions web/src/components/ui/channel-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ interface ChannelSelectorProps {
className?: string;
maxSelections?: number;
filter?: ChannelTypeFilter;
id?: string;
}

function getChannelIcon(type: number) {
Expand Down Expand Up @@ -147,6 +148,23 @@ function filterChannelsByType(
});
}

/**
* Renders a searchable popover UI for selecting Discord channels from a guild.
*
* Displays a button that opens a searchable list of channels fetched from the provided guild.
* Shows selected channels as removable badges, includes handling for unknown/removed channel IDs,
* and respects an optional maximum selection limit and channel-type filter.
*
* @param guildId - ID of the guild whose channels will be fetched and listed
* @param selected - Array of currently selected channel IDs
* @param onChange - Callback invoked with the updated array of selected channel IDs
* @param placeholder - Text shown when no channels are selected
* @param disabled - When true, disables interaction with the selector and remove buttons
* @param className - Additional class names applied to the root container
* @param maxSelections - Optional maximum number of channels that can be selected
* @param filter - Optional channel-type filter to limit which channels are shown
* @returns A JSX element that renders the channel selector UI
*/
export function ChannelSelector({
guildId,
selected,
Expand All @@ -156,6 +174,7 @@ export function ChannelSelector({
className,
maxSelections,
filter = 'all',
id,
}: ChannelSelectorProps) {
const [open, setOpen] = React.useState(false);
const [channels, setChannels] = React.useState<DiscordChannel[]>([]);
Expand Down Expand Up @@ -279,6 +298,7 @@ export function ChannelSelector({
aria-expanded={open}
disabled={disabled || loading}
className="w-full justify-between"
id={id}
>
<span className="truncate">
{selected.length > 0
Expand Down
7 changes: 7 additions & 0 deletions web/src/components/ui/command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ import {
} from '@/components/ui/dialog';
import { cn } from '@/lib/utils';

/**
* Wraps CommandPrimitive as the command-palette root, injecting a data-slot and base styling while forwarding all props.
*
* @param className - Additional CSS classes to merge with the component's base styling
* @param props - Remaining props forwarded to the underlying CommandPrimitive
* @returns The underlying CommandPrimitive element configured as the command palette root
*/
function Command({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
Expand Down
30 changes: 30 additions & 0 deletions web/src/components/ui/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,42 @@ import type * as React from 'react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';

/**
* Render a Dialog root element, forwarding received props to the underlying Radix Dialog Root.
*
* @param props - Props passed to the Dialog root component.
* @returns A React element that renders the dialog root.
*/
function Dialog({ ...props }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root {...props} />;
}

/**
* Renders a Radix Dialog Trigger element that forwards all received props and sets a `data-slot="dialog-trigger"`.
*
* @param props - Props forwarded to the underlying Radix Dialog Trigger
* @returns The Dialog trigger element with forwarded props and the `data-slot="dialog-trigger"` attribute
*/
function DialogTrigger({ ...props }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}

/**
* Renders a Dialog Portal element and forwards all received props to the underlying portal.
*
* @param props - Props forwarded to Radix Dialog Portal primitive
* @returns A React element representing the dialog portal.
*/
function DialogPortal({ ...props }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal {...props} />;
}

/**
* Render a dialog close control using Radix UI's Close primitive.
*
* @param props - Props forwarded to Radix Dialog Close primitive
* @returns A React element for closing the dialog with `data-slot="dialog-close"`
*/
function DialogClose({ ...props }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
Expand All @@ -38,6 +62,12 @@ function DialogOverlay({
);
}

/**
* Renders dialog content inside a portal with its overlay and an optional close control.
*
* @param showCloseButton - When `true` (default), renders a close button in the top-right corner of the dialog.
* @returns The dialog content element with overlay and optional close button.
*/
function DialogContent({
className,
children,
Expand Down
16 changes: 15 additions & 1 deletion web/src/components/ui/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@ function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
);
}

/**
* Renders a form field label that is linked to the field control and indicates validation state.
*
* @param props - Props forwarded to the underlying Label component; `className` will be merged with
* an error-aware style and `htmlFor` is set to the field's control id.
* @returns The label element for the current form field with `data-error` and `htmlFor` applied.
*/
function FormLabel({ className, ...props }: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField();

Expand All @@ -99,12 +106,19 @@ function FormLabel({ className, ...props }: React.ComponentProps<typeof LabelPri
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField();

// Merge caller-provided aria-describedby with form field IDs
const callerDescribedBy = props['aria-describedby'];
const fieldDescribedBy = !error ? formDescriptionId : `${formDescriptionId} ${formMessageId}`;
const ariaDescribedBy = callerDescribedBy
? `${fieldDescribedBy} ${callerDescribedBy}`
: fieldDescribedBy;

return (
<Slot
{...props}
data-slot="form-control"
id={formItemId}
aria-describedby={!error ? `${formDescriptionId}` : `${formDescriptionId} ${formMessageId}`}
aria-describedby={ariaDescribedBy}
aria-invalid={!!error}
/>
);
Expand Down
15 changes: 15 additions & 0 deletions web/src/components/ui/role-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export interface DiscordRole {
interface RoleSelectorProps {
guildId: string;
selected: string[];
id?: string;
onChange: (selected: string[]) => void;
placeholder?: string;
disabled?: boolean;
Expand All @@ -37,6 +38,18 @@ function discordColorToHex(color: number): string | null {
return `#${color.toString(16).padStart(6, '0')}`;
}

/**
* Render a role selection UI for a guild, allowing users to search, select, and remove roles.
*
* @param guildId - The guild ID used to fetch available roles; when not provided no fetch is performed.
* @param selected - Array of selected role IDs.
* @param onChange - Callback invoked with the updated array of selected role IDs whenever the selection changes.
* @param placeholder - Text shown in the trigger when no roles are selected.
* @param disabled - When true, disables user interaction with the selector.
* @param className - Optional additional CSS class names applied to the outer container.
* @param maxSelections - Optional maximum number of roles that may be selected; further selections are prevented when reached.
* @returns A React element that displays the role picker, selected role badges, and selection controls.
*/
export function RoleSelector({
guildId,
selected,
Expand All @@ -45,6 +58,7 @@ export function RoleSelector({
disabled = false,
className,
maxSelections,
id,
}: RoleSelectorProps) {
const [open, setOpen] = React.useState(false);
const [roles, setRoles] = React.useState<DiscordRole[]>([]);
Expand Down Expand Up @@ -155,6 +169,7 @@ export function RoleSelector({
aria-expanded={open}
disabled={disabled || loading}
className="w-full justify-between"
id={id}
>
<span className="truncate">
{selected.length > 0
Expand Down
1 change: 1 addition & 0 deletions web/src/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export interface WelcomeDynamic {

/** Self-assignable role menu option. */
export interface WelcomeRoleOption {
id?: string;
label: string;
roleId: string;
description?: string;
Expand Down