Compare commits

...

5 Commits

Author SHA1 Message Date
d77b5611f8 chore(main): release 0.2.1 (#499) 2025-07-17 20:19:56 -06:00
fc38345d65 fix: separate browsing page into different components, limit to 10 by default (#498)
* feat: limit the amount of transcripts to 10 by default

* feat: separate page into different component, greatly improving the
loading and reactivity

* fix: current implementation immediately invokes the onDelete and
onReprocess

From pr-agent-monadical: Suggestion: The current implementation
immediately invokes the onDelete and onReprocess functions when the
component renders, rather than when the menu items are clicked. This can
cause unexpected behavior and potential memory leaks. Use callback
functions that only execute when the menu items are actually clicked.
[possible issue, importance: 9]
2025-07-17 20:18:00 -06:00
5a1d662dc4 chore(main): release 0.2.0 (#497) 2025-07-17 15:55:19 -06:00
033bd4bc48 feat: improve transcript listing with room_id (#496)
Added a new field in transcript for room_id, and set room_id/meeting_id
in a transcript now. Use this field to list the transcripts. URL is now
very fast.
2025-07-17 15:43:36 -06:00
0eb670ca19 fix: don't attempt to load waveform/mp3 if audio was deleted (#495) 2025-07-17 10:04:59 -06:00
17 changed files with 733 additions and 461 deletions

View File

@@ -1,5 +1,24 @@
# Changelog
## [0.2.1](https://github.com/Monadical-SAS/reflector/compare/v0.2.0...v0.2.1) (2025-07-18)
### Bug Fixes
* separate browsing page into different components, limit to 10 by default ([#498](https://github.com/Monadical-SAS/reflector/issues/498)) ([c752da6](https://github.com/Monadical-SAS/reflector/commit/c752da6b97c96318aff079a5b2a6eceadfbfcad1))
## [0.2.0](https://github.com/Monadical-SAS/reflector/compare/0.1.1...v0.2.0) (2025-07-17)
### Features
* improve transcript listing with room_id ([#496](https://github.com/Monadical-SAS/reflector/issues/496)) ([d2b5de5](https://github.com/Monadical-SAS/reflector/commit/d2b5de543fc0617fc220caa6a8a290e4040cb10b))
### Bug Fixes
* don't attempt to load waveform/mp3 if audio was deleted ([#495](https://github.com/Monadical-SAS/reflector/issues/495)) ([f4578a7](https://github.com/Monadical-SAS/reflector/commit/f4578a743fd0f20312fbd242fa9cccdfaeb20a9e))
## [0.1.1](https://github.com/Monadical-SAS/reflector/compare/0.1.0...v0.1.1) (2025-07-17)

View File

@@ -0,0 +1,59 @@
"""Add room_id to transcript
Revision ID: d7fbb74b673b
Revises: a9c9c229ee36
Create Date: 2025-07-17 12:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "d7fbb74b673b"
down_revision: Union[str, None] = "a9c9c229ee36"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Add room_id column to transcript table
op.add_column("transcript", sa.Column("room_id", sa.String(), nullable=True))
# Add index for room_id for better query performance
op.create_index("idx_transcript_room_id", "transcript", ["room_id"])
# Populate room_id for existing ROOM-type transcripts
# This joins through recording -> meeting -> room to get the room_id
op.execute("""
UPDATE transcript AS t
SET room_id = r.id
FROM recording rec
JOIN meeting m ON rec.meeting_id = m.id
JOIN room r ON m.room_id = r.id
WHERE t.recording_id = rec.id
AND t.source_kind = 'room'
AND t.room_id IS NULL
""")
# Fix missing meeting_id for ROOM-type transcripts
# The meeting_id field exists but was never populated
op.execute("""
UPDATE transcript AS t
SET meeting_id = rec.meeting_id
FROM recording rec
WHERE t.recording_id = rec.id
AND t.source_kind = 'room'
AND t.meeting_id IS NULL
AND rec.meeting_id IS NOT NULL
""")
def downgrade() -> None:
# Drop the index first
op.drop_index("idx_transcript_room_id", "transcript")
# Drop the room_id column
op.drop_column("transcript", "room_id")

View File

@@ -74,10 +74,12 @@ transcripts = sqlalchemy.Table(
# the main "audio deleted" is the presence of the audio itself / consents not-given
# same field could've been in recording/meeting, and it's maybe even ok to dupe it at need
sqlalchemy.Column("audio_deleted", sqlalchemy.Boolean),
sqlalchemy.Column("room_id", sqlalchemy.String),
sqlalchemy.Index("idx_transcript_recording_id", "recording_id"),
sqlalchemy.Index("idx_transcript_user_id", "user_id"),
sqlalchemy.Index("idx_transcript_created_at", "created_at"),
sqlalchemy.Index("idx_transcript_user_id_recording_id", "user_id", "recording_id"),
sqlalchemy.Index("idx_transcript_room_id", "room_id"),
)
@@ -167,6 +169,7 @@ class Transcript(BaseModel):
zulip_message_id: int | None = None
source_kind: SourceKind
audio_deleted: bool | None = None
room_id: str | None = None
@field_serializer("created_at", when_used="json")
def serialize_datetime(self, dt: datetime) -> str:
@@ -331,17 +334,10 @@ class TranscriptController:
- `room_id`: filter transcripts by room ID
- `search_term`: filter transcripts by search term
"""
from reflector.db.meetings import meetings
from reflector.db.recordings import recordings
from reflector.db.rooms import rooms
query = (
transcripts.select()
.join(
recordings, transcripts.c.recording_id == recordings.c.id, isouter=True
)
.join(meetings, recordings.c.meeting_id == meetings.c.id, isouter=True)
.join(rooms, meetings.c.room_id == rooms.c.id, isouter=True)
query = transcripts.select().join(
rooms, transcripts.c.room_id == rooms.c.id, isouter=True
)
if user_id:
@@ -355,7 +351,7 @@ class TranscriptController:
query = query.where(transcripts.c.source_kind == source_kind)
if room_id:
query = query.where(rooms.c.id == room_id)
query = query.where(transcripts.c.room_id == room_id)
if search_term:
query = query.where(transcripts.c.title.ilike(f"%{search_term}%"))
@@ -368,7 +364,6 @@ class TranscriptController:
query = query.with_only_columns(
transcript_columns
+ [
rooms.c.id.label("room_id"),
rooms.c.name.label("room_name"),
]
)
@@ -419,6 +414,22 @@ class TranscriptController:
return None
return Transcript(**result)
async def get_by_room_id(self, room_id: str, **kwargs) -> list[Transcript]:
"""
Get transcripts by room_id (direct access without joins)
"""
query = transcripts.select().where(transcripts.c.room_id == room_id)
if "user_id" in kwargs:
query = query.where(transcripts.c.user_id == kwargs["user_id"])
if "order_by" in kwargs:
order_by = kwargs["order_by"]
field = getattr(transcripts.c, order_by[1:])
if order_by.startswith("-"):
field = field.desc()
query = query.order_by(field)
results = await database.fetch_all(query)
return [Transcript(**result) for result in results]
async def get_by_id_for_http(
self,
transcript_id: str,
@@ -469,6 +480,8 @@ class TranscriptController:
user_id: str | None = None,
recording_id: str | None = None,
share_mode: str = "private",
meeting_id: str | None = None,
room_id: str | None = None,
):
"""
Add a new transcript
@@ -481,6 +494,8 @@ class TranscriptController:
user_id=user_id,
recording_id=recording_id,
share_mode=share_mode,
meeting_id=meeting_id,
room_id=room_id,
)
query = transcripts.insert().values(**transcript.model_dump())
await database.execute(query)

View File

@@ -3,7 +3,7 @@ from typing import Annotated, Literal, Optional
import reflector.auth as auth
from fastapi import APIRouter, Depends, HTTPException
from fastapi_pagination import Page
from fastapi_pagination import Page, Params
from fastapi_pagination.ext.databases import paginate
from jose import jwt
from pydantic import BaseModel, Field, field_serializer
@@ -128,6 +128,7 @@ async def transcripts_list(
order_by="-created_at",
return_query=True,
),
params=Params(size=10),
)

View File

@@ -101,6 +101,8 @@ async def process_recording(bucket_name: str, object_key: str):
user_id=room.user_id,
recording_id=recording.id,
share_mode="public",
meeting_id=meeting.id,
room_id=room.id,
)
_, extension = os.path.splitext(object_key)

View File

@@ -0,0 +1,51 @@
import React from "react";
import {
Button,
AlertDialog,
AlertDialogOverlay,
AlertDialogContent,
AlertDialogHeader,
AlertDialogBody,
AlertDialogFooter,
} from "@chakra-ui/react";
interface DeleteTranscriptDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
cancelRef: React.RefObject<any>;
}
export default function DeleteTranscriptDialog({
isOpen,
onClose,
onConfirm,
cancelRef,
}: DeleteTranscriptDialogProps) {
return (
<AlertDialog
isOpen={isOpen}
leastDestructiveRef={cancelRef}
onClose={onClose}
>
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
Delete Transcript
</AlertDialogHeader>
<AlertDialogBody>
Are you sure? You can't undo this action afterwards.
</AlertDialogBody>
<AlertDialogFooter>
<Button ref={cancelRef} onClick={onClose}>
Cancel
</Button>
<Button colorScheme="red" onClick={onConfirm} ml={3}>
Delete
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
);
}

View File

@@ -0,0 +1,120 @@
import React from "react";
import { Box, Stack, Link, Heading, Divider } from "@chakra-ui/react";
import NextLink from "next/link";
import { Room, SourceKind } from "../../../api";
interface FilterSidebarProps {
rooms: Room[];
selectedSourceKind: SourceKind | null;
selectedRoomId: string;
onFilterChange: (sourceKind: SourceKind | null, roomId: string) => void;
}
export default function FilterSidebar({
rooms,
selectedSourceKind,
selectedRoomId,
onFilterChange,
}: FilterSidebarProps) {
const myRooms = rooms.filter((room) => !room.is_shared);
const sharedRooms = rooms.filter((room) => room.is_shared);
return (
<Box w={{ base: "full", md: "300px" }} p={4} bg="gray.100">
<Stack spacing={3}>
<Link
as={NextLink}
href="#"
onClick={() => onFilterChange(null, "")}
color={selectedSourceKind === null ? "blue.500" : "gray.600"}
_hover={{ color: "blue.300" }}
fontWeight={selectedSourceKind === null ? "bold" : "normal"}
>
All Transcripts
</Link>
<Divider />
{myRooms.length > 0 && (
<>
<Heading size="sm">My Rooms</Heading>
{myRooms.map((room) => (
<Link
key={room.id}
as={NextLink}
href="#"
onClick={() => onFilterChange("room", room.id)}
color={
selectedSourceKind === "room" && selectedRoomId === room.id
? "blue.500"
: "gray.600"
}
_hover={{ color: "blue.300" }}
fontWeight={
selectedSourceKind === "room" && selectedRoomId === room.id
? "bold"
: "normal"
}
ml={4}
>
{room.name}
</Link>
))}
</>
)}
{sharedRooms.length > 0 && (
<>
<Heading size="sm">Shared Rooms</Heading>
{sharedRooms.map((room) => (
<Link
key={room.id}
as={NextLink}
href="#"
onClick={() => onFilterChange("room", room.id)}
color={
selectedSourceKind === "room" && selectedRoomId === room.id
? "blue.500"
: "gray.600"
}
_hover={{ color: "blue.300" }}
fontWeight={
selectedSourceKind === "room" && selectedRoomId === room.id
? "bold"
: "normal"
}
ml={4}
>
{room.name}
</Link>
))}
</>
)}
<Divider />
<Link
as={NextLink}
href="#"
onClick={() => onFilterChange("live", "")}
color={selectedSourceKind === "live" ? "blue.500" : "gray.600"}
_hover={{ color: "blue.300" }}
fontWeight={selectedSourceKind === "live" ? "bold" : "normal"}
>
Live Transcripts
</Link>
<Link
as={NextLink}
href="#"
onClick={() => onFilterChange("file", "")}
color={selectedSourceKind === "file" ? "blue.500" : "gray.600"}
_hover={{ color: "blue.300" }}
fontWeight={selectedSourceKind === "file" ? "bold" : "normal"}
>
Uploaded Files
</Link>
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,34 @@
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 mb={4} alignItems="center">
<Input
placeholder="Search transcriptions..."
value={searchInputValue}
onChange={(e) => setSearchInputValue(e.target.value)}
onKeyDown={handleKeyDown}
/>
<Button ml={2} onClick={handleSearch}>
Search
</Button>
</Flex>
);
}

View File

@@ -0,0 +1,39 @@
import React from "react";
import {
Menu,
MenuButton,
MenuList,
MenuItem,
IconButton,
Icon,
} from "@chakra-ui/react";
import { FaEllipsisVertical } from "react-icons/fa6";
interface TranscriptActionsMenuProps {
transcriptId: string;
onDelete: (transcriptId: string) => (e: any) => void;
onReprocess: (transcriptId: string) => (e: any) => void;
}
export default function TranscriptActionsMenu({
transcriptId,
onDelete,
onReprocess,
}: TranscriptActionsMenuProps) {
return (
<Menu closeOnSelect={true} isLazy={true}>
<MenuButton
as={IconButton}
icon={<Icon as={FaEllipsisVertical} />}
variant="outline"
aria-label="Options"
/>
<MenuList>
<MenuItem onClick={(e) => onDelete(transcriptId)(e)}>Delete</MenuItem>
<MenuItem onClick={(e) => onReprocess(transcriptId)(e)}>
Reprocess
</MenuItem>
</MenuList>
</Menu>
);
}

View File

@@ -0,0 +1,81 @@
import React from "react";
import { Box, Stack, Text, Flex, Link, 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 TranscriptCardsProps {
transcripts: GetTranscriptMinimal[];
onDelete: (transcriptId: string) => (e: any) => void;
onReprocess: (transcriptId: string) => (e: any) => void;
loading?: boolean;
}
export default function TranscriptCards({
transcripts,
onDelete,
onReprocess,
loading,
}: TranscriptCardsProps) {
return (
<Box display={{ base: "block", md: "none" }} position="relative">
{loading && (
<Flex
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
bg="rgba(255, 255, 255, 0.8)"
zIndex={10}
align="center"
justify="center"
>
<Spinner size="xl" color="gray.700" thickness="4px" />
</Flex>
)}
<Box
opacity={loading ? 0.9 : 1}
pointerEvents={loading ? "none" : "auto"}
transition="opacity 0.2s ease-in-out"
>
<Stack spacing={2}>
{transcripts.map((item) => (
<Box key={item.id} borderWidth={1} p={4} borderRadius="md">
<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="bold"
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>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,62 @@
import React from "react";
import { Icon, Tooltip } from "@chakra-ui/react";
import {
FaCheck,
FaTrash,
FaStar,
FaMicrophone,
FaGear,
} from "react-icons/fa6";
interface TranscriptStatusIconProps {
status: string;
}
export default function TranscriptStatusIcon({
status,
}: TranscriptStatusIconProps) {
switch (status) {
case "ended":
return (
<Tooltip label="Processing done">
<span>
<Icon color="green" as={FaCheck} />
</span>
</Tooltip>
);
case "error":
return (
<Tooltip label="Processing error">
<span>
<Icon color="red.500" as={FaTrash} />
</span>
</Tooltip>
);
case "idle":
return (
<Tooltip label="New meeting, no recording">
<span>
<Icon color="yellow.500" as={FaStar} />
</span>
</Tooltip>
);
case "processing":
return (
<Tooltip label="Processing in progress">
<span>
<Icon color="gray.500" as={FaGear} />
</span>
</Tooltip>
);
case "recording":
return (
<Tooltip label="Recording in progress">
<span>
<Icon color="blue.500" as={FaMicrophone} />
</span>
</Tooltip>
);
default:
return null;
}
}

View File

@@ -0,0 +1,99 @@
import React from "react";
import {
Box,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
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", md: "block" }} position="relative">
{loading && (
<Flex
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
bg="rgba(255, 255, 255, 0.8)"
zIndex={10}
align="center"
justify="center"
>
<Spinner size="xl" color="gray.700" thickness="4px" />
</Flex>
)}
<Box
opacity={loading ? 0.9 : 1}
pointerEvents={loading ? "none" : "auto"}
transition="opacity 0.2s ease-in-out"
>
<Table colorScheme="gray">
<Thead>
<Tr>
<Th pl={12} width="400px">
Transcription Title
</Th>
<Th width="150px">Source</Th>
<Th width="200px">Date</Th>
<Th width="100px">Duration</Th>
<Th width="50px"></Th>
</Tr>
</Thead>
<Tbody>
{transcripts.map((item) => (
<Tr key={item.id}>
<Td>
<Flex alignItems="start">
<TranscriptStatusIcon status={item.status} />
<Link as={NextLink} href={`/transcripts/${item.id}`} ml={2}>
{item.title || "Unnamed Transcript"}
</Link>
</Flex>
</Td>
<Td>
{item.source_kind === "room"
? item.room_name
: item.source_kind}
</Td>
<Td>{formatLocalDate(item.created_at)}</Td>
<Td>{formatTimeMs(item.duration)}</Td>
<Td>
<TranscriptActionsMenu
transcriptId={item.id}
onDelete={onDelete}
onReprocess={onReprocess}
/>
</Td>
</Tr>
))}
</Tbody>
</Table>
</Box>
</Box>
);
}

View File

@@ -1,55 +1,18 @@
"use client";
import React, { useState, useEffect } from "react";
import {
Flex,
Spinner,
Heading,
Box,
Text,
Link,
Stack,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
Button,
Divider,
Input,
Icon,
Tooltip,
Menu,
MenuButton,
MenuList,
MenuItem,
IconButton,
AlertDialog,
AlertDialogOverlay,
AlertDialogContent,
AlertDialogHeader,
AlertDialogBody,
AlertDialogFooter,
Spacer,
} from "@chakra-ui/react";
import {
FaCheck,
FaTrash,
FaStar,
FaMicrophone,
FaGear,
FaEllipsisVertical,
FaArrowRotateRight,
} from "react-icons/fa6";
import { Flex, Spinner, Heading, Text, Link } from "@chakra-ui/react";
import useTranscriptList from "../transcripts/useTranscriptList";
import useSessionUser from "../../lib/useSessionUser";
import NextLink from "next/link";
import { Room, GetTranscriptMinimal } from "../../api";
import { Room } from "../../api";
import Pagination from "./pagination";
import { formatTimeMs, formatLocalDate } from "../../lib/time";
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 TranscriptCards from "./_components/TranscriptCards";
import DeleteTranscriptDialog from "./_components/DeleteTranscriptDialog";
export default function TranscriptBrowser() {
const [selectedSourceKind, setSelectedSourceKind] =
@@ -58,7 +21,6 @@ export default function TranscriptBrowser() {
const [rooms, setRooms] = useState<Room[]>([]);
const [page, setPage] = useState(1);
const [searchTerm, setSearchTerm] = useState("");
const [searchInputValue, setSearchInputValue] = useState("");
const { loading, response, refetch } = useTranscriptList(
page,
selectedSourceKind,
@@ -74,9 +36,6 @@ export default function TranscriptBrowser() {
React.useState<string>();
const [deletedItemIds, setDeletedItemIds] = React.useState<string[]>();
const myRooms = rooms.filter((room) => !room.is_shared);
const sharedRooms = rooms.filter((room) => room.is_shared);
useEffect(() => {
setDeletedItemIds([]);
}, [page, response]);
@@ -103,20 +62,14 @@ export default function TranscriptBrowser() {
refetch();
};
const handleSearch = () => {
const handleSearch = (searchTerm: string) => {
setPage(1);
setSearchTerm(searchInputValue);
setSearchTerm(searchTerm);
setSelectedSourceKind(null);
setSelectedRoomId("");
refetch();
};
const handleKeyDown = (event) => {
if (event.key === "Enter") {
handleSearch();
}
};
if (loading && !response)
return (
<Flex flexDir="column" align="center" justify="center" h="100%">
@@ -195,324 +148,42 @@ export default function TranscriptBrowser() {
</Flex>
<Flex flexDir={{ base: "column", md: "row" }}>
<Box w={{ base: "full", md: "300px" }} p={4} bg="gray.100">
<Stack spacing={3}>
<Link
as={NextLink}
href="#"
onClick={() => handleFilterTranscripts(null, "")}
color={selectedSourceKind === null ? "blue.500" : "gray.600"}
_hover={{ color: "blue.300" }}
fontWeight={selectedSourceKind === null ? "bold" : "normal"}
>
All Transcripts
</Link>
<Divider />
{myRooms.length > 0 && (
<>
<Heading size="sm">My Rooms</Heading>
{myRooms.map((room) => (
<Link
key={room.id}
as={NextLink}
href="#"
onClick={() => handleFilterTranscripts("room", room.id)}
color={
selectedSourceKind === "room" &&
selectedRoomId === room.id
? "blue.500"
: "gray.600"
}
_hover={{ color: "blue.300" }}
fontWeight={
selectedSourceKind === "room" &&
selectedRoomId === room.id
? "bold"
: "normal"
}
ml={4}
>
{room.name}
</Link>
))}
</>
)}
{sharedRooms.length > 0 && (
<>
<Heading size="sm">Shared Rooms</Heading>
{sharedRooms.map((room) => (
<Link
key={room.id}
as={NextLink}
href="#"
onClick={() => handleFilterTranscripts("room", room.id)}
color={
selectedSourceKind === "room" &&
selectedRoomId === room.id
? "blue.500"
: "gray.600"
}
_hover={{ color: "blue.300" }}
fontWeight={
selectedSourceKind === "room" &&
selectedRoomId === room.id
? "bold"
: "normal"
}
ml={4}
>
{room.name}
</Link>
))}
</>
)}
<Divider />
<Link
as={NextLink}
href="#"
onClick={() => handleFilterTranscripts("live", "")}
color={selectedSourceKind === "live" ? "blue.500" : "gray.600"}
_hover={{ color: "blue.300" }}
fontWeight={selectedSourceKind === "live" ? "bold" : "normal"}
>
Live Transcripts
</Link>
<Link
as={NextLink}
href="#"
onClick={() => handleFilterTranscripts("file", "")}
color={selectedSourceKind === "file" ? "blue.500" : "gray.600"}
_hover={{ color: "blue.300" }}
fontWeight={selectedSourceKind === "file" ? "bold" : "normal"}
>
Uploaded Files
</Link>
</Stack>
</Box>
<FilterSidebar
rooms={rooms}
selectedSourceKind={selectedSourceKind}
selectedRoomId={selectedRoomId}
onFilterChange={handleFilterTranscripts}
/>
<Flex flexDir="column" flex="1" p={4} gap={4}>
<Flex mb={4} alignItems="center">
<Input
placeholder="Search transcriptions..."
value={searchInputValue}
onChange={(e) => setSearchInputValue(e.target.value)}
onKeyDown={handleKeyDown}
/>
<Button ml={2} onClick={handleSearch}>
Search
</Button>
</Flex>
<SearchBar onSearch={handleSearch} />
<Pagination
page={page}
setPage={setPage}
total={response?.total || 0}
size={response?.size || 0}
/>
<Box display={{ base: "none", md: "block" }}>
<Table colorScheme="gray">
<Thead>
<Tr>
<Th pl={12} width="400px">
Transcription Title
</Th>
<Th width="150px">Source</Th>
<Th width="200px">Date</Th>
<Th width="100px">Duration</Th>
<Th width="50px"></Th>
</Tr>
</Thead>
<Tbody>
{response?.items?.map((item: GetTranscriptMinimal) => (
<Tr key={item.id}>
<Td>
<Flex alignItems="start">
{item.status === "ended" && (
<Tooltip label="Processing done">
<span>
<Icon color="green" as={FaCheck} />
</span>
</Tooltip>
)}
{item.status === "error" && (
<Tooltip label="Processing error">
<span>
<Icon color="red.500" as={FaTrash} />
</span>
</Tooltip>
)}
{item.status === "idle" && (
<Tooltip label="New meeting, no recording">
<span>
<Icon color="yellow.500" as={FaStar} />
</span>
</Tooltip>
)}
{item.status === "processing" && (
<Tooltip label="Processing in progress">
<span>
<Icon color="gray.500" as={FaGear} />
</span>
</Tooltip>
)}
{item.status === "recording" && (
<Tooltip label="Recording in progress">
<span>
<Icon color="blue.500" as={FaMicrophone} />
</span>
</Tooltip>
)}
<Link
as={NextLink}
href={`/transcripts/${item.id}`}
ml={2}
>
{item.title || "Unnamed Transcript"}
</Link>
</Flex>
</Td>
<Td>
{item.source_kind === "room"
? item.room_name
: item.source_kind}
</Td>
<Td>{formatLocalDate(item.created_at)}</Td>
<Td>{formatTimeMs(item.duration)}</Td>
<Td>
<Menu closeOnSelect={true}>
<MenuButton
as={IconButton}
icon={<Icon as={FaEllipsisVertical} />}
variant="outline"
aria-label="Options"
/>
<MenuList>
<MenuItem onClick={handleDeleteTranscript(item.id)}>
Delete
</MenuItem>
<MenuItem onClick={handleProcessTranscript(item.id)}>
Reprocess
</MenuItem>
</MenuList>
</Menu>
</Td>
</Tr>
))}
</Tbody>
</Table>
</Box>
<Box display={{ base: "block", md: "none" }}>
<Stack spacing={2}>
{response?.items?.map((item: GetTranscriptMinimal) => (
<Box key={item.id} borderWidth={1} p={4} borderRadius="md">
<Flex justify="space-between" alignItems="flex-start" gap="2">
<Box>
{item.status === "ended" && (
<Tooltip label="Processing done">
<span>
<Icon color="green" as={FaCheck} />
</span>
</Tooltip>
)}
{item.status === "error" && (
<Tooltip label="Processing error">
<span>
<Icon color="red.500" as={FaTrash} />
</span>
</Tooltip>
)}
{item.status === "idle" && (
<Tooltip label="New meeting, no recording">
<span>
<Icon color="yellow.500" as={FaStar} />
</span>
</Tooltip>
)}
{item.status === "processing" && (
<Tooltip label="Processing in progress">
<span>
<Icon color="gray.500" as={FaGear} />
</span>
</Tooltip>
)}
{item.status === "recording" && (
<Tooltip label="Recording in progress">
<span>
<Icon color="blue.500" as={FaMicrophone} />
</span>
</Tooltip>
)}
</Box>
<Box flex="1">
<Text fontWeight="bold">
{item.title || "Unnamed Transcript"}
</Text>
<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>
<Menu>
<MenuButton
as={IconButton}
icon={<Icon as={FaEllipsisVertical} />}
variant="outline"
aria-label="Options"
/>
<MenuList>
<MenuItem onClick={handleDeleteTranscript(item.id)}>
Delete
</MenuItem>
<MenuItem onClick={handleProcessTranscript(item.id)}>
Reprocess
</MenuItem>
</MenuList>
</Menu>
</Flex>
</Box>
))}
</Stack>
</Box>
<TranscriptTable
transcripts={response?.items || []}
onDelete={handleDeleteTranscript}
onReprocess={handleProcessTranscript}
loading={loading}
/>
<TranscriptCards
transcripts={response?.items || []}
onDelete={handleDeleteTranscript}
onReprocess={handleProcessTranscript}
loading={loading}
/>
</Flex>
</Flex>
<AlertDialog
<DeleteTranscriptDialog
isOpen={!!transcriptToDeleteId}
leastDestructiveRef={cancelRef}
onClose={onCloseDeletion}
>
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
Delete Transcript
</AlertDialogHeader>
<AlertDialogBody>
Are you sure? You can't undo this action afterwards.
</AlertDialogBody>
<AlertDialogFooter>
<Button ref={cancelRef} onClick={onCloseDeletion}>
Cancel
</Button>
<Button
colorScheme="red"
onClick={handleDeleteTranscript(transcriptToDeleteId)}
ml={3}
>
Delete
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
onConfirm={() => handleDeleteTranscript(transcriptToDeleteId)(null)}
cancelRef={cancelRef}
/>
</Flex>
);
}

View File

@@ -12,7 +12,7 @@ import FinalSummary from "./finalSummary";
import TranscriptTitle from "../transcriptTitle";
import Player from "../player";
import { useRouter } from "next/navigation";
import { Flex, Grid, GridItem, Skeleton, Text } from "@chakra-ui/react";
import { Box, Flex, Grid, GridItem, Skeleton, Text } from "@chakra-ui/react";
type TranscriptDetails = {
params: {
@@ -29,10 +29,13 @@ export default function TranscriptDetails(details: TranscriptDetails) {
const transcriptStatus = transcript.response?.status;
const waiting = statusToRedirect.includes(transcriptStatus || "");
const topics = useTopics(transcriptId);
const waveform = useWaveform(transcriptId, waiting);
const useActiveTopic = useState<Topic | null>(null);
const mp3 = useMp3(transcriptId, waiting);
const topics = useTopics(transcriptId);
const waveform = useWaveform(
transcriptId,
waiting || mp3.loading || mp3.audioDeleted === true,
);
const useActiveTopic = useState<Topic | null>(null);
useEffect(() => {
if (waiting) {
@@ -76,23 +79,24 @@ export default function TranscriptDetails(details: TranscriptDetails) {
mt={4}
mb={4}
>
{waveform.waveform &&
mp3.media &&
!mp3.audioDeleted &&
topics.topics ? (
<Player
topics={topics?.topics}
useActiveTopic={useActiveTopic}
waveform={waveform.waveform}
media={mp3.media}
mediaDuration={transcript.response.duration}
/>
) : waveform.error ? (
<div>error loading this recording</div>
) : mp3.audioDeleted ? (
<div>Audio was deleted</div>
) : (
<Skeleton h={14} />
{!mp3.audioDeleted && (
<>
{waveform.waveform && mp3.media && topics.topics ? (
<Player
topics={topics?.topics}
useActiveTopic={useActiveTopic}
waveform={waveform.waveform}
media={mp3.media}
mediaDuration={transcript.response.duration}
/>
) : !mp3.loading && (waveform.error || mp3.error) ? (
<Box p={4} bg="red.100" borderRadius="md">
<Text>Error loading this recording</Text>
</Box>
) : (
<Skeleton h={14} />
)}
</>
)}
<Grid
templateColumns={{ base: "minmax(0, 1fr)", md: "repeat(2, 1fr)" }}
@@ -108,19 +112,24 @@ export default function TranscriptDetails(details: TranscriptDetails) {
borderColor={"gray.bg"}
borderRadius={8}
>
<GridItem
display="flex"
flexDir="row"
alignItems={"center"}
colSpan={{ base: 1, md: 2 }}
>
<TranscriptTitle
title={transcript.response.title || "Unnamed Transcript"}
transcriptId={transcriptId}
onUpdate={(newTitle) => {
transcript.reload();
}}
/>
<GridItem colSpan={{ base: 1, md: 2 }}>
<Flex direction="column" gap={0}>
<Flex alignItems="center" gap={2}>
<TranscriptTitle
title={transcript.response.title || "Unnamed Transcript"}
transcriptId={transcriptId}
onUpdate={(newTitle) => {
transcript.reload();
}}
/>
</Flex>
{mp3.audioDeleted && (
<Text fontSize="xs" color="gray.600" fontStyle="italic">
No audio is available because one or more participants didn't
consent to keep the audio
</Text>
)}
</Flex>
</GridItem>
<TopicList
topics={topics.topics || []}

View File

@@ -55,8 +55,8 @@ export function TopicList({
};
useEffect(() => {
if (activeTopic) scrollToTopic();
}, [activeTopic]);
if (activeTopic && autoscroll) scrollToTopic();
}, [activeTopic, autoscroll]);
// scroll top is not rounded, heights are, so exact match won't work.
// https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight#determine_if_an_element_has_been_totally_scrolled
@@ -105,8 +105,10 @@ export function TopicList({
const requireLogin = featureEnabled("requireLogin");
useEffect(() => {
setActiveTopic(topics[topics.length - 1]);
}, [topics]);
if (autoscroll) {
setActiveTopic(topics[topics.length - 1]);
}
}, [topics, autoscroll]);
return (
<Flex

View File

@@ -54,62 +54,65 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
useEffect(() => {
if (!transcriptId || !api || later) return;
let deleted: boolean | null = null;
let stopped = false;
let audioElement: HTMLAudioElement | null = null;
let handleCanPlay: (() => void) | null = null;
let handleError: (() => void) | null = null;
setTranscriptMetadataLoading(true);
const audioElement = document.createElement("audio");
audioElement.src = `${api_url}/v1/transcripts/${transcriptId}/audio/mp3`;
audioElement.crossOrigin = "anonymous";
audioElement.preload = "auto";
const handleCanPlay = () => {
if (deleted) {
console.error(
"Illegal state: audio supposed to be deleted, but was loaded",
);
return;
}
setAudioLoading(false);
setAudioLoadingError(null);
};
const handleError = () => {
setAudioLoading(false);
if (deleted) {
// we arrived here earlier, ignore
return;
}
setAudioLoadingError("Failed to load audio");
};
audioElement.addEventListener("canplay", handleCanPlay);
audioElement.addEventListener("error", handleError);
setMedia(audioElement);
setAudioLoading(true);
let stopped = false;
// Fetch transcript info in parallel
// First fetch transcript info to check if audio is deleted
api
.v1TranscriptGet({ transcriptId })
.then((transcript) => {
if (stopped) return;
deleted = transcript.audio_deleted || false;
if (stopped) {
return;
}
const deleted = transcript.audio_deleted || false;
setAudioDeleted(deleted);
setTranscriptMetadataLoadingError(null);
if (deleted) {
// Audio is deleted, don't attempt to load it
setMedia(null);
setAudioLoadingError(null);
setAudioLoading(false);
return;
}
// Audio is not deleted, proceed to load it
audioElement = document.createElement("audio");
audioElement.src = `${api_url}/v1/transcripts/${transcriptId}/audio/mp3`;
audioElement.crossOrigin = "anonymous";
audioElement.preload = "auto";
handleCanPlay = () => {
if (stopped) return;
setAudioLoading(false);
setAudioLoadingError(null);
};
handleError = () => {
if (stopped) return;
setAudioLoading(false);
setAudioLoadingError("Failed to load audio");
};
audioElement.addEventListener("canplay", handleCanPlay);
audioElement.addEventListener("error", handleError);
if (!stopped) {
setMedia(audioElement);
}
// if deleted, media will or already returned error
})
.catch((error) => {
if (stopped) return;
console.error("Failed to fetch transcript:", error);
setAudioDeleted(null);
setTranscriptMetadataLoadingError(error.message);
setAudioLoading(false);
})
.finally(() => {
if (stopped) return;
@@ -118,10 +121,14 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
return () => {
stopped = true;
audioElement.removeEventListener("canplay", handleCanPlay);
audioElement.removeEventListener("error", handleError);
if (audioElement) {
audioElement.src = "";
if (handleCanPlay)
audioElement.removeEventListener("canplay", handleCanPlay);
if (handleError) audioElement.removeEventListener("error", handleError);
}
};
}, [transcriptId, !api, later, api_url]);
}, [transcriptId, api, later, api_url]);
const getNow = () => {
setLater(false);

View File

@@ -10,16 +10,22 @@ type AudioWaveFormResponse = {
error: Error | null;
};
const useWaveform = (id: string, waiting: boolean): AudioWaveFormResponse => {
const useWaveform = (id: string, skip: boolean): AudioWaveFormResponse => {
const [waveform, setWaveform] = useState<AudioWaveform | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [loading, setLoading] = useState<boolean>(false);
const [error, setErrorState] = useState<Error | null>(null);
const { setError } = useError();
const api = useApi();
useEffect(() => {
if (!id || !api || waiting) return;
if (!id || !api || skip) {
setLoading(false);
setErrorState(null);
setWaveform(null);
return;
}
setLoading(true);
setErrorState(null);
api
.v1TranscriptGetAudioWaveform({ transcriptId: id })
.then((result) => {
@@ -29,14 +35,9 @@ const useWaveform = (id: string, waiting: boolean): AudioWaveFormResponse => {
})
.catch((err) => {
setErrorState(err);
const shouldShowHuman = shouldShowError(err);
if (shouldShowHuman) {
setError(err, "There was an error loading the waveform");
} else {
setError(err);
}
setLoading(false);
});
}, [id, !api, waiting]);
}, [id, api, skip]);
return { waveform, loading, error };
};