Use {'{user}'} for the member mention and {'{memberCount}'} for the server member count.
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..62b18d82c
--- /dev/null
+++ b/web/src/components/theme-toggle.tsx
@@ -0,0 +1,54 @@
+'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 } = 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/channel-selector.tsx b/web/src/components/ui/channel-selector.tsx
new file mode 100644
index 000000000..23a5ba36a
--- /dev/null
+++ b/web/src/components/ui/channel-selector.tsx
@@ -0,0 +1,390 @@
+'use client';
+
+import {
+ Check,
+ ChevronsUpDown,
+ Hash,
+ Headphones,
+ Loader2,
+ Megaphone,
+ StickyNote,
+ Text,
+ Video,
+ X,
+} from 'lucide-react';
+import * as React from 'react';
+
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from '@/components/ui/command';
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
+import { cn } from '@/lib/utils';
+
+export interface DiscordChannel {
+ id: string;
+ name: string;
+ type: number;
+}
+
+// Discord channel types
+const CHANNEL_TYPES = {
+ GUILD_TEXT: 0,
+ DM: 1,
+ GUILD_VOICE: 2,
+ GROUP_DM: 3,
+ GUILD_CATEGORY: 4,
+ GUILD_ANNOUNCEMENT: 5,
+ ANNOUNCEMENT_THREAD: 10,
+ PUBLIC_THREAD: 11,
+ PRIVATE_THREAD: 12,
+ GUILD_STAGE_VOICE: 13,
+ GUILD_DIRECTORY: 14,
+ GUILD_FORUM: 15,
+ GUILD_MEDIA: 16,
+} as const;
+
+type ChannelTypeFilter = 'all' | 'text' | 'voice' | 'announcement' | 'thread' | 'forum';
+
+interface ChannelSelectorProps {
+ guildId: string;
+ selected: string[];
+ onChange: (selected: string[]) => void;
+ placeholder?: string;
+ disabled?: boolean;
+ className?: string;
+ maxSelections?: number;
+ filter?: ChannelTypeFilter;
+}
+
+function getChannelIcon(type: number) {
+ switch (type) {
+ case CHANNEL_TYPES.GUILD_TEXT:
+ return
;
+ case CHANNEL_TYPES.GUILD_VOICE:
+ return
;
+ case CHANNEL_TYPES.GUILD_ANNOUNCEMENT:
+ return
;
+ case CHANNEL_TYPES.ANNOUNCEMENT_THREAD:
+ case CHANNEL_TYPES.PUBLIC_THREAD:
+ case CHANNEL_TYPES.PRIVATE_THREAD:
+ return
;
+ case CHANNEL_TYPES.GUILD_STAGE_VOICE:
+ return
;
+ case CHANNEL_TYPES.GUILD_FORUM:
+ case CHANNEL_TYPES.GUILD_MEDIA:
+ return
;
+ case CHANNEL_TYPES.GUILD_CATEGORY:
+ return null;
+ default:
+ return
;
+ }
+}
+
+function getChannelTypeLabel(type: number): string {
+ switch (type) {
+ case CHANNEL_TYPES.GUILD_TEXT:
+ return 'Text';
+ case CHANNEL_TYPES.GUILD_VOICE:
+ return 'Voice';
+ case CHANNEL_TYPES.GUILD_CATEGORY:
+ return 'Category';
+ case CHANNEL_TYPES.GUILD_ANNOUNCEMENT:
+ return 'Announcement';
+ case CHANNEL_TYPES.ANNOUNCEMENT_THREAD:
+ return 'Thread';
+ case CHANNEL_TYPES.PUBLIC_THREAD:
+ return 'Thread';
+ case CHANNEL_TYPES.PRIVATE_THREAD:
+ return 'Private Thread';
+ case CHANNEL_TYPES.GUILD_STAGE_VOICE:
+ return 'Stage';
+ case CHANNEL_TYPES.GUILD_FORUM:
+ return 'Forum';
+ case CHANNEL_TYPES.GUILD_MEDIA:
+ return 'Media';
+ default:
+ return 'Channel';
+ }
+}
+
+function filterChannelsByType(
+ channels: DiscordChannel[],
+ filter: ChannelTypeFilter,
+): DiscordChannel[] {
+ if (filter === 'all') return channels;
+
+ return channels.filter((channel) => {
+ switch (filter) {
+ case 'text':
+ return channel.type === CHANNEL_TYPES.GUILD_TEXT;
+ case 'voice':
+ return (
+ channel.type === CHANNEL_TYPES.GUILD_VOICE ||
+ channel.type === CHANNEL_TYPES.GUILD_STAGE_VOICE
+ );
+ case 'announcement':
+ return channel.type === CHANNEL_TYPES.GUILD_ANNOUNCEMENT;
+ case 'thread':
+ return (
+ channel.type === CHANNEL_TYPES.ANNOUNCEMENT_THREAD ||
+ channel.type === CHANNEL_TYPES.PUBLIC_THREAD ||
+ channel.type === CHANNEL_TYPES.PRIVATE_THREAD
+ );
+ case 'forum':
+ return (
+ channel.type === CHANNEL_TYPES.GUILD_FORUM || channel.type === CHANNEL_TYPES.GUILD_MEDIA
+ );
+ default:
+ return true;
+ }
+ });
+}
+
+export function ChannelSelector({
+ guildId,
+ selected,
+ onChange,
+ placeholder = 'Select channels...',
+ disabled = false,
+ className,
+ maxSelections,
+ filter = 'all',
+}: ChannelSelectorProps) {
+ const [open, setOpen] = React.useState(false);
+ const [channels, setChannels] = React.useState
([]);
+ const [loading, setLoading] = React.useState(false);
+ const [error, setError] = React.useState(null);
+ const abortControllerRef = React.useRef(null);
+
+ React.useEffect(() => {
+ if (!guildId || !open) return;
+
+ async function fetchChannels() {
+ abortControllerRef.current?.abort();
+ const controller = new AbortController();
+ abortControllerRef.current = controller;
+
+ setLoading(true);
+ setError(null);
+ setChannels([]);
+
+ try {
+ const response = await fetch(`/api/guilds/${encodeURIComponent(guildId)}/channels`, {
+ signal: controller.signal,
+ });
+
+ if (response.status === 401) {
+ window.location.href = '/login';
+ return;
+ }
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch channels: ${response.statusText}`);
+ }
+
+ const data: unknown = await response.json();
+
+ if (!Array.isArray(data)) {
+ throw new Error('Invalid response: expected array');
+ }
+
+ const fetchedChannels = data.filter(
+ (c): c is DiscordChannel =>
+ typeof c === 'object' &&
+ c !== null &&
+ typeof (c as Record).id === 'string' &&
+ typeof (c as Record).name === 'string' &&
+ typeof (c as Record).type === 'number',
+ );
+
+ const sortedChannels = fetchedChannels.sort((a, b) => {
+ if (a.type === CHANNEL_TYPES.GUILD_CATEGORY && b.type !== CHANNEL_TYPES.GUILD_CATEGORY)
+ return 1;
+ if (b.type === CHANNEL_TYPES.GUILD_CATEGORY && a.type !== CHANNEL_TYPES.GUILD_CATEGORY)
+ return -1;
+ return a.name.localeCompare(b.name);
+ });
+
+ setChannels(sortedChannels);
+ } catch (err) {
+ if (err instanceof DOMException && err.name === 'AbortError') return;
+ setError(err instanceof Error ? err.message : 'Failed to load channels');
+ } finally {
+ if (abortControllerRef.current === controller) {
+ setLoading(false);
+ }
+ }
+ }
+
+ void fetchChannels();
+
+ return () => {
+ abortControllerRef.current?.abort();
+ };
+ }, [guildId, open]);
+
+ const filteredChannels = React.useMemo(
+ () => filterChannelsByType(channels, filter),
+ [channels, filter],
+ );
+
+ const toggleChannel = React.useCallback(
+ (channelId: string) => {
+ if (selected.includes(channelId)) {
+ onChange(selected.filter((id) => id !== channelId));
+ } else if (!maxSelections || selected.length < maxSelections) {
+ onChange([...selected, channelId]);
+ }
+ },
+ [selected, onChange, maxSelections],
+ );
+
+ const removeChannel = React.useCallback(
+ (channelId: string) => {
+ onChange(selected.filter((id) => id !== channelId));
+ },
+ [selected, onChange],
+ );
+
+ const selectedChannels = React.useMemo(
+ () => channels.filter((channel) => selected.includes(channel.id)),
+ [channels, selected],
+ );
+
+ const unknownSelectedIds = React.useMemo(
+ () => selected.filter((id) => !channels.some((channel) => channel.id === id)),
+ [channels, selected],
+ );
+
+ const atMaxSelection = maxSelections !== undefined && selected.length >= maxSelections;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {loading ? (
+
+
+ Loading...
+
+ ) : error ? (
+ {error}
+ ) : (
+ 'No channels found.'
+ )}
+
+
+ {filteredChannels.map((channel) => {
+ const isSelected = selected.includes(channel.id);
+ const isDisabled = !isSelected && atMaxSelection;
+ const isCategory = channel.type === CHANNEL_TYPES.GUILD_CATEGORY;
+ const icon = getChannelIcon(channel.type);
+
+ return (
+ toggleChannel(channel.id)}
+ disabled={isDisabled || isCategory}
+ className={cn(
+ 'flex items-center gap-2',
+ (isDisabled || isCategory) && 'cursor-not-allowed opacity-50',
+ isCategory && 'font-semibold bg-muted/50 mt-1',
+ )}
+ >
+ {icon && {icon}}
+ {channel.name}
+ {!isCategory && (
+
+ )}
+
+ );
+ })}
+
+
+
+
+
+
+ {(selectedChannels.length > 0 || unknownSelectedIds.length > 0) && (
+
+ {selectedChannels.map((channel) => {
+ const icon = getChannelIcon(channel.type);
+ return (
+
+ {icon && {icon}}
+ #{channel.name}
+
+
+ );
+ })}
+ {unknownSelectedIds.map((id) => (
+
+
+ #unknown-channel
+
+
+ ))}
+
+ )}
+
+ {maxSelections !== undefined && (
+
+ {selected.length} of {maxSelections} maximum channels selected
+
+ )}
+
+ );
+}
+
+export { CHANNEL_TYPES, getChannelIcon, getChannelTypeLabel };
+export type { ChannelTypeFilter };
diff --git a/web/src/components/ui/checkbox.tsx b/web/src/components/ui/checkbox.tsx
new file mode 100644
index 000000000..25fa67dc9
--- /dev/null
+++ b/web/src/components/ui/checkbox.tsx
@@ -0,0 +1,29 @@
+'use client';
+
+import * as React from 'react';
+import { CheckIcon } from 'lucide-react';
+import { Checkbox as CheckboxPrimitive } from 'radix-ui';
+
+import { cn } from '@/lib/utils';
+
+function Checkbox({ className, ...props }: React.ComponentProps) {
+ return (
+
+
+
+
+
+ );
+}
+
+export { Checkbox };
diff --git a/web/src/components/ui/command.tsx b/web/src/components/ui/command.tsx
new file mode 100644
index 000000000..23337323d
--- /dev/null
+++ b/web/src/components/ui/command.tsx
@@ -0,0 +1,161 @@
+'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 (
+
+ );
+}
+
+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..a81826fff 100644
--- a/web/src/components/ui/dialog.tsx
+++ b/web/src/components/ui/dialog.tsx
@@ -1,100 +1,144 @@
'use client';
-import * as DialogPrimitive from '@radix-ui/react-dialog';
-import { X } from 'lucide-react';
import * as React from 'react';
+import { XIcon } from 'lucide-react';
+import { Dialog as DialogPrimitive } from 'radix-ui';
+
import { cn } from '@/lib/utils';
+import { Button } from '@/components/ui/button';
+
+function Dialog({ ...props }: React.ComponentProps) {
+ return ;
+}
+
+function DialogTrigger({ ...props }: React.ComponentProps) {
+ return ;
+}
-const Dialog = DialogPrimitive.Root;
-const DialogTrigger = DialogPrimitive.Trigger;
-const DialogPortal = DialogPrimitive.Portal;
-const DialogClose = DialogPrimitive.Close;
+function DialogPortal({ ...props }: React.ComponentProps) {
+ return ;
+}
-const DialogOverlay = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-));
-DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
+function DialogClose({ ...props }: React.ComponentProps) {
+ return ;
+}
-const DialogContent = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, children, ...props }, ref) => (
-
-
- ) {
+ 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/form.tsx b/web/src/components/ui/form.tsx
new file mode 100644
index 000000000..02462c52f
--- /dev/null
+++ b/web/src/components/ui/form.tsx
@@ -0,0 +1,156 @@
+'use client';
+
+import * as React from 'react';
+import type { Label as LabelPrimitive } from 'radix-ui';
+import { Slot } from 'radix-ui';
+import {
+ Controller,
+ FormProvider,
+ useFormContext,
+ useFormState,
+ type ControllerProps,
+ type FieldPath,
+ type FieldValues,
+} from 'react-hook-form';
+
+import { cn } from '@/lib/utils';
+import { Label } from '@/components/ui/label';
+
+const Form = FormProvider;
+
+type FormFieldContextValue<
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath,
+> = {
+ name: TName;
+};
+
+const FormFieldContext = React.createContext(null);
+
+const FormField = <
+ TFieldValues extends FieldValues = FieldValues,
+ TName extends FieldPath = FieldPath,
+>({
+ ...props
+}: ControllerProps) => {
+ return (
+
+
+
+ );
+};
+
+const useFormField = () => {
+ const fieldContext = React.useContext(FormFieldContext);
+ if (!fieldContext) {
+ throw new Error('useFormField should be used within ');
+ }
+
+ const itemContext = React.useContext(FormItemContext);
+ if (!itemContext) {
+ throw new Error('useFormField should be used within ');
+ }
+
+ const { getFieldState } = useFormContext();
+ const formState = useFormState({ name: fieldContext.name });
+ const fieldState = getFieldState(fieldContext.name, formState);
+
+ const { id } = itemContext;
+
+ return {
+ id,
+ name: fieldContext.name,
+ formItemId: `${id}-form-item`,
+ formDescriptionId: `${id}-form-item-description`,
+ formMessageId: `${id}-form-item-message`,
+ ...fieldState,
+ };
+};
+
+type FormItemContextValue = {
+ id: string;
+};
+
+const FormItemContext = React.createContext(null);
+
+function FormItem({ className, ...props }: React.ComponentProps<'div'>) {
+ const id = React.useId();
+
+ return (
+
+
+
+ );
+}
+
+function FormLabel({ className, ...props }: React.ComponentProps) {
+ const { error, formItemId } = useFormField();
+
+ return (
+
+ );
+}
+
+function FormControl({ ...props }: React.ComponentProps) {
+ const { error, formItemId, formDescriptionId, formMessageId } = useFormField();
+
+ return (
+
+ );
+}
+
+function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
+ const { formDescriptionId } = useFormField();
+
+ return (
+
+ );
+}
+
+function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
+ const { error, formMessageId } = useFormField();
+ const body = error ? String(error?.message ?? '') : props.children;
+
+ if (!body) {
+ return null;
+ }
+
+ return (
+
+ {body}
+
+ );
+}
+
+export {
+ useFormField,
+ Form,
+ FormItem,
+ FormLabel,
+ FormControl,
+ FormDescription,
+ FormMessage,
+ FormField,
+};
diff --git a/web/src/components/ui/popover.tsx b/web/src/components/ui/popover.tsx
new file mode 100644
index 000000000..053fa202e
--- /dev/null
+++ b/web/src/components/ui/popover.tsx
@@ -0,0 +1,74 @@
+'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,
+};
diff --git a/web/src/components/ui/role-selector.tsx b/web/src/components/ui/role-selector.tsx
new file mode 100644
index 000000000..2017725dc
--- /dev/null
+++ b/web/src/components/ui/role-selector.tsx
@@ -0,0 +1,279 @@
+'use client';
+
+import { Check, ChevronsUpDown, Loader2, X } from 'lucide-react';
+import * as React from 'react';
+
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from '@/components/ui/command';
+import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
+import { cn } from '@/lib/utils';
+
+export interface DiscordRole {
+ id: string;
+ name: string;
+ color: number;
+}
+
+interface RoleSelectorProps {
+ guildId: string;
+ selected: string[];
+ onChange: (selected: string[]) => void;
+ placeholder?: string;
+ disabled?: boolean;
+ className?: string;
+ maxSelections?: number;
+}
+
+function discordColorToHex(color: number): string | null {
+ if (!color) return null;
+ return `#${color.toString(16).padStart(6, '0')}`;
+}
+
+export function RoleSelector({
+ guildId,
+ selected,
+ onChange,
+ placeholder = 'Select roles...',
+ disabled = false,
+ className,
+ maxSelections,
+}: RoleSelectorProps) {
+ const [open, setOpen] = React.useState(false);
+ const [roles, setRoles] = React.useState([]);
+ const [loading, setLoading] = React.useState(false);
+ const [error, setError] = React.useState(null);
+ const abortControllerRef = React.useRef(null);
+
+ React.useEffect(() => {
+ if (!guildId || !open) return;
+
+ async function fetchRoles() {
+ abortControllerRef.current?.abort();
+ const controller = new AbortController();
+ abortControllerRef.current = controller;
+
+ setLoading(true);
+ setRoles([]);
+ setError(null);
+
+ try {
+ const response = await fetch(`/api/guilds/${encodeURIComponent(guildId)}/roles`, {
+ signal: controller.signal,
+ });
+
+ if (response.status === 401) {
+ window.location.href = '/login';
+ return;
+ }
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch roles: ${response.statusText}`);
+ }
+
+ const data: unknown = await response.json();
+
+ if (!Array.isArray(data)) {
+ throw new Error('Invalid response: expected array');
+ }
+
+ const fetchedRoles = data.filter(
+ (r): r is DiscordRole =>
+ typeof r === 'object' &&
+ r !== null &&
+ typeof (r as Record).id === 'string' &&
+ typeof (r as Record).name === 'string' &&
+ typeof (r as Record).color === 'number',
+ );
+
+ setRoles(fetchedRoles);
+ } catch (err) {
+ if (err instanceof DOMException && err.name === 'AbortError') return;
+ setError(err instanceof Error ? err.message : 'Failed to load roles');
+ } finally {
+ if (abortControllerRef.current === controller) {
+ setLoading(false);
+ }
+ }
+ }
+
+ void fetchRoles();
+
+ return () => {
+ abortControllerRef.current?.abort();
+ };
+ }, [guildId, open]);
+
+ const toggleRole = React.useCallback(
+ (roleId: string) => {
+ if (selected.includes(roleId)) {
+ onChange(selected.filter((id) => id !== roleId));
+ } else if (!maxSelections || selected.length < maxSelections) {
+ onChange([...selected, roleId]);
+ }
+ },
+ [selected, onChange, maxSelections],
+ );
+
+ const removeRole = React.useCallback(
+ (roleId: string) => {
+ onChange(selected.filter((id) => id !== roleId));
+ },
+ [selected, onChange],
+ );
+
+ const selectedRoles = React.useMemo(
+ () => roles.filter((role) => selected.includes(role.id)),
+ [roles, selected],
+ );
+
+ const unknownSelectedIds = React.useMemo(
+ () => selected.filter((id) => !roles.some((role) => role.id === id)),
+ [roles, selected],
+ );
+
+ const atMaxSelection = maxSelections !== undefined && selected.length >= maxSelections;
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {loading ? (
+
+
+ Loading...
+
+ ) : error ? (
+ {error}
+ ) : (
+ 'No roles found.'
+ )}
+
+
+ {roles.map((role) => {
+ const isSelected = selected.includes(role.id);
+ const isDisabled = !isSelected && atMaxSelection;
+ const colorHex = discordColorToHex(role.color);
+
+ return (
+ toggleRole(role.id)}
+ disabled={isDisabled}
+ className={cn(
+ 'flex items-center gap-2',
+ isDisabled && 'cursor-not-allowed opacity-50',
+ )}
+ >
+
+ {role.name}
+
+
+ );
+ })}
+
+
+
+
+
+
+ {(selectedRoles.length > 0 || unknownSelectedIds.length > 0) && (
+
+ {selectedRoles.map((role) => {
+ const colorHex = discordColorToHex(role.color);
+ return (
+
+
+ {role.name}
+
+
+ );
+ })}
+ {unknownSelectedIds.map((id) => (
+
+
+ Unknown role
+
+
+ ))}
+
+ )}
+
+ {maxSelections !== undefined && (
+
+ {selected.length} of {maxSelections} maximum roles selected
+
+ )}
+
+ );
+}
diff --git a/web/src/components/ui/switch.tsx b/web/src/components/ui/switch.tsx
new file mode 100644
index 000000000..f368229b1
--- /dev/null
+++ b/web/src/components/ui/switch.tsx
@@ -0,0 +1,35 @@
+'use client';
+
+import * as React from 'react';
+import { Switch as SwitchPrimitive } from 'radix-ui';
+
+import { cn } from '@/lib/utils';
+
+function Switch({
+ className,
+ size = 'default',
+ ...props
+}: React.ComponentProps & {
+ size?: 'sm' | 'default';
+}) {
+ return (
+
+
+
+ );
+}
+
+export { Switch };