From 1d8d5297f624df67b5be9bd48e71c49cf1c3aaf4 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 1 Mar 2026 00:06:10 -0500 Subject: [PATCH 01/26] feat: shadcn/ui theme setup with dark mode toggle --- pnpm-lock.yaml | 35 ++++ web/package.json | 4 +- web/src/app/layout.tsx | 2 +- web/src/components/layout/header.tsx | 2 + web/src/components/providers.tsx | 18 ++- web/src/components/theme-provider.tsx | 14 ++ web/src/components/theme-toggle.tsx | 60 +++++++ web/src/components/ui/command.tsx | 184 +++++++++++++++++++++ web/src/components/ui/dialog.tsx | 222 ++++++++++++++++---------- web/src/components/ui/popover.tsx | 89 +++++++++++ 10 files changed, 542 insertions(+), 88 deletions(-) create mode 100644 web/src/components/theme-provider.tsx create mode 100644 web/src/components/theme-toggle.tsx create mode 100644 web/src/components/ui/command.tsx create mode 100644 web/src/components/ui/popover.tsx diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4a5d99412..84b9e8697 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -97,6 +97,9 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + cmdk: + specifier: ^1.1.1 + version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) diff: specifier: ^8.0.3 version: 8.0.3 @@ -109,6 +112,9 @@ importers: next-auth: specifier: ^4.24.13 version: 4.24.13(next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next-themes: + specifier: ^0.4.6 + version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -2775,6 +2781,12 @@ packages: resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} engines: {node: '>=0.10.0'} + cmdk@1.1.1: + resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} + peerDependencies: + react: ^18 || ^19 || ^19.0.0-rc + react-dom: ^18 || ^19 || ^19.0.0-rc + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -3915,6 +3927,12 @@ packages: nodemailer: optional: true + next-themes@0.4.6: + resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} + peerDependencies: + react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc + next@16.1.6: resolution: {integrity: sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==} engines: {node: '>=20.9.0'} @@ -7662,6 +7680,18 @@ snapshots: cluster-key-slot@1.1.2: {} + cmdk@1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@radix-ui/react-id': 1.1.1(@types/react@19.2.14)(react@19.2.4) + '@radix-ui/react-primitive': 2.1.4(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -8852,6 +8882,11 @@ snapshots: react-dom: 19.2.4(react@19.2.4) uuid: 8.3.2 + next-themes@0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + next@16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@next/env': 16.1.6 diff --git a/web/package.json b/web/package.json index 1e9c3914a..3ab10c492 100644 --- a/web/package.json +++ b/web/package.json @@ -18,13 +18,15 @@ "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", - "radix-ui": "^1.4.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "cmdk": "^1.1.1", "diff": "^8.0.3", "lucide-react": "^0.525.0", "next": "^16.1.6", "next-auth": "^4.24.13", + "next-themes": "^0.4.6", + "radix-ui": "^1.4.3", "react": "^19.2.4", "react-dom": "^19.2.4", "recharts": "^3.5.0", diff --git a/web/src/app/layout.tsx b/web/src/app/layout.tsx index 349e48ef1..389047e00 100644 --- a/web/src/app/layout.tsx +++ b/web/src/app/layout.tsx @@ -13,7 +13,7 @@ export const metadata: Metadata = { export default function RootLayout({ children }: { children: React.ReactNode }) { return ( - + {children} diff --git a/web/src/components/layout/header.tsx b/web/src/components/layout/header.tsx index f0f14939d..75f0a3ebc 100644 --- a/web/src/components/layout/header.tsx +++ b/web/src/components/layout/header.tsx @@ -16,6 +16,7 @@ import { } from '@/components/ui/dropdown-menu'; import { Skeleton } from '@/components/ui/skeleton'; import { MobileSidebar } from './mobile-sidebar'; +import { ThemeToggle } from '@/components/theme-toggle'; export function Header() { const { data: session, status } = useSession(); @@ -46,6 +47,7 @@ export function Header() {
+ {status === 'loading' && ( )} diff --git a/web/src/components/providers.tsx b/web/src/components/providers.tsx index 2f14aac84..2ea51d46c 100644 --- a/web/src/components/providers.tsx +++ b/web/src/components/providers.tsx @@ -3,21 +3,31 @@ import { SessionProvider } from 'next-auth/react'; import type { ReactNode } from 'react'; import { Toaster } from 'sonner'; +import { ThemeProvider } from '@/components/theme-provider'; /** - * Wraps application UI with NextAuth session context and a global toast container. + * Wraps application UI with NextAuth session context, theme provider, and a global toast container. * * Session error handling (e.g. RefreshTokenError) is handled elsewhere (the Header component), * which signs out and redirects to /login. * - * @returns A React element that renders a SessionProvider around `children` and mounts a Toaster + * Theme defaults to system preference with CSS variable-based dark/light mode support. + * + * @returns A React element that renders providers around `children` and mounts a Toaster * positioned at the bottom-right with system theme and rich colors enabled. */ export function Providers({ children }: { children: ReactNode }) { return ( - {children} - + + {children} + + ); } diff --git a/web/src/components/theme-provider.tsx b/web/src/components/theme-provider.tsx new file mode 100644 index 000000000..103c7a87f --- /dev/null +++ b/web/src/components/theme-provider.tsx @@ -0,0 +1,14 @@ +'use client'; + +import { ThemeProvider as NextThemesProvider } from 'next-themes'; +import type { ThemeProviderProps } from 'next-themes'; + +/** + * Theme provider wrapper for next-themes. + * + * Provides system-aware dark/light mode support with CSS variable theming. + * Defaults to system preference on first load. + */ +export function ThemeProvider({ children, ...props }: ThemeProviderProps) { + return {children}; +} diff --git a/web/src/components/theme-toggle.tsx b/web/src/components/theme-toggle.tsx new file mode 100644 index 000000000..768ce25b8 --- /dev/null +++ b/web/src/components/theme-toggle.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { Moon, Sun } from 'lucide-react'; +import { useTheme } from 'next-themes'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { useEffect, useState } from 'react'; + +/** + * Theme toggle dropdown component. + * + * Displays a button with sun/moon icons that toggles between + * light, dark, and system themes. Uses next-themes for state management. + */ +export function ThemeToggle() { + const { setTheme, theme } = useTheme(); + const [mounted, setMounted] = useState(false); + + // Prevent hydration mismatch by only rendering after mount + useEffect(() => { + setMounted(true); + }, []); + + if (!mounted) { + return ( + + ); + } + + return ( + + + + + + setTheme('light')}> + Light + + setTheme('dark')}> + Dark + + setTheme('system')}> + System + + + + ); +} diff --git a/web/src/components/ui/command.tsx b/web/src/components/ui/command.tsx new file mode 100644 index 000000000..8cb4ca7a5 --- /dev/null +++ b/web/src/components/ui/command.tsx @@ -0,0 +1,184 @@ +"use client" + +import * as React from "react" +import { Command as CommandPrimitive } from "cmdk" +import { SearchIcon } from "lucide-react" + +import { cn } from "@/lib/utils" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" + +function Command({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandDialog({ + title = "Command Palette", + description = "Search for a command to run...", + children, + className, + showCloseButton = true, + ...props +}: React.ComponentProps & { + title?: string + description?: string + className?: string + showCloseButton?: boolean +}) { + return ( + + + {title} + {description} + + + + {children} + + + + ) +} + +function CommandInput({ + className, + ...props +}: React.ComponentProps) { + return ( +
+ + +
+ ) +} + +function CommandList({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandEmpty({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function CommandShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ) +} + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +} diff --git a/web/src/components/ui/dialog.tsx b/web/src/components/ui/dialog.tsx index 624c9a1d7..80d7ad604 100644 --- a/web/src/components/ui/dialog.tsx +++ b/web/src/components/ui/dialog.tsx @@ -1,100 +1,158 @@ -'use client'; +"use client" -import * as DialogPrimitive from '@radix-ui/react-dialog'; -import { X } from 'lucide-react'; -import * as React from 'react'; -import { cn } from '@/lib/utils'; +import * as React from "react" +import { XIcon } from "lucide-react" +import { Dialog as DialogPrimitive } from "radix-ui" -const Dialog = DialogPrimitive.Root; -const DialogTrigger = DialogPrimitive.Trigger; -const DialogPortal = DialogPrimitive.Portal; -const DialogClose = DialogPrimitive.Close; +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" -const DialogOverlay = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; +function Dialog({ + ...props +}: React.ComponentProps) { + return +} -const DialogContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( - - - ) { + return +} + +function DialogPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DialogClose({ + ...props +}: React.ComponentProps) { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + - {children} - - - Close - - - -)); -DialogContent.displayName = DialogPrimitive.Content.displayName; + /> + ) +} -const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( -
-); -DialogHeader.displayName = 'DialogHeader'; +function DialogContent({ + className, + children, + showCloseButton = true, + ...props +}: React.ComponentProps & { + showCloseButton?: boolean +}) { + return ( + + + + {children} + {showCloseButton && ( + + + Close + + )} + + + ) +} -const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => ( -
-); -DialogFooter.displayName = 'DialogFooter'; +function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} -const DialogTitle = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -DialogTitle.displayName = DialogPrimitive.Title.displayName; +function DialogFooter({ + className, + showCloseButton = false, + children, + ...props +}: React.ComponentProps<"div"> & { + showCloseButton?: boolean +}) { + return ( +
+ {children} + {showCloseButton && ( + + + + )} +
+ ) +} -const DialogDescription = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)); -DialogDescription.displayName = DialogPrimitive.Description.displayName; +function DialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} export { Dialog, - DialogPortal, - DialogOverlay, - DialogTrigger, DialogClose, DialogContent, - DialogHeader, + DialogDescription, DialogFooter, + DialogHeader, + DialogOverlay, + DialogPortal, DialogTitle, - DialogDescription, -}; + DialogTrigger, +} diff --git a/web/src/components/ui/popover.tsx b/web/src/components/ui/popover.tsx new file mode 100644 index 000000000..9519cc488 --- /dev/null +++ b/web/src/components/ui/popover.tsx @@ -0,0 +1,89 @@ +"use client" + +import * as React from "react" +import { Popover as PopoverPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" + +function Popover({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverContent({ + className, + align = "center", + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function PopoverAnchor({ + ...props +}: React.ComponentProps) { + return +} + +function PopoverHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function PopoverTitle({ className, ...props }: React.ComponentProps<"h2">) { + return ( +
+ ) +} + +function PopoverDescription({ + className, + ...props +}: React.ComponentProps<"p">) { + return ( +

+ ) +} + +export { + Popover, + PopoverTrigger, + PopoverContent, + PopoverAnchor, + PopoverHeader, + PopoverTitle, + PopoverDescription, +} From eec474f63d98d975e0f30e0625084f7b9d333090 Mon Sep 17 00:00:00 2001 From: Pip Build Date: Sun, 1 Mar 2026 00:07:04 -0500 Subject: [PATCH 02/26] feat: migrate dashboard to shadcn components (Switch, Input, Label, Form) --- .../dashboard/config-sections/AiSection.tsx | 20 ++- .../config-sections/ModerationSection.tsx | 56 +++--- .../dashboard/config-sections/NumberField.tsx | 15 +- .../config-sections/TriageSection.tsx | 76 ++++---- .../config-sections/WelcomeSection.tsx | 23 ++- web/src/components/ui/checkbox.tsx | 32 ++++ web/src/components/ui/form.tsx | 167 ++++++++++++++++++ web/src/components/ui/switch.tsx | 35 ++++ 8 files changed, 340 insertions(+), 84 deletions(-) create mode 100644 web/src/components/ui/checkbox.tsx create mode 100644 web/src/components/ui/form.tsx create mode 100644 web/src/components/ui/switch.tsx diff --git a/web/src/components/dashboard/config-sections/AiSection.tsx b/web/src/components/dashboard/config-sections/AiSection.tsx index e8fce6b68..6692f7cfc 100644 --- a/web/src/components/dashboard/config-sections/AiSection.tsx +++ b/web/src/components/dashboard/config-sections/AiSection.tsx @@ -1,8 +1,9 @@ 'use client'; import { SystemPromptEditor } from '@/components/dashboard/system-prompt-editor'; -import { ToggleSwitch } from '@/components/dashboard/toggle-switch'; import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; import type { GuildConfig } from '@/lib/config-utils'; import { SYSTEM_PROMPT_MAX_LENGTH } from '@/types/config'; @@ -28,12 +29,17 @@ export function AiSection({ AI Chat Configure the AI assistant behavior.

- +
+ + +
diff --git a/web/src/components/dashboard/config-sections/ModerationSection.tsx b/web/src/components/dashboard/config-sections/ModerationSection.tsx index 3f4d804b8..58abc3482 100644 --- a/web/src/components/dashboard/config-sections/ModerationSection.tsx +++ b/web/src/components/dashboard/config-sections/ModerationSection.tsx @@ -1,12 +1,11 @@ 'use client'; -import { ToggleSwitch } from '@/components/dashboard/toggle-switch'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; import type { GuildConfig } from '@/lib/config-utils'; -const inputClasses = - 'w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50'; - interface ModerationSectionProps { draftConfig: GuildConfig; saving: boolean; @@ -36,56 +35,65 @@ export function ModerationSection({ Configure moderation, escalation, and logging settings.
-
- +
- Auto-delete flagged messages - + Auto-delete flagged messages + + onFieldChange('autoDelete', v)} + onCheckedChange={(v) => onFieldChange('autoDelete', v)} disabled={saving} - label="Auto Delete" + aria-label="Toggle auto-delete" />
DM Notifications {(['warn', 'timeout', 'kick', 'ban'] as const).map((action) => (
- {action} - + {action} + + onDmNotificationChange(action, v)} + onCheckedChange={(v) => onDmNotificationChange(action, v)} disabled={saving} - label={`DM on ${action}`} + aria-label={`DM on ${action}`} />
))}
- Escalation Enabled - + Escalation Enabled + + onEscalationChange(v)} + onCheckedChange={(v) => onEscalationChange(v)} disabled={saving} - label="Escalation" + aria-label="Toggle escalation" />
diff --git a/web/src/components/dashboard/config-sections/NumberField.tsx b/web/src/components/dashboard/config-sections/NumberField.tsx index a013320b4..84ba08598 100644 --- a/web/src/components/dashboard/config-sections/NumberField.tsx +++ b/web/src/components/dashboard/config-sections/NumberField.tsx @@ -1,5 +1,7 @@ -const inputClasses = - 'w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50'; +'use client'; + +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; interface NumberFieldProps { label: string; @@ -12,9 +14,9 @@ interface NumberFieldProps { export function NumberField({ label, value, onChange, disabled, min, step }: NumberFieldProps) { return ( - +
); } diff --git a/web/src/components/dashboard/config-sections/TriageSection.tsx b/web/src/components/dashboard/config-sections/TriageSection.tsx index 393510c43..bc91eebd0 100644 --- a/web/src/components/dashboard/config-sections/TriageSection.tsx +++ b/web/src/components/dashboard/config-sections/TriageSection.tsx @@ -1,13 +1,12 @@ 'use client'; -import { ToggleSwitch } from '@/components/dashboard/toggle-switch'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; import type { GuildConfig } from '@/lib/config-utils'; import { NumberField } from './NumberField'; -const inputClasses = - 'w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50'; - interface TriageSectionProps { draftConfig: GuildConfig; saving: boolean; @@ -33,37 +32,37 @@ export function TriageSection({ Configure message triage classifier, responder models, and channels. - - - ); diff --git a/web/src/components/dashboard/config-sections/WelcomeSection.tsx b/web/src/components/dashboard/config-sections/WelcomeSection.tsx index a908b58f4..349ae39a8 100644 --- a/web/src/components/dashboard/config-sections/WelcomeSection.tsx +++ b/web/src/components/dashboard/config-sections/WelcomeSection.tsx @@ -1,12 +1,11 @@ 'use client'; -import { ToggleSwitch } from '@/components/dashboard/toggle-switch'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { Textarea } from '@/components/ui/textarea'; import type { GuildConfig } from '@/lib/config-utils'; -const inputClasses = - 'w-full rounded-md border bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50'; - interface WelcomeSectionProps { draftConfig: GuildConfig; saving: boolean; @@ -28,27 +27,27 @@ export function WelcomeSection({ Welcome Messages Greet new members when they join the server. - -