mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2025-12-20 20:29:06 +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,26 +1,67 @@
|
||||
import React from "react";
|
||||
import React, { useEffect } from "react";
|
||||
import { Pagination, IconButton, ButtonGroup } from "@chakra-ui/react";
|
||||
import { LuChevronLeft, LuChevronRight } from "react-icons/lu";
|
||||
|
||||
// explicitly 1-based to prevent +/-1-confusion errors
|
||||
export const FIRST_PAGE = 1 as PaginationPage;
|
||||
export const parsePaginationPage = (
|
||||
page: number,
|
||||
):
|
||||
| {
|
||||
value: PaginationPage;
|
||||
}
|
||||
| {
|
||||
error: string;
|
||||
} => {
|
||||
if (page < FIRST_PAGE)
|
||||
return {
|
||||
error: "Page must be greater than 0",
|
||||
};
|
||||
if (!Number.isInteger(page))
|
||||
return {
|
||||
error: "Page must be an integer",
|
||||
};
|
||||
return {
|
||||
value: page as PaginationPage,
|
||||
};
|
||||
};
|
||||
export type PaginationPage = number & { __brand: "PaginationPage" };
|
||||
export const PaginationPage = (page: number): PaginationPage => {
|
||||
const v = parsePaginationPage(page);
|
||||
if ("error" in v) throw new Error(v.error);
|
||||
return v.value;
|
||||
};
|
||||
|
||||
export const paginationPageTo0Based = (page: PaginationPage): number =>
|
||||
page - FIRST_PAGE;
|
||||
|
||||
type PaginationProps = {
|
||||
page: number;
|
||||
setPage: (page: number) => void;
|
||||
page: PaginationPage;
|
||||
setPage: (page: PaginationPage) => void;
|
||||
total: number;
|
||||
size: number;
|
||||
};
|
||||
|
||||
export const totalPages = (total: number, size: number) => {
|
||||
return Math.ceil(total / size);
|
||||
};
|
||||
|
||||
export default function PaginationComponent(props: PaginationProps) {
|
||||
const { page, setPage, total, size } = props;
|
||||
const totalPages = Math.ceil(total / size);
|
||||
|
||||
if (totalPages <= 1) return null;
|
||||
useEffect(() => {
|
||||
if (page > totalPages(total, size)) {
|
||||
console.error(
|
||||
`Page number (${page}) is greater than total pages (${totalPages}) in pagination`,
|
||||
);
|
||||
}
|
||||
}, [page, totalPages(total, size)]);
|
||||
|
||||
return (
|
||||
<Pagination.Root
|
||||
count={total}
|
||||
pageSize={size}
|
||||
page={page}
|
||||
onPageChange={(details) => setPage(details.page)}
|
||||
onPageChange={(details) => setPage(PaginationPage(details.page))}
|
||||
style={{ display: "flex", justifyContent: "center" }}
|
||||
>
|
||||
<ButtonGroup variant="ghost" size="xs">
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import React, { useState } from "react";
|
||||
import { Flex, Input, Button } from "@chakra-ui/react";
|
||||
|
||||
interface SearchBarProps {
|
||||
onSearch: (searchTerm: string) => void;
|
||||
}
|
||||
|
||||
export default function SearchBar({ onSearch }: SearchBarProps) {
|
||||
const [searchInputValue, setSearchInputValue] = useState("");
|
||||
|
||||
const handleSearch = () => {
|
||||
onSearch(searchInputValue);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (event.key === "Enter") {
|
||||
handleSearch();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex alignItems="center">
|
||||
<Input
|
||||
placeholder="Search transcriptions..."
|
||||
value={searchInputValue}
|
||||
onChange={(e) => setSearchInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<Button ml={2} onClick={handleSearch}>
|
||||
Search
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import { LuMenu, LuTrash, LuRotateCw } from "react-icons/lu";
|
||||
|
||||
interface TranscriptActionsMenuProps {
|
||||
transcriptId: string;
|
||||
onDelete: (transcriptId: string) => (e: any) => void;
|
||||
onReprocess: (transcriptId: string) => (e: any) => void;
|
||||
onDelete: (transcriptId: string) => void;
|
||||
onReprocess: (transcriptId: string) => void;
|
||||
}
|
||||
|
||||
export default function TranscriptActionsMenu({
|
||||
@@ -24,11 +24,17 @@ export default function TranscriptActionsMenu({
|
||||
<Menu.Content>
|
||||
<Menu.Item
|
||||
value="reprocess"
|
||||
onClick={(e) => onReprocess(transcriptId)(e)}
|
||||
onClick={() => onReprocess(transcriptId)}
|
||||
>
|
||||
<LuRotateCw /> Reprocess
|
||||
</Menu.Item>
|
||||
<Menu.Item value="delete" onClick={(e) => onDelete(transcriptId)(e)}>
|
||||
<Menu.Item
|
||||
value="delete"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(transcriptId);
|
||||
}}
|
||||
>
|
||||
<LuTrash /> Delete
|
||||
</Menu.Item>
|
||||
</Menu.Content>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
import React from "react";
|
||||
import { Box, Table, Link, Flex, Spinner } 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";
|
||||
|
||||
interface TranscriptTableProps {
|
||||
transcripts: GetTranscriptMinimal[];
|
||||
onDelete: (transcriptId: string) => (e: any) => void;
|
||||
onReprocess: (transcriptId: string) => (e: any) => void;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export default function TranscriptTable({
|
||||
transcripts,
|
||||
onDelete,
|
||||
onReprocess,
|
||||
loading,
|
||||
}: TranscriptTableProps) {
|
||||
return (
|
||||
<Box display={{ base: "none", lg: "block" }} position="relative">
|
||||
{loading && (
|
||||
<Flex
|
||||
position="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
align="center"
|
||||
justify="center"
|
||||
>
|
||||
<Spinner size="xl" color="gray.700" />
|
||||
</Flex>
|
||||
)}
|
||||
<Box
|
||||
opacity={loading ? 0.9 : 1}
|
||||
pointerEvents={loading ? "none" : "auto"}
|
||||
transition="opacity 0.2s ease-in-out"
|
||||
>
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.ColumnHeader
|
||||
width="16px"
|
||||
fontWeight="600"
|
||||
></Table.ColumnHeader>
|
||||
<Table.ColumnHeader width="400px" fontWeight="600">
|
||||
Transcription Title
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader width="150px" fontWeight="600">
|
||||
Source
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader width="200px" fontWeight="600">
|
||||
Date
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader width="100px" fontWeight="600">
|
||||
Duration
|
||||
</Table.ColumnHeader>
|
||||
<Table.ColumnHeader
|
||||
width="50px"
|
||||
fontWeight="600"
|
||||
></Table.ColumnHeader>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{transcripts.map((item) => (
|
||||
<Table.Row key={item.id}>
|
||||
<Table.Cell>
|
||||
<TranscriptStatusIcon status={item.status} />
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<Link as={NextLink} href={`/transcripts/${item.id}`}>
|
||||
{item.title || "Unnamed Transcript"}
|
||||
</Link>
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
{item.source_kind === "room"
|
||||
? item.room_name
|
||||
: item.source_kind}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{formatLocalDate(item.created_at)}</Table.Cell>
|
||||
<Table.Cell>{formatTimeMs(item.duration)}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<TranscriptActionsMenu
|
||||
transcriptId={item.id}
|
||||
onDelete={onDelete}
|
||||
onReprocess={onReprocess}
|
||||
/>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
))}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +1,264 @@
|
||||
"use client";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Flex, Spinner, Heading, Text, Link } from "@chakra-ui/react";
|
||||
import useTranscriptList from "../transcripts/useTranscriptList";
|
||||
import {
|
||||
Flex,
|
||||
Spinner,
|
||||
Heading,
|
||||
Text,
|
||||
Link,
|
||||
Box,
|
||||
Stack,
|
||||
Input,
|
||||
Button,
|
||||
IconButton,
|
||||
} from "@chakra-ui/react";
|
||||
import {
|
||||
useQueryState,
|
||||
parseAsString,
|
||||
parseAsInteger,
|
||||
parseAsStringLiteral,
|
||||
} from "nuqs";
|
||||
import { LuX } from "react-icons/lu";
|
||||
import { useSearchTranscripts } from "../transcripts/useSearchTranscripts";
|
||||
import useSessionUser from "../../lib/useSessionUser";
|
||||
import { Room } from "../../api";
|
||||
import Pagination from "./_components/Pagination";
|
||||
import { Room, SourceKind, SearchResult, $SourceKind } from "../../api";
|
||||
import useApi from "../../lib/useApi";
|
||||
import { useError } from "../../(errors)/errorContext";
|
||||
import { SourceKind } from "../../api";
|
||||
import FilterSidebar from "./_components/FilterSidebar";
|
||||
import SearchBar from "./_components/SearchBar";
|
||||
import TranscriptTable from "./_components/TranscriptTable";
|
||||
import Pagination, {
|
||||
FIRST_PAGE,
|
||||
PaginationPage,
|
||||
parsePaginationPage,
|
||||
totalPages as getTotalPages,
|
||||
} from "./_components/Pagination";
|
||||
import TranscriptCards from "./_components/TranscriptCards";
|
||||
import DeleteTranscriptDialog from "./_components/DeleteTranscriptDialog";
|
||||
import { formatLocalDate } from "../../lib/time";
|
||||
import { RECORD_A_MEETING_URL } from "../../api/urls";
|
||||
|
||||
const SEARCH_FORM_QUERY_INPUT_NAME = "query" as const;
|
||||
|
||||
const usePrefetchRooms = (setRooms: (rooms: Room[]) => void): void => {
|
||||
const { setError } = useError();
|
||||
const api = useApi();
|
||||
useEffect(() => {
|
||||
if (!api) return;
|
||||
api
|
||||
.v1RoomsList({ page: 1 })
|
||||
.then((rooms) => setRooms(rooms.items))
|
||||
.catch((err) => setError(err, "There was an error fetching the rooms"));
|
||||
}, [api, setError]);
|
||||
};
|
||||
|
||||
const SearchForm: React.FC<{
|
||||
setPage: (page: PaginationPage) => void;
|
||||
sourceKind: SourceKind | null;
|
||||
roomId: string | null;
|
||||
setSourceKind: (sourceKind: SourceKind | null) => void;
|
||||
setRoomId: (roomId: string | null) => void;
|
||||
rooms: Room[];
|
||||
searchQuery: string | null;
|
||||
setSearchQuery: (query: string | null) => void;
|
||||
}> = ({
|
||||
setPage,
|
||||
sourceKind,
|
||||
roomId,
|
||||
setRoomId,
|
||||
setSourceKind,
|
||||
rooms,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
}) => {
|
||||
// to keep the search input controllable + more fine grained control (urlSearchQuery is updated on submits)
|
||||
const [searchInputValue, setSearchInputValue] = useState(searchQuery || "");
|
||||
const handleSearchQuerySubmit = async (d: FormData) => {
|
||||
await setSearchQuery((d.get(SEARCH_FORM_QUERY_INPUT_NAME) as string) || "");
|
||||
};
|
||||
|
||||
const handleClearSearch = () => {
|
||||
setSearchInputValue("");
|
||||
setSearchQuery(null);
|
||||
setPage(FIRST_PAGE);
|
||||
};
|
||||
return (
|
||||
<Stack gap={2}>
|
||||
<form action={handleSearchQuerySubmit}>
|
||||
<Flex alignItems="center">
|
||||
<Box position="relative" flex="1">
|
||||
<Input
|
||||
placeholder="Search transcriptions..."
|
||||
value={searchInputValue}
|
||||
onChange={(e) => setSearchInputValue(e.target.value)}
|
||||
name={SEARCH_FORM_QUERY_INPUT_NAME}
|
||||
pr={searchQuery ? "2.5rem" : undefined}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<IconButton
|
||||
aria-label="Clear search"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={handleClearSearch}
|
||||
position="absolute"
|
||||
right="0.25rem"
|
||||
top="50%"
|
||||
transform="translateY(-50%)"
|
||||
_hover={{ bg: "gray.100" }}
|
||||
>
|
||||
<LuX />
|
||||
</IconButton>
|
||||
)}
|
||||
</Box>
|
||||
<Button ml={2} type="submit">
|
||||
Search
|
||||
</Button>
|
||||
</Flex>
|
||||
</form>
|
||||
<UnderSearchFormFilterIndicators
|
||||
sourceKind={sourceKind}
|
||||
roomId={roomId}
|
||||
setSourceKind={setSourceKind}
|
||||
setRoomId={setRoomId}
|
||||
rooms={rooms}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
const UnderSearchFormFilterIndicators: React.FC<{
|
||||
sourceKind: SourceKind | null;
|
||||
roomId: string | null;
|
||||
setSourceKind: (sourceKind: SourceKind | null) => void;
|
||||
setRoomId: (roomId: string | null) => void;
|
||||
rooms: Room[];
|
||||
}> = ({ sourceKind, roomId, setRoomId, setSourceKind, rooms }) => {
|
||||
return (
|
||||
<>
|
||||
{(sourceKind || roomId) && (
|
||||
<Flex gap={2} flexWrap="wrap" align="center">
|
||||
<Text fontSize="sm" color="gray.600">
|
||||
Active filters:
|
||||
</Text>
|
||||
{sourceKind && (
|
||||
<Flex
|
||||
align="center"
|
||||
px={2}
|
||||
py={1}
|
||||
bg="blue.100"
|
||||
borderRadius="md"
|
||||
fontSize="xs"
|
||||
gap={1}
|
||||
>
|
||||
<Text>
|
||||
{roomId
|
||||
? `Room: ${
|
||||
rooms.find((r) => r.id === roomId)?.name || roomId
|
||||
}`
|
||||
: `Source: ${sourceKind}`}
|
||||
</Text>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
minW="auto"
|
||||
h="auto"
|
||||
p="1px"
|
||||
onClick={() => {
|
||||
setSourceKind(null);
|
||||
// TODO questionable
|
||||
setRoomId(null);
|
||||
}}
|
||||
_hover={{ bg: "blue.200" }}
|
||||
aria-label="Clear filter"
|
||||
>
|
||||
<LuX size={14} />
|
||||
</Button>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const EmptyResult: React.FC<{
|
||||
searchQuery: string;
|
||||
}> = ({ searchQuery }) => {
|
||||
return (
|
||||
<Flex flexDir="column" alignItems="center" justifyContent="center" py={8}>
|
||||
<Text textAlign="center">
|
||||
{searchQuery
|
||||
? `No results found for "${searchQuery}". Try adjusting your search terms.`
|
||||
: "No transcripts found, but you can "}
|
||||
{!searchQuery && (
|
||||
<>
|
||||
<Link href={RECORD_A_MEETING_URL} color="blue.500">
|
||||
record a meeting
|
||||
</Link>
|
||||
{" to get started."}
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default function TranscriptBrowser() {
|
||||
const [selectedSourceKind, setSelectedSourceKind] =
|
||||
useState<SourceKind | null>(null);
|
||||
const [selectedRoomId, setSelectedRoomId] = useState("");
|
||||
const [rooms, setRooms] = useState<Room[]>([]);
|
||||
const [page, setPage] = useState(1);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const { loading, response, refetch } = useTranscriptList(
|
||||
page,
|
||||
selectedSourceKind,
|
||||
selectedRoomId,
|
||||
searchTerm,
|
||||
const [urlSearchQuery, setUrlSearchQuery] = useQueryState(
|
||||
"q",
|
||||
parseAsString.withDefault("").withOptions({ shallow: false }),
|
||||
);
|
||||
|
||||
const [urlSourceKind, setUrlSourceKind] = useQueryState(
|
||||
"source",
|
||||
parseAsStringLiteral($SourceKind.enum).withOptions({
|
||||
shallow: false,
|
||||
}),
|
||||
);
|
||||
const [urlRoomId, setUrlRoomId] = useQueryState(
|
||||
"room",
|
||||
parseAsString.withDefault("").withOptions({ shallow: false }),
|
||||
);
|
||||
|
||||
const [urlPage, setPage] = useQueryState(
|
||||
"page",
|
||||
parseAsInteger.withDefault(1).withOptions({ shallow: false }),
|
||||
);
|
||||
|
||||
const [page, _setSafePage] = useState(FIRST_PAGE);
|
||||
|
||||
// safety net
|
||||
useEffect(() => {
|
||||
const maybePage = parsePaginationPage(urlPage);
|
||||
if ("error" in maybePage) {
|
||||
setPage(FIRST_PAGE).then(() => {
|
||||
/*may be called n times we dont care*/
|
||||
});
|
||||
return;
|
||||
}
|
||||
_setSafePage(maybePage.value);
|
||||
}, [urlPage]);
|
||||
|
||||
const [rooms, setRooms] = useState<Room[]>([]);
|
||||
|
||||
const pageSize = 20;
|
||||
const {
|
||||
results,
|
||||
totalCount: totalResults,
|
||||
isLoading,
|
||||
reload,
|
||||
} = useSearchTranscripts(
|
||||
urlSearchQuery,
|
||||
{
|
||||
roomIds: urlRoomId ? [urlRoomId] : null,
|
||||
sourceKind: urlSourceKind,
|
||||
},
|
||||
{
|
||||
pageSize,
|
||||
page,
|
||||
},
|
||||
);
|
||||
|
||||
const totalPages = getTotalPages(totalResults, pageSize);
|
||||
|
||||
const userName = useSessionUser().name;
|
||||
const [deletionLoading, setDeletionLoading] = useState(false);
|
||||
const api = useApi();
|
||||
@@ -35,37 +266,73 @@ export default function TranscriptBrowser() {
|
||||
const cancelRef = React.useRef(null);
|
||||
const [transcriptToDeleteId, setTranscriptToDeleteId] =
|
||||
React.useState<string>();
|
||||
const [deletedItemIds, setDeletedItemIds] = React.useState<string[]>();
|
||||
|
||||
useEffect(() => {
|
||||
setDeletedItemIds([]);
|
||||
}, [page, response]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) return;
|
||||
api
|
||||
.v1RoomsList({ page: 1 })
|
||||
.then((rooms) => setRooms(rooms.items))
|
||||
.catch((err) => setError(err, "There was an error fetching the rooms"));
|
||||
}, [api]);
|
||||
usePrefetchRooms(setRooms);
|
||||
|
||||
const handleFilterTranscripts = (
|
||||
sourceKind: SourceKind | null,
|
||||
roomId: string,
|
||||
) => {
|
||||
setSelectedSourceKind(sourceKind);
|
||||
setSelectedRoomId(roomId);
|
||||
setUrlSourceKind(sourceKind);
|
||||
setUrlRoomId(roomId);
|
||||
setPage(1);
|
||||
};
|
||||
|
||||
const handleSearch = (searchTerm: string) => {
|
||||
setPage(1);
|
||||
setSearchTerm(searchTerm);
|
||||
setSelectedSourceKind(null);
|
||||
setSelectedRoomId("");
|
||||
const onCloseDeletion = () => setTranscriptToDeleteId(undefined);
|
||||
|
||||
const confirmDeleteTranscript = (transcriptId: string) => {
|
||||
if (!api || deletionLoading) return;
|
||||
setDeletionLoading(true);
|
||||
api
|
||||
.v1TranscriptDelete({ transcriptId })
|
||||
.then(() => {
|
||||
setDeletionLoading(false);
|
||||
onCloseDeletion();
|
||||
reload();
|
||||
})
|
||||
.catch((err) => {
|
||||
setDeletionLoading(false);
|
||||
setError(err, "There was an error deleting the transcript");
|
||||
});
|
||||
};
|
||||
|
||||
if (loading && !response)
|
||||
const handleProcessTranscript = (transcriptId: string) => {
|
||||
if (!api) {
|
||||
console.error("API not available on handleProcessTranscript");
|
||||
return;
|
||||
}
|
||||
api
|
||||
.v1TranscriptProcess({ transcriptId })
|
||||
.then((result) => {
|
||||
const status =
|
||||
result && typeof result === "object" && "status" in result
|
||||
? (result as { status: string }).status
|
||||
: undefined;
|
||||
if (status === "already running") {
|
||||
setError(
|
||||
new Error("Processing is already running, please wait"),
|
||||
"Processing is already running, please wait",
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err, "There was an error processing the transcript");
|
||||
});
|
||||
};
|
||||
|
||||
const transcriptToDelete = results?.find(
|
||||
(i) => i.id === transcriptToDeleteId,
|
||||
);
|
||||
const dialogTitle = transcriptToDelete?.title || "Unnamed Transcript";
|
||||
const dialogDate = transcriptToDelete?.created_at
|
||||
? formatLocalDate(transcriptToDelete.created_at)
|
||||
: undefined;
|
||||
const dialogSource =
|
||||
transcriptToDelete?.source_kind === "room" && transcriptToDelete?.room_id
|
||||
? transcriptToDelete.room_name || transcriptToDelete.room_id
|
||||
: transcriptToDelete?.source_kind;
|
||||
|
||||
if (isLoading && results.length === 0) {
|
||||
return (
|
||||
<Flex
|
||||
flexDir="column"
|
||||
@@ -76,82 +343,7 @@ export default function TranscriptBrowser() {
|
||||
<Spinner size="xl" />
|
||||
</Flex>
|
||||
);
|
||||
|
||||
if (!loading && !response)
|
||||
return (
|
||||
<Flex
|
||||
flexDir="column"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
h="100%"
|
||||
>
|
||||
<Text>
|
||||
No transcripts found, but you can
|
||||
<Link href="/transcripts/new" className="underline">
|
||||
record a meeting
|
||||
</Link>
|
||||
to get started.
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
const onCloseDeletion = () => setTranscriptToDeleteId(undefined);
|
||||
|
||||
const confirmDeleteTranscript = (transcriptId: string) => {
|
||||
if (!api || deletionLoading) return;
|
||||
setDeletionLoading(true);
|
||||
api
|
||||
.v1TranscriptDelete({ transcriptId })
|
||||
.then(() => {
|
||||
refetch();
|
||||
setDeletionLoading(false);
|
||||
onCloseDeletion();
|
||||
setDeletedItemIds((prev) =>
|
||||
prev ? [...prev, transcriptId] : [transcriptId],
|
||||
);
|
||||
})
|
||||
.catch((err) => {
|
||||
setDeletionLoading(false);
|
||||
setError(err, "There was an error deleting the transcript");
|
||||
});
|
||||
};
|
||||
|
||||
const handleDeleteTranscript = (transcriptId: string) => (e: any) => {
|
||||
e?.stopPropagation?.();
|
||||
setTranscriptToDeleteId(transcriptId);
|
||||
};
|
||||
|
||||
const handleProcessTranscript = (transcriptId) => (e) => {
|
||||
if (api) {
|
||||
api
|
||||
.v1TranscriptProcess({ transcriptId })
|
||||
.then((result) => {
|
||||
const status = (result as any).status;
|
||||
if (status === "already running") {
|
||||
setError(
|
||||
new Error("Processing is already running, please wait"),
|
||||
"Processing is already running, please wait",
|
||||
);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err, "There was an error processing the transcript");
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const transcriptToDelete = response?.items?.find(
|
||||
(i) => i.id === transcriptToDeleteId,
|
||||
);
|
||||
const dialogTitle = transcriptToDelete?.title || "Unnamed Transcript";
|
||||
const dialogDate = transcriptToDelete?.created_at
|
||||
? formatLocalDate(transcriptToDelete.created_at)
|
||||
: undefined;
|
||||
const dialogSource = transcriptToDelete
|
||||
? transcriptToDelete.source_kind === "room"
|
||||
? transcriptToDelete.room_name || undefined
|
||||
: transcriptToDelete.source_kind
|
||||
: undefined;
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex
|
||||
@@ -168,15 +360,15 @@ export default function TranscriptBrowser() {
|
||||
>
|
||||
<Heading size="lg">
|
||||
{userName ? `${userName}'s Transcriptions` : "Your Transcriptions"}{" "}
|
||||
{loading || (deletionLoading && <Spinner size="sm" />)}
|
||||
{(isLoading || deletionLoading) && <Spinner size="sm" />}
|
||||
</Heading>
|
||||
</Flex>
|
||||
|
||||
<Flex flexDir={{ base: "column", md: "row" }}>
|
||||
<FilterSidebar
|
||||
rooms={rooms}
|
||||
selectedSourceKind={selectedSourceKind}
|
||||
selectedRoomId={selectedRoomId}
|
||||
selectedSourceKind={urlSourceKind}
|
||||
selectedRoomId={urlRoomId}
|
||||
onFilterChange={handleFilterTranscripts}
|
||||
/>
|
||||
|
||||
@@ -188,25 +380,37 @@ export default function TranscriptBrowser() {
|
||||
gap={4}
|
||||
px={{ base: 0, md: 4 }}
|
||||
>
|
||||
<SearchBar onSearch={handleSearch} />
|
||||
<Pagination
|
||||
page={page}
|
||||
<SearchForm
|
||||
setPage={setPage}
|
||||
total={response?.total || 0}
|
||||
size={response?.size || 0}
|
||||
/>
|
||||
<TranscriptTable
|
||||
transcripts={response?.items || []}
|
||||
onDelete={handleDeleteTranscript}
|
||||
onReprocess={handleProcessTranscript}
|
||||
loading={loading}
|
||||
sourceKind={urlSourceKind}
|
||||
roomId={urlRoomId}
|
||||
searchQuery={urlSearchQuery}
|
||||
setSearchQuery={setUrlSearchQuery}
|
||||
setSourceKind={setUrlSourceKind}
|
||||
setRoomId={setUrlRoomId}
|
||||
rooms={rooms}
|
||||
/>
|
||||
|
||||
{totalPages > 1 ? (
|
||||
<Pagination
|
||||
page={page}
|
||||
setPage={setPage}
|
||||
total={totalResults}
|
||||
size={pageSize}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<TranscriptCards
|
||||
transcripts={response?.items || []}
|
||||
onDelete={handleDeleteTranscript}
|
||||
results={results}
|
||||
query={urlSearchQuery}
|
||||
isLoading={isLoading}
|
||||
onDelete={setTranscriptToDeleteId}
|
||||
onReprocess={handleProcessTranscript}
|
||||
loading={loading}
|
||||
/>
|
||||
|
||||
{!isLoading && results.length === 0 && (
|
||||
<EmptyResult searchQuery={urlSearchQuery} />
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import Image from "next/image";
|
||||
import About from "../(aboutAndPrivacy)/about";
|
||||
import Privacy from "../(aboutAndPrivacy)/privacy";
|
||||
import UserInfo from "../(auth)/userInfo";
|
||||
import { RECORD_A_MEETING_URL } from "../api/urls";
|
||||
|
||||
export default async function AppLayout({
|
||||
children,
|
||||
@@ -53,7 +54,7 @@ export default async function AppLayout({
|
||||
{/* Text link on the right */}
|
||||
<Link
|
||||
as={NextLink}
|
||||
href="/transcripts/new"
|
||||
href={RECORD_A_MEETING_URL}
|
||||
className="font-light px-2"
|
||||
>
|
||||
Create
|
||||
|
||||
@@ -19,6 +19,7 @@ import useApi from "../../lib/useApi";
|
||||
import useRoomList from "./useRoomList";
|
||||
import { ApiError, Room } from "../../api";
|
||||
import { RoomList } from "./_components/RoomList";
|
||||
import { PaginationPage } from "../browse/_components/Pagination";
|
||||
|
||||
interface SelectOption {
|
||||
label: string;
|
||||
@@ -75,8 +76,9 @@ export default function RoomsList() {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editRoomId, setEditRoomId] = useState("");
|
||||
const api = useApi();
|
||||
// TODO seems to be no setPage calls
|
||||
const [page, setPage] = useState<number>(1);
|
||||
const { loading, response, refetch } = useRoomList(page);
|
||||
const { loading, response, refetch } = useRoomList(PaginationPage(page));
|
||||
const [streams, setStreams] = useState<Stream[]>([]);
|
||||
const [topics, setTopics] = useState<Topic[]>([]);
|
||||
const [nameError, setNameError] = useState("");
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
||||
import { useError } from "../../(errors)/errorContext";
|
||||
import useApi from "../../lib/useApi";
|
||||
import { Page_Room_ } from "../../api";
|
||||
import { PaginationPage } from "../browse/_components/Pagination";
|
||||
|
||||
type RoomList = {
|
||||
response: Page_Room_ | null;
|
||||
@@ -11,7 +12,7 @@ type RoomList = {
|
||||
};
|
||||
|
||||
//always protected
|
||||
const useRoomList = (page: number): RoomList => {
|
||||
const useRoomList = (page: PaginationPage): RoomList => {
|
||||
const [response, setResponse] = useState<Page_Room_ | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setErrorState] = useState<Error | null>(null);
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
[
|
||||
{
|
||||
"id": "27c07e49-d7a3-4b86-905c-f1a047366f91",
|
||||
"title": "Issue one",
|
||||
"summary": "The team discusses the first issue in the list",
|
||||
"timestamp": 0.0,
|
||||
"transcript": "",
|
||||
"duration": 33,
|
||||
"segments": [
|
||||
{
|
||||
"text": "Let's start with issue one, Alice you've been working on that, can you give an update ?",
|
||||
"start": 0.0,
|
||||
"speaker": 0
|
||||
},
|
||||
{
|
||||
"text": "Yes, I've run into an issue with the task system but Bob helped me out and I have a POC ready, should I present it now ?",
|
||||
"start": 0.38,
|
||||
"speaker": 1
|
||||
},
|
||||
{
|
||||
"text": "Yeah, I had to modify the task system because it didn't account for incoming blobs",
|
||||
"start": 4.5,
|
||||
"speaker": 2
|
||||
},
|
||||
{
|
||||
"text": "Cool, yeah lets see it",
|
||||
"start": 5.96,
|
||||
"speaker": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
@@ -11,6 +11,7 @@ import useWebRTC from "./useWebRTC";
|
||||
import useAudioDevice from "./useAudioDevice";
|
||||
import { Box, Flex, IconButton, Menu, RadioGroup } from "@chakra-ui/react";
|
||||
import { LuScreenShare, LuMic, LuPlay, LuCircleStop } from "react-icons/lu";
|
||||
import { RECORD_A_MEETING_URL } from "../../api/urls";
|
||||
|
||||
type RecorderProps = {
|
||||
transcriptId: string;
|
||||
@@ -46,7 +47,7 @@ export default function Recorder(props: RecorderProps) {
|
||||
location.href = "";
|
||||
break;
|
||||
case ",":
|
||||
location.href = "/transcripts/new";
|
||||
location.href = RECORD_A_MEETING_URL;
|
||||
break;
|
||||
case "!":
|
||||
if (record.isRecording()) return;
|
||||
|
||||
123
www/app/(app)/transcripts/useSearchTranscripts.ts
Normal file
123
www/app/(app)/transcripts/useSearchTranscripts.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
// this hook is not great, we want to substitute it with a proper state management solution that is also not re-invention
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { SearchResult, SourceKind } from "../../api";
|
||||
import useApi from "../../lib/useApi";
|
||||
import {
|
||||
PaginationPage,
|
||||
paginationPageTo0Based,
|
||||
} from "../browse/_components/Pagination";
|
||||
|
||||
interface SearchFilters {
|
||||
roomIds: readonly string[] | null;
|
||||
sourceKind: SourceKind | null;
|
||||
}
|
||||
|
||||
const EMPTY_SEARCH_FILTERS: SearchFilters = {
|
||||
roomIds: null,
|
||||
sourceKind: null,
|
||||
};
|
||||
|
||||
type UseSearchTranscriptsOptions = {
|
||||
pageSize: number;
|
||||
page: PaginationPage;
|
||||
};
|
||||
|
||||
interface UseSearchTranscriptsReturn {
|
||||
results: SearchResult[];
|
||||
totalCount: number;
|
||||
isLoading: boolean;
|
||||
error: unknown;
|
||||
reload: () => void;
|
||||
}
|
||||
|
||||
function hashEffectFilters(filters: SearchFilters): string {
|
||||
return JSON.stringify(filters);
|
||||
}
|
||||
|
||||
export function useSearchTranscripts(
|
||||
query: string = "",
|
||||
filters: SearchFilters = EMPTY_SEARCH_FILTERS,
|
||||
options: UseSearchTranscriptsOptions = {
|
||||
pageSize: 20,
|
||||
page: PaginationPage(1),
|
||||
},
|
||||
): UseSearchTranscriptsReturn {
|
||||
const { pageSize, page } = options;
|
||||
|
||||
const [reloadCount, setReloadCount] = useState(0);
|
||||
|
||||
const api = useApi();
|
||||
const abortControllerRef = useRef<AbortController>();
|
||||
|
||||
const [data, setData] = useState<{ results: SearchResult[]; total: number }>({
|
||||
results: [],
|
||||
total: 0,
|
||||
});
|
||||
const [error, setError] = useState<any>();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const filterHash = hashEffectFilters(filters);
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) {
|
||||
setData({ results: [], total: 0 });
|
||||
setError(undefined);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
const performSearch = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const response = await api.v1TranscriptsSearch({
|
||||
q: query || "",
|
||||
limit: pageSize,
|
||||
offset: paginationPageTo0Based(page) * pageSize,
|
||||
roomId: filters.roomIds?.[0],
|
||||
sourceKind: filters.sourceKind || undefined,
|
||||
});
|
||||
|
||||
if (abortController.signal.aborted) return;
|
||||
setData(response);
|
||||
setError(undefined);
|
||||
} catch (err: unknown) {
|
||||
if ((err as Error).name === "AbortError") {
|
||||
return;
|
||||
}
|
||||
if (abortController.signal.aborted) {
|
||||
console.error("Aborted search but error", err);
|
||||
return;
|
||||
}
|
||||
|
||||
setError(err);
|
||||
} finally {
|
||||
if (!abortController.signal.aborted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
performSearch().then(() => {});
|
||||
|
||||
return () => {
|
||||
abortController.abort();
|
||||
};
|
||||
}, [api, query, page, filterHash, pageSize, reloadCount]);
|
||||
|
||||
return {
|
||||
results: data.results,
|
||||
totalCount: data.total,
|
||||
isLoading,
|
||||
error,
|
||||
reload: () => setReloadCount(reloadCount + 1),
|
||||
};
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useError } from "../../(errors)/errorContext";
|
||||
import useApi from "../../lib/useApi";
|
||||
import { Page_GetTranscriptMinimal_, SourceKind } from "../../api";
|
||||
|
||||
type TranscriptList = {
|
||||
response: Page_GetTranscriptMinimal_ | null;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
refetch: () => void;
|
||||
};
|
||||
|
||||
const useTranscriptList = (
|
||||
page: number,
|
||||
sourceKind: SourceKind | null,
|
||||
roomId: string | null,
|
||||
searchTerm: string | null,
|
||||
): TranscriptList => {
|
||||
const [response, setResponse] = useState<Page_GetTranscriptMinimal_ | null>(
|
||||
null,
|
||||
);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setErrorState] = useState<Error | null>(null);
|
||||
const { setError } = useError();
|
||||
const api = useApi();
|
||||
const [refetchCount, setRefetchCount] = useState(0);
|
||||
|
||||
const refetch = () => {
|
||||
setLoading(true);
|
||||
setRefetchCount(refetchCount + 1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!api) return;
|
||||
setLoading(true);
|
||||
api
|
||||
.v1TranscriptsList({
|
||||
page,
|
||||
sourceKind,
|
||||
roomId,
|
||||
searchTerm,
|
||||
size: 10,
|
||||
})
|
||||
.then((response) => {
|
||||
setResponse(response);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
setResponse(null);
|
||||
setLoading(false);
|
||||
setError(err);
|
||||
setErrorState(err);
|
||||
});
|
||||
}, [api, page, refetchCount, roomId, searchTerm, sourceKind]);
|
||||
|
||||
return { response, loading, error, refetch };
|
||||
};
|
||||
|
||||
export default useTranscriptList;
|
||||
@@ -1002,7 +1002,7 @@ export const $SearchResponse = {
|
||||
},
|
||||
query: {
|
||||
type: "string",
|
||||
minLength: 1,
|
||||
minLength: 0,
|
||||
title: "Query",
|
||||
description: "Search query text",
|
||||
},
|
||||
@@ -1065,6 +1065,20 @@ export const $SearchResult = {
|
||||
],
|
||||
title: "Room Id",
|
||||
},
|
||||
room_name: {
|
||||
anyOf: [
|
||||
{
|
||||
type: "string",
|
||||
},
|
||||
{
|
||||
type: "null",
|
||||
},
|
||||
],
|
||||
title: "Room Name",
|
||||
},
|
||||
source_kind: {
|
||||
$ref: "#/components/schemas/SourceKind",
|
||||
},
|
||||
created_at: {
|
||||
type: "string",
|
||||
title: "Created At",
|
||||
@@ -1101,10 +1115,18 @@ export const $SearchResult = {
|
||||
title: "Search Snippets",
|
||||
description: "Text snippets around search matches",
|
||||
},
|
||||
total_match_count: {
|
||||
type: "integer",
|
||||
minimum: 0,
|
||||
title: "Total Match Count",
|
||||
description: "Total number of matches found in the transcript",
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
type: "object",
|
||||
required: [
|
||||
"id",
|
||||
"source_kind",
|
||||
"created_at",
|
||||
"status",
|
||||
"rank",
|
||||
|
||||
@@ -286,6 +286,7 @@ export class DefaultService {
|
||||
* @param data.limit Results per page
|
||||
* @param data.offset Number of results to skip
|
||||
* @param data.roomId
|
||||
* @param data.sourceKind
|
||||
* @returns SearchResponse Successful Response
|
||||
* @throws ApiError
|
||||
*/
|
||||
@@ -300,6 +301,7 @@ export class DefaultService {
|
||||
limit: data.limit,
|
||||
offset: data.offset,
|
||||
room_id: data.roomId,
|
||||
source_kind: data.sourceKind,
|
||||
},
|
||||
errors: {
|
||||
422: "Validation Error",
|
||||
|
||||
@@ -209,6 +209,8 @@ export type SearchResult = {
|
||||
title?: string | null;
|
||||
user_id?: string | null;
|
||||
room_id?: string | null;
|
||||
room_name?: string | null;
|
||||
source_kind: SourceKind;
|
||||
created_at: string;
|
||||
status: string;
|
||||
rank: number;
|
||||
@@ -220,6 +222,10 @@ export type SearchResult = {
|
||||
* Text snippets around search matches
|
||||
*/
|
||||
search_snippets: Array<string>;
|
||||
/**
|
||||
* Total number of matches found in the transcript
|
||||
*/
|
||||
total_match_count?: number;
|
||||
};
|
||||
|
||||
export type SourceKind = "room" | "live" | "file";
|
||||
@@ -407,6 +413,7 @@ export type V1TranscriptsSearchData = {
|
||||
*/
|
||||
q: string;
|
||||
roomId?: string | null;
|
||||
sourceKind?: SourceKind | null;
|
||||
};
|
||||
|
||||
export type V1TranscriptsSearchResponse = SearchResponse;
|
||||
|
||||
2
www/app/api/urls.ts
Normal file
2
www/app/api/urls.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// TODO better connection with generated schema; it's duplication
|
||||
export const RECORD_A_MEETING_URL = "/transcripts/new" as const;
|
||||
62
www/app/lib/textHighlight.tsx
Normal file
62
www/app/lib/textHighlight.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Text highlighting and text fragment generation utilities
|
||||
* Used for search result highlighting and deep linking with Chrome Text Fragments
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
export interface HighlightResult {
|
||||
text: string;
|
||||
matches: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes special regex characters in a string
|
||||
*/
|
||||
function escapeRegex(str: string): string {
|
||||
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
export const highlightMatches = (
|
||||
text: string,
|
||||
query: string,
|
||||
): { match: string; index: number }[] => {
|
||||
if (!query || !text) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const queryWords = query.trim().split(/\s+/);
|
||||
|
||||
const regex = new RegExp(
|
||||
`(${queryWords.map((word) => escapeRegex(word)).join("|")})`,
|
||||
"gi",
|
||||
);
|
||||
|
||||
return Array.from(text.matchAll(regex)).map((result) => ({
|
||||
match: result[0],
|
||||
index: result.index!,
|
||||
}));
|
||||
};
|
||||
|
||||
export function findFirstHighlight(text: string, query: string): string | null {
|
||||
const matches = highlightMatches(text, query);
|
||||
if (matches.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return matches[0].match;
|
||||
}
|
||||
|
||||
export function generateTextFragment(
|
||||
text: string,
|
||||
query: string,
|
||||
): {
|
||||
k: ":~:text";
|
||||
v: string;
|
||||
} | null {
|
||||
const firstMatch = findFirstHighlight(text, query);
|
||||
if (!firstMatch) return null;
|
||||
return {
|
||||
k: ":~:text",
|
||||
v: firstMatch,
|
||||
};
|
||||
}
|
||||
@@ -136,3 +136,10 @@ export function extractDomain(url) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function assertExists<T>(value: T | null | undefined, err?: string): T {
|
||||
if (value === null || value === undefined) {
|
||||
throw new Error(`Assertion failed: ${err ?? "value is null or undefined"}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
import { redirect } from "next/navigation";
|
||||
import { RECORD_A_MEETING_URL } from "./api/urls";
|
||||
|
||||
export default function Index() {
|
||||
redirect("/transcripts/new");
|
||||
redirect(RECORD_A_MEETING_URL);
|
||||
}
|
||||
|
||||
@@ -5,14 +5,17 @@ import system from "./styles/theme";
|
||||
|
||||
import { WherebyProvider } from "@whereby.com/browser-sdk/react";
|
||||
import { Toaster } from "./components/ui/toaster";
|
||||
import { NuqsAdapter } from "nuqs/adapters/next/app";
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ChakraProvider value={system}>
|
||||
<WherebyProvider>
|
||||
{children}
|
||||
<Toaster />
|
||||
</WherebyProvider>
|
||||
</ChakraProvider>
|
||||
<NuqsAdapter>
|
||||
<ChakraProvider value={system}>
|
||||
<WherebyProvider>
|
||||
{children}
|
||||
<Toaster />
|
||||
</WherebyProvider>
|
||||
</ChakraProvider>
|
||||
</NuqsAdapter>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user