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