import { useState } from 'react'; import { useSearchParams } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; import { FolderGit2, GitPullRequest, Hexagon, MessageSquare, Search, Users, Loader2, } from 'lucide-react'; import { client } from '@/client/client.gen'; import { messageSearch, search, searchMessages } from '@/client/sdk.gen'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Input } from '@/components/ui/input'; import { cn } from '@/lib/utils'; import { formatDistanceToNow, parseISO } from 'date-fns'; import type { ProjectSearchItem, RepoSearchItem, IssueSearchItem, UserSearchItem, MessageSearchResponse, RoomMessageResponse, SearchResponse, GlobalMessageSearchResponse, GlobalMessageSearchItem, } from '@/client/types.gen'; // ─── Helpers ────────────────────────────────────────────────────────────────── /** Escape HTML entities to prevent XSS when rendering user content via dangerouslySetInnerHTML. */ function escapeHtml(str: string): string { return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } const ALL_TYPES = ['projects', 'repos', 'issues', 'users', 'messages'] as const; type SearchType = typeof ALL_TYPES[number]; const TYPE_LABELS: Record = { projects: 'Projects', repos: 'Repositories', issues: 'Issues', users: 'Users', messages: 'Messages', }; const TYPE_ICONS: Record> = { projects: Hexagon, repos: FolderGit2, issues: GitPullRequest, users: Users, messages: MessageSquare, }; function getTotal(results: SearchResponse): number { return [results.projects, results.repos, results.issues, results.users] .filter(Boolean) .reduce((sum, r) => sum + (r?.total ?? 0), 0); } // ─── Sub-components ─────────────────────────────────────────────────────────── function ProjectItem({ item }: { item: ProjectSearchItem }) { return (
{item.display_name?.charAt(0)?.toUpperCase() ?? 'P'}
{item.display_name || item.name} {item.name} {item.is_public ? 'Public' : 'Private'}
{item.description && (

{item.description}

)}

