diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fdaba957d..cee4d8562 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -127,6 +127,9 @@ importers: react-dom: specifier: ^19.2.4 version: 19.2.4(react@19.2.4) + react-hook-form: + specifier: ^7.56.4 + version: 7.71.2(react@19.2.4) recharts: specifier: ^3.7.0 version: 3.7.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@18.3.1)(react@19.2.4)(redux@5.0.1) @@ -4378,6 +4381,12 @@ packages: peerDependencies: react: ^19.2.4 + react-hook-form@7.71.2: + resolution: {integrity: sha512-1CHvcDYzuRUNOflt4MOq3ZM46AronNJtQ1S7tnX6YN4y72qhgiUItpacZUAQ0TyWYci3yz1X+rXaSxiuEm86PA==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} @@ -5763,7 +5772,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 25.3.3 + '@types/node': 22.19.13 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -7418,7 +7427,7 @@ snapshots: '@types/pg@8.11.0': dependencies: - '@types/node': 25.3.3 + '@types/node': 22.19.13 pg-protocol: 1.12.0 pg-types: 4.1.0 @@ -7442,7 +7451,7 @@ snapshots: '@types/sqlite3@3.1.11': dependencies: - '@types/node': 25.3.3 + '@types/node': 22.19.13 '@types/stack-utils@2.0.3': {} @@ -8631,7 +8640,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 25.3.3 + '@types/node': 22.19.13 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -9403,7 +9412,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 25.3.3 + '@types/node': 22.19.13 long: 5.3.2 proxy-addr@2.0.7: @@ -9508,6 +9517,10 @@ snapshots: react: 19.2.4 scheduler: 0.27.0 + react-hook-form@7.71.2(react@19.2.4): + dependencies: + react: 19.2.4 + react-is@17.0.2: {} react-is@18.3.1: {} diff --git a/web/package.json b/web/package.json index de463cb4f..7b9c3db6b 100644 --- a/web/package.json +++ b/web/package.json @@ -31,7 +31,9 @@ "recharts": "^3.7.0", "server-only": "^0.0.1", "sonner": "^2.0.7", - "tailwind-merge": "^3.5.0" + "tailwind-merge": "^3.5.0", + "next-themes": "^0.4.6", + "react-hook-form": "^7.56.4" }, "devDependencies": { "@tailwindcss/postcss": "^4.2.1", diff --git a/web/src/components/layout/header.tsx b/web/src/components/layout/header.tsx index 75f0a3ebc..297128798 100644 --- a/web/src/components/layout/header.tsx +++ b/web/src/components/layout/header.tsx @@ -4,6 +4,7 @@ import { ExternalLink, LogOut } from 'lucide-react'; import Link from 'next/link'; import { signOut, useSession } from 'next-auth/react'; import { useEffect, useRef } from 'react'; +import { ThemeToggle } from '@/components/theme-toggle'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Button } from '@/components/ui/button'; import { @@ -16,7 +17,6 @@ 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(); diff --git a/web/src/components/providers.tsx b/web/src/components/providers.tsx index 2ea51d46c..9551d3898 100644 --- a/web/src/components/providers.tsx +++ b/web/src/components/providers.tsx @@ -1,10 +1,22 @@ 'use client'; import { SessionProvider } from 'next-auth/react'; +import { useTheme } from 'next-themes'; import type { ReactNode } from 'react'; import { Toaster } from 'sonner'; import { ThemeProvider } from '@/components/theme-provider'; +function ThemedToaster() { + const { resolvedTheme } = useTheme(); + return ( + + ); +} + /** * Wraps application UI with NextAuth session context, theme provider, and a global toast container. * @@ -14,7 +26,7 @@ import { ThemeProvider } from '@/components/theme-provider'; * 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. + * positioned at the bottom-right with resolved theme (light/dark) and rich colors enabled. */ export function Providers({ children }: { children: ReactNode }) { return ( @@ -26,7 +38,7 @@ export function Providers({ children }: { children: ReactNode }) { disableTransitionOnChange={false} > {children} - + ); diff --git a/web/src/components/theme-provider.tsx b/web/src/components/theme-provider.tsx index 103c7a87f..4c4f92aa1 100644 --- a/web/src/components/theme-provider.tsx +++ b/web/src/components/theme-provider.tsx @@ -1,7 +1,7 @@ 'use client'; -import { ThemeProvider as NextThemesProvider } from 'next-themes'; import type { ThemeProviderProps } from 'next-themes'; +import { ThemeProvider as NextThemesProvider } from 'next-themes'; /** * Theme provider wrapper for next-themes. diff --git a/web/src/components/theme-toggle.tsx b/web/src/components/theme-toggle.tsx index 62b18d82c..1f0ef0b4e 100644 --- a/web/src/components/theme-toggle.tsx +++ b/web/src/components/theme-toggle.tsx @@ -2,6 +2,7 @@ import { Moon, Sun } from 'lucide-react'; import { useTheme } from 'next-themes'; +import { useEffect, useState } from 'react'; import { Button } from '@/components/ui/button'; import { DropdownMenu, @@ -9,7 +10,6 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { useEffect, useState } from 'react'; /** * Theme toggle dropdown component. diff --git a/web/src/components/ui/channel-selector.tsx b/web/src/components/ui/channel-selector.tsx index 23a5ba36a..1dd22ac28 100644 --- a/web/src/components/ui/channel-selector.tsx +++ b/web/src/components/ui/channel-selector.tsx @@ -212,10 +212,14 @@ export function ChannelSelector({ return a.name.localeCompare(b.name); }); - setChannels(sortedChannels); + if (abortControllerRef.current === controller) { + setChannels(sortedChannels); + } } catch (err) { if (err instanceof DOMException && err.name === 'AbortError') return; - setError(err instanceof Error ? err.message : 'Failed to load channels'); + if (abortControllerRef.current === controller) { + setError(err instanceof Error ? err.message : 'Failed to load channels'); + } } finally { if (abortControllerRef.current === controller) { setLoading(false); @@ -361,7 +365,9 @@ export function ChannelSelector({ })} {unknownSelectedIds.map((id) => ( - + + + #unknown-channel