mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-22 13:19:05 +00:00
feat: search frontend (#551)
* feat: better highlight * feat(search): add long_summary to search vector for improved search results - Update search vector to include long_summary with weight B (between title A and webvtt C) - Modify SearchController to fetch long_summary and prioritize its snippets - Generate snippets from long_summary first (max 2), then from webvtt for remaining slots - Add comprehensive tests for long_summary search functionality - Create migration to update search_vector_en column in PostgreSQL This improves search quality by including summarized content which often contains key topics and themes that may not be explicitly mentioned in the transcript. * fix: address code review feedback for search enhancements - Fix test file inconsistencies by removing references to non-existent model fields - Comment out tests for unimplemented features (room_ids, status filters, date ranges) - Update tests to only use currently available fields (room_id singular, no room_name/processing_status) - Mark future functionality tests with @pytest.mark.skip - Make snippet counts configurable - Add LONG_SUMMARY_MAX_SNIPPETS constant (default: 2) - Replace hardcoded value with configurable constant - Improve error handling consistency in WebVTT parsing - Use different log levels for different error types (debug for malformed, warning for decode, error for unexpected) - Add catch-all exception handler for unexpected errors - Include stack trace for critical errors All existing tests pass with these changes. * fix: correct datetime test to include required duration field * feat: better highlight * feat: search room names * feat: acknowledge deleted room * feat: search filters fix and rank removal * chore: minor refactoring * feat: better matches frontend * chore: self-review (vibe) * chore: self-review WIP * chore: self-review WIP * chore: self-review WIP * chore: self-review WIP * chore: self-review WIP * chore: self-review WIP * chore: self-review WIP * remove swc (vibe) * search url query sync (vibe) * search url query sync (vibe) * better casts and cap while * PR review + simplify frontend hook * pr: remove search db timeouts * cleanup tests * tests cleanup * frontend cleanup * index declarations * refactor frontend (self-review) * fix search pagination * clear "x" for search input * pagination max pages fix * chore: cleanup * cleanup * cleanup * cleanup * cleanup * cleanup * cleanup * cleanup * lockfile * pr review
This commit is contained in:
@@ -1,27 +1,290 @@
|
||||
import React from "react";
|
||||
import { Box, Stack, Text, Flex, Link, Spinner } from "@chakra-ui/react";
|
||||
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 { GetTranscriptMinimal } from "../../../api";
|
||||
import { formatTimeMs, formatLocalDate } from "../../../lib/time";
|
||||
import TranscriptStatusIcon from "./TranscriptStatusIcon";
|
||||
import TranscriptActionsMenu from "./TranscriptActionsMenu";
|
||||
import {
|
||||
highlightMatches,
|
||||
generateTextFragment,
|
||||
} from "../../../lib/textHighlight";
|
||||
import { SearchResult } from "../../../api";
|
||||
|
||||
interface TranscriptCardsProps {
|
||||
transcripts: GetTranscriptMinimal[];
|
||||
onDelete: (transcriptId: string) => (e: any) => void;
|
||||
onReprocess: (transcriptId: string) => (e: any) => void;
|
||||
loading?: boolean;
|
||||
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 as="span" key={`text-${i}`} display="inline">
|
||||
{text.slice(lastIndex, match.index)}
|
||||
</Text>,
|
||||
);
|
||||
}
|
||||
|
||||
// Add the highlighted match
|
||||
parts.push(
|
||||
<Text
|
||||
as="mark"
|
||||
key={`match-${i}`}
|
||||
bg="yellow.200"
|
||||
px={0.5}
|
||||
display="inline"
|
||||
>
|
||||
{match.match}
|
||||
</Text>,
|
||||
);
|
||||
|
||||
lastIndex = match.index + match.match.length;
|
||||
});
|
||||
|
||||
// Add remaining text after last match
|
||||
if (lastIndex < text.length) {
|
||||
parts.push(
|
||||
<Text as="span" key={`text-end`} display="inline">
|
||||
{text.slice(lastIndex)}
|
||||
</Text>,
|
||||
);
|
||||
}
|
||||
|
||||
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"
|
||||
? result.room_name || result.room_id
|
||||
: result.source_kind;
|
||||
|
||||
const handleExpandClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsExpanded(!isExpanded);
|
||||
};
|
||||
|
||||
return (
|
||||
<Box borderWidth={1} p={4} borderRadius="md" fontSize="sm">
|
||||
<Flex justify="space-between" alignItems="flex-start" gap="2">
|
||||
<Box>
|
||||
<TranscriptStatusIcon status={result.status} />
|
||||
</Box>
|
||||
<Box flex="1">
|
||||
{/* Title with highlighting and text fragment for deep linking */}
|
||||
<Link
|
||||
as={NextLink}
|
||||
href={transcriptHref(result.id, mainSnippet, query)}
|
||||
fontWeight="600"
|
||||
display="block"
|
||||
mb={2}
|
||||
>
|
||||
{highlightText(resultTitle, query)}
|
||||
</Link>
|
||||
|
||||
{/* Metadata - Horizontal on desktop, vertical on mobile */}
|
||||
<Flex
|
||||
direction={{ base: "column", md: "row" }}
|
||||
gap={{ base: 1, md: 2 }}
|
||||
fontSize="xs"
|
||||
color="gray.600"
|
||||
flexWrap="wrap"
|
||||
align={{ base: "flex-start", md: "center" }}
|
||||
>
|
||||
<Flex align="center" gap={1}>
|
||||
<Text fontWeight="medium" color="gray.500">
|
||||
Source:
|
||||
</Text>
|
||||
<Text>{source}</Text>
|
||||
</Flex>
|
||||
<Text display={{ base: "none", md: "block" }} color="gray.400">
|
||||
•
|
||||
</Text>
|
||||
<Flex align="center" gap={1}>
|
||||
<Text fontWeight="medium" color="gray.500">
|
||||
Date:
|
||||
</Text>
|
||||
<Text>{formattedDate}</Text>
|
||||
</Flex>
|
||||
<Text display={{ base: "none", md: "block" }} color="gray.400">
|
||||
•
|
||||
</Text>
|
||||
<Flex align="center" gap={1}>
|
||||
<Text fontWeight="medium" color="gray.500">
|
||||
Duration:
|
||||
</Text>
|
||||
<Text>{formattedDuration}</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
{/* Search Results Section - only show when searching */}
|
||||
{mainSnippet && (
|
||||
<>
|
||||
{/* Main Snippet */}
|
||||
<Box
|
||||
mt={3}
|
||||
p={2}
|
||||
bg="gray.50"
|
||||
borderLeft="2px solid"
|
||||
borderLeftColor="blue.400"
|
||||
borderRadius="sm"
|
||||
fontSize="xs"
|
||||
>
|
||||
<Text color="gray.700">
|
||||
{highlightText(mainSnippet, query)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{hasAdditionalSnippets && (
|
||||
<>
|
||||
<Flex
|
||||
mt={2}
|
||||
p={2}
|
||||
bg="blue.50"
|
||||
borderRadius="sm"
|
||||
cursor="pointer"
|
||||
onClick={handleExpandClick}
|
||||
_hover={{ bg: "blue.100" }}
|
||||
align="center"
|
||||
justify="space-between"
|
||||
>
|
||||
<HStack gap={2}>
|
||||
<Badge
|
||||
bg="blue.500"
|
||||
color="white"
|
||||
fontSize="xs"
|
||||
px={2}
|
||||
borderRadius="full"
|
||||
>
|
||||
{remainingMatches > 0
|
||||
? `${additionalSnippets.length + remainingMatches}+`
|
||||
: additionalSnippets.length}
|
||||
</Badge>
|
||||
<Text fontSize="xs" color="blue.600" fontWeight="medium">
|
||||
more{" "}
|
||||
{additionalSnippets.length + remainingMatches === 1
|
||||
? "match"
|
||||
: "matches"}
|
||||
{remainingMatches > 0 &&
|
||||
` (${additionalSnippets.length} shown)`}
|
||||
</Text>
|
||||
</HStack>
|
||||
<Text fontSize="xs" color="blue.600">
|
||||
{isExpanded ? "▲" : "▼"}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{/* Additional Snippets */}
|
||||
{isExpanded && (
|
||||
<VStack align="stretch" gap={2} mt={2}>
|
||||
{additionalSnippets.map((snippet, index) => (
|
||||
<Box
|
||||
key={index}
|
||||
p={2}
|
||||
bg="gray.50"
|
||||
borderLeft="2px solid"
|
||||
borderLeftColor="gray.300"
|
||||
borderRadius="sm"
|
||||
fontSize="xs"
|
||||
>
|
||||
<Text color="gray.700">
|
||||
{highlightText(snippet, query)}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
<TranscriptActionsMenu
|
||||
transcriptId={result.id}
|
||||
onDelete={onDelete}
|
||||
onReprocess={onReprocess}
|
||||
/>
|
||||
</Flex>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TranscriptCards({
|
||||
transcripts,
|
||||
results,
|
||||
query,
|
||||
isLoading,
|
||||
onDelete,
|
||||
onReprocess,
|
||||
loading,
|
||||
}: TranscriptCardsProps) {
|
||||
return (
|
||||
<Box display={{ base: "block", lg: "none" }} position="relative">
|
||||
{loading && (
|
||||
<Box position="relative">
|
||||
{isLoading && (
|
||||
<Flex
|
||||
position="absolute"
|
||||
top={0}
|
||||
@@ -37,48 +300,19 @@ export default function TranscriptCards({
|
||||
</Flex>
|
||||
)}
|
||||
<Box
|
||||
opacity={loading ? 0.9 : 1}
|
||||
pointerEvents={loading ? "none" : "auto"}
|
||||
opacity={isLoading ? 0.9 : 1}
|
||||
pointerEvents={isLoading ? "none" : "auto"}
|
||||
transition="opacity 0.2s ease-in-out"
|
||||
>
|
||||
<Stack gap={2}>
|
||||
{transcripts.map((item) => (
|
||||
<Box
|
||||
key={item.id}
|
||||
borderWidth={1}
|
||||
p={4}
|
||||
borderRadius="md"
|
||||
fontSize="sm"
|
||||
>
|
||||
<Flex justify="space-between" alignItems="flex-start" gap="2">
|
||||
<Box>
|
||||
<TranscriptStatusIcon status={item.status} />
|
||||
</Box>
|
||||
<Box flex="1">
|
||||
<Link
|
||||
as={NextLink}
|
||||
href={`/transcripts/${item.id}`}
|
||||
fontWeight="600"
|
||||
display="block"
|
||||
>
|
||||
{item.title || "Unnamed Transcript"}
|
||||
</Link>
|
||||
<Text>
|
||||
Source:{" "}
|
||||
{item.source_kind === "room"
|
||||
? item.room_name
|
||||
: item.source_kind}
|
||||
</Text>
|
||||
<Text>Date: {formatLocalDate(item.created_at)}</Text>
|
||||
<Text>Duration: {formatTimeMs(item.duration)}</Text>
|
||||
</Box>
|
||||
<TranscriptActionsMenu
|
||||
transcriptId={item.id}
|
||||
onDelete={onDelete}
|
||||
onReprocess={onReprocess}
|
||||
/>
|
||||
</Flex>
|
||||
</Box>
|
||||
<Stack gap={3}>
|
||||
{results.map((result) => (
|
||||
<TranscriptCard
|
||||
key={result.id}
|
||||
result={result}
|
||||
query={query}
|
||||
onDelete={onDelete}
|
||||
onReprocess={onReprocess}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
Reference in New Issue
Block a user