Updated {formatDistanceToNow(parseISO(item.updated_at), { addSuffix: true })}

); } function RepoItem({ item }: { item: RepoSearchItem }) { return (
{item.name} {item.project_name} {item.is_private ? 'Private' : 'Public'}
{item.description && (

{item.description}

)}
); } function IssueItem({ item }: { item: IssueSearchItem }) { return (
{item.title} #{item.number} {item.state}

{item.project_name} · Updated {formatDistanceToNow(parseISO(item.updated_at), { addSuffix: true })}

); } function UserItem({ item }: { item: UserSearchItem }) { return (
{item.display_name?.charAt(0)?.toUpperCase() ?? item.username.charAt(0).toUpperCase()}
{item.display_name || item.username} @{item.username}
{item.organization && (

{item.organization}

)}
); } type MessageSearchItem = RoomMessageResponse | GlobalMessageSearchItem; function isGlobalMessage(item: MessageSearchItem): item is GlobalMessageSearchItem { return 'room_id' in item && 'room_name' in item; } function MessageItem({ item }: { item: MessageSearchItem }) { const isGlobal = isGlobalMessage(item); const roomLabel = isGlobal ? item.room_name : item.room; const roomHref = isGlobal ? `/rooms/${item.room_id}` : `/project/${item.room.split(':')[0]}/room/${item.room.split(':')[1] ?? item.room}`; const displayContent = isGlobal ? (item.highlighted_content ?? item.content) : item.content; return (
{(item.display_name ?? item.sender_id ?? '?')[0]?.toUpperCase()}
{item.display_name ?? item.sender_id ?? 'Unknown'} {roomLabel} {formatDistanceToNow(parseISO(item.send_at), { addSuffix: true })}

); } function ResultSection({ type, result, renderer, }: { type: SearchType; result: { items: T[]; total: number; page: number; per_page: number } | null; renderer: (item: T) => React.ReactNode; }) { const Icon = TYPE_ICONS[type]; const label = TYPE_LABELS[type]; if (!result || result.items.length === 0) return null; return ( {label} {result.total} {result.items.map((item) => (
{renderer(item)}
))} {result.items.length < result.total && (

Showing {result.items.length} of {result.total} {label.toLowerCase()}

)}
); } // ─── Main page ──────────────────────────────────────────────────────────────── export default function SearchPage() { const [searchParams, setSearchParams] = useSearchParams(); const [roomIdInput, setRoomIdInput] = useState(''); const q = searchParams.get('q') ?? ''; const typeParam = searchParams.get('type') ?? ''; const page = parseInt(searchParams.get('page') ?? '1', 10); const perPage = 20; // Parse active types from query param const activeTypes: SearchType[] = typeParam ? (typeParam.split(',').filter((t): t is SearchType => ALL_TYPES.includes(t as SearchType))) : [...ALL_TYPES]; const showMessages = activeTypes.includes('messages'); const useRoomScoped = roomIdInput.trim().length > 0; const { data, isLoading, error } = useQuery({ queryKey: ['search', q, typeParam, page], queryFn: async () => { const resp = await search({ client, query: { q, page, per_page: perPage, ...(typeParam ? { type: typeParam } : {}), }, }); return resp.data!.data as SearchResponse; }, enabled: q.trim().length > 0, }); // Global message search across all accessible rooms const { data: globalMessagesData, isLoading: globalMessagesLoading } = useQuery({ queryKey: ['search-messages-global', q], queryFn: async () => { const resp = await searchMessages({ query: { q, page: 1, per_page: 20 }, }); return resp.data?.data as GlobalMessageSearchResponse; }, enabled: q.trim().length > 0 && showMessages && !useRoomScoped, }); // Room-scoped message search (when room ID is explicitly provided) const { data: roomMessagesData, isLoading: roomMessagesLoading } = useQuery({ queryKey: ['search-messages-room', q, roomIdInput], queryFn: async () => { const resp = await messageSearch({ path: { room_id: roomIdInput.trim() }, query: { q, limit: 20 }, }); return resp.data?.data as MessageSearchResponse; }, enabled: q.trim().length > 0 && showMessages && useRoomScoped, }); const messagesData = useRoomScoped ? roomMessagesData : globalMessagesData; const messagesLoading = useRoomScoped ? roomMessagesLoading : globalMessagesLoading; const results = data ?? null; function handleSearchSubmit(e: React.FormEvent) { e.preventDefault(); const fd = new FormData(e.currentTarget); const newQ = fd.get('q') as string; if (newQ.trim()) { setSearchParams({ q: newQ.trim(), type: typeParam }); } } function toggleType(t: SearchType) { let next: SearchType[]; if (activeTypes.includes(t)) { next = activeTypes.filter((x) => x !== t); } else { next = [...activeTypes, t]; } if (next.length === 0) next = [...ALL_TYPES]; setSearchParams({ q, type: next.join(','), page: '1' }); } return (
{/* Search header */}
{/* Type filters */}
{ALL_TYPES.map((t) => { const Icon = TYPE_ICONS[t]; const active = activeTypes.includes(t); return ( ); })}
{/* Room ID input for messages search */} {showMessages && (
setRoomIdInput(e.target.value)} className="h-8 text-xs" />
)}
{/* Results */}
{isLoading && !showMessages && (
)} {error && (

Search failed

{error.message}

)} {!isLoading && !error && !q.trim() && (

Enter a keyword to search across all content.

)} {!isLoading && !error && results && q.trim() && ( <>

{results && getTotal(results) > 0 ? `${getTotal(results)} results for "${q}"` : showMessages && messagesData ? `${messagesData.total} message${messagesData.total === 1 ? '' : 's'} for "${q}"${useRoomScoped ? ` in room ${roomIdInput}` : ' across all accessible rooms'}` : `No results for "${q}"`}

{activeTypes.includes('projects') && ( } /> )} {activeTypes.includes('repos') && ( } /> )} {activeTypes.includes('issues') && ( } /> )} {activeTypes.includes('users') && ( } /> )} {activeTypes.includes('messages') && ( <> {messagesLoading && (
)} {!messagesLoading && messagesData && messagesData.messages.length > 0 && ( Messages {messagesData.total} {useRoomScoped ? (

in room {roomIdInput}

) : (

across all accessible rooms

)}
{messagesData.messages.map((msg) => ( ))}
)} {!messagesLoading && messagesData && messagesData.messages.length === 0 && ( Messages

{useRoomScoped ? `No messages found in room "${roomIdInput}" matching "${q}"` : `No messages found matching "${q}" across accessible rooms`}

)} )}
{getTotal(results) === 0 && (

No results found

Try different keywords or check your spelling.

)} )}
); }