import React, { useState } from "react"; import { Box, Stack, Text, Flex, Link, Spinner, Badge, HStack, VStack, } from "@chakra-ui/react"; import NextLink from "next/link"; import { formatTimeMs, formatLocalDate } from "../../../lib/time"; import TranscriptStatusIcon from "./TranscriptStatusIcon"; import TranscriptActionsMenu from "./TranscriptActionsMenu"; import { highlightMatches, generateTextFragment, } from "../../../lib/textHighlight"; import type { components } from "../../../reflector-api"; type SearchResult = components["schemas"]["SearchResult"]; type SourceKind = components["schemas"]["SourceKind"]; interface TranscriptCardsProps { results: SearchResult[]; query: string; isLoading?: boolean; onDelete: (transcriptId: string) => void; onReprocess: (transcriptId: string) => void; } function highlightText(text: string, query: string): React.ReactNode { if (!query) return text; const matches = highlightMatches(text, query); if (matches.length === 0) return text; // Sort matches by index to process them in order const sortedMatches = [...matches].sort((a, b) => a.index - b.index); const parts: React.ReactNode[] = []; let lastIndex = 0; sortedMatches.forEach((match, i) => { // Add text before the match if (match.index > lastIndex) { parts.push( {text.slice(lastIndex, match.index)} , ); } // Add the highlighted match parts.push( {match.match} , ); lastIndex = match.index + match.match.length; }); // Add remaining text after last match if (lastIndex < text.length) { parts.push( {text.slice(lastIndex)} , ); } return parts; } const transcriptHref = ( transcriptId: string, mainSnippet: string, query: string, ): `/transcripts/${string}` => { const urlTextFragment = mainSnippet ? generateTextFragment(mainSnippet, query) : null; const urlTextFragmentWithHash = urlTextFragment ? `#${urlTextFragment.k}=${encodeURIComponent(urlTextFragment.v)}` : ""; return `/transcripts/${transcriptId}${urlTextFragmentWithHash}`; }; // note that it's strongly tied to search logic - in case you want to use it independently, refactor function TranscriptCard({ result, query, onDelete, onReprocess, }: { result: SearchResult; query: string; onDelete: (transcriptId: string) => void; onReprocess: (transcriptId: string) => void; }) { const [isExpanded, setIsExpanded] = useState(false); const mainSnippet = result.search_snippets[0]; const additionalSnippets = result.search_snippets.slice(1); const totalMatches = result.total_match_count || 0; const snippetsShown = result.search_snippets.length; const remainingMatches = totalMatches - snippetsShown; const hasAdditionalSnippets = additionalSnippets.length > 0; const resultTitle = result.title || "Unnamed Transcript"; const formattedDuration = result.duration ? formatTimeMs(result.duration) : "N/A"; const formattedDate = formatLocalDate(result.created_at); const source = result.source_kind === ("room" as SourceKind) ? result.room_name || result.room_id : result.source_kind; const handleExpandClick = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); setIsExpanded(!isExpanded); }; return ( {/* Title with highlighting and text fragment for deep linking */} {highlightText(resultTitle, query)} {/* Metadata - Horizontal on desktop, vertical on mobile */} Source: {source} Date: {formattedDate} Duration: {formattedDuration} {/* Search Results Section - only show when searching */} {mainSnippet && ( <> {/* Main Snippet */} {highlightText(mainSnippet, query)} {hasAdditionalSnippets && ( <> {remainingMatches > 0 ? `${additionalSnippets.length + remainingMatches}+` : additionalSnippets.length} more{" "} {additionalSnippets.length + remainingMatches === 1 ? "match" : "matches"} {remainingMatches > 0 && ` (${additionalSnippets.length} shown)`} {isExpanded ? "▲" : "▼"} {/* Additional Snippets */} {isExpanded && ( {additionalSnippets.map((snippet, index) => ( {highlightText(snippet, query)} ))} )} )} )} ); } export default function TranscriptCards({ results, query, isLoading, onDelete, onReprocess, }: TranscriptCardsProps) { return ( {isLoading && ( )} {results.map((result) => ( ))} ); }