Merge pull request #422 from Monadical-SAS/transcriptions-page-redesign

Transcriptions page redesign
This commit is contained in:
2024-10-04 18:30:18 +02:00
committed by GitHub
13 changed files with 723 additions and 208 deletions

View File

@@ -0,0 +1,48 @@
"""Add transcript source kind
Revision ID: 74b2b0236931
Revises: 0925da921477
Create Date: 2024-10-04 14:19:23.625447
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "74b2b0236931"
down_revision: Union[str, None] = "0925da921477"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"transcript",
sa.Column(
"source_kind",
sa.Enum("ROOM", "LIVE", "FILE", name="sourcekind"),
nullable=True,
),
)
op.execute(
"UPDATE transcript SET source_kind = 'room' WHERE meeting_id IS NOT NULL"
)
op.execute("UPDATE transcript SET source_kind = 'live' WHERE meeting_id IS NULL")
with op.batch_alter_table("transcript", schema=None) as batch_op:
batch_op.alter_column("source_kind", nullable=False)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("transcript", "source_kind")
# ### end Alembic commands ###

View File

@@ -0,0 +1,40 @@
"""Add shared rooms
Revision ID: a7122bc0b2ca
Revises: 74b2b0236931
Create Date: 2024-10-04 16:41:28.841889
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "a7122bc0b2ca"
down_revision: Union[str, None] = "74b2b0236931"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.add_column(
"room",
sa.Column(
"is_shared",
sa.Boolean(),
server_default=sa.text("0"),
nullable=False,
),
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column("room", "is_shared")
# ### end Alembic commands ###

View File

@@ -36,6 +36,9 @@ rooms = sqlalchemy.Table(
nullable=False, nullable=False,
server_default="automatic-2nd-participant", server_default="automatic-2nd-participant",
), ),
sqlalchemy.Column(
"is_shared", sqlalchemy.Boolean, nullable=False, server_default=false()
),
) )
@@ -53,6 +56,7 @@ class Room(BaseModel):
recording_trigger: Literal[ recording_trigger: Literal[
"none", "prompt", "automatic", "automatic-2nd-participant" "none", "prompt", "automatic", "automatic-2nd-participant"
] = "automatic-2nd-participant" ] = "automatic-2nd-participant"
is_shared: bool = False
class RoomController: class RoomController:
@@ -98,6 +102,7 @@ class RoomController:
room_mode: str, room_mode: str,
recording_type: str, recording_type: str,
recording_trigger: str, recording_trigger: str,
is_shared: bool,
): ):
""" """
Add a new room Add a new room
@@ -112,6 +117,7 @@ class RoomController:
room_mode=room_mode, room_mode=room_mode,
recording_type=recording_type, recording_type=recording_type,
recording_trigger=recording_trigger, recording_trigger=recording_trigger,
is_shared=is_shared,
) )
query = rooms.insert().values(**room.model_dump()) query = rooms.insert().values(**room.model_dump())
try: try:

View File

@@ -1,3 +1,4 @@
import enum
import json import json
import os import os
import shutil import shutil
@@ -14,8 +15,16 @@ from reflector.db import database, metadata
from reflector.processors.types import Word as ProcessorWord from reflector.processors.types import Word as ProcessorWord
from reflector.settings import settings from reflector.settings import settings
from reflector.storage import Storage from reflector.storage import Storage
from sqlalchemy import Enum
from sqlalchemy.sql import false from sqlalchemy.sql import false
class SourceKind(enum.StrEnum):
ROOM = enum.auto()
LIVE = enum.auto()
FILE = enum.auto()
transcripts = sqlalchemy.Table( transcripts = sqlalchemy.Table(
"transcript", "transcript",
metadata, metadata,
@@ -55,6 +64,11 @@ transcripts = sqlalchemy.Table(
sqlalchemy.String, sqlalchemy.String,
), ),
sqlalchemy.Column("zulip_message_id", sqlalchemy.Integer, nullable=True), sqlalchemy.Column("zulip_message_id", sqlalchemy.Integer, nullable=True),
sqlalchemy.Column(
"source_kind",
Enum(SourceKind, values_callable=lambda obj: [e.value for e in obj]),
nullable=False,
),
) )
@@ -152,6 +166,7 @@ class Transcript(BaseModel):
reviewed: bool = False reviewed: bool = False
meeting_id: str | None = None meeting_id: str | None = None
zulip_message_id: int | None = None zulip_message_id: int | None = None
source_kind: SourceKind
def add_event(self, event: str, data: BaseModel) -> TranscriptEvent: def add_event(self, event: str, data: BaseModel) -> TranscriptEvent:
ev = TranscriptEvent(event=event, data=data.model_dump()) ev = TranscriptEvent(event=event, data=data.model_dump())
@@ -291,6 +306,9 @@ class TranscriptController:
order_by: str | None = None, order_by: str | None = None,
filter_empty: bool | None = False, filter_empty: bool | None = False,
filter_recording: bool | None = False, filter_recording: bool | None = False,
source_kind: SourceKind | None = None,
room_id: str | None = None,
search_term: str | None = None,
return_query: bool = False, return_query: bool = False,
) -> list[Transcript]: ) -> list[Transcript]:
""" """
@@ -303,8 +321,39 @@ class TranscriptController:
- `order_by`: field to order by, e.g. "-created_at" - `order_by`: field to order by, e.g. "-created_at"
- `filter_empty`: filter out empty transcripts - `filter_empty`: filter out empty transcripts
- `filter_recording`: filter out transcripts that are currently recording - `filter_recording`: filter out transcripts that are currently recording
- `room_id`: filter transcripts by room ID
- `search_term`: filter transcripts by search term
""" """
query = transcripts.select().where(transcripts.c.user_id == user_id) from reflector.db.meetings import meetings
from reflector.db.rooms import rooms
query = (
transcripts.select()
.join(meetings, transcripts.c.meeting_id == meetings.c.id, isouter=True)
.join(rooms, meetings.c.room_id == rooms.c.id, isouter=True)
)
if user_id:
query = query.where(transcripts.c.user_id == user_id)
if source_kind:
query = query.where(transcripts.c.source_kind == source_kind)
if room_id:
query = query.where(rooms.c.id == room_id)
if search_term:
query = query.where(
transcripts.c.title.ilike(f"%{search_term}%")
) # Assuming there's a 'title' column
query = query.with_only_columns(
[
transcripts,
rooms.c.id.label("room_id"),
rooms.c.name.label("room_name"),
]
)
if order_by is not None: if order_by is not None:
field = getattr(transcripts.c, order_by[1:]) field = getattr(transcripts.c, order_by[1:])
@@ -392,6 +441,7 @@ class TranscriptController:
async def add( async def add(
self, self,
name: str, name: str,
source_kind: SourceKind,
source_language: str = "en", source_language: str = "en",
target_language: str = "en", target_language: str = "en",
user_id: str | None = None, user_id: str | None = None,
@@ -403,6 +453,7 @@ class TranscriptController:
""" """
transcript = Transcript( transcript = Transcript(
name=name, name=name,
source_kind=source_kind,
source_language=source_language, source_language=source_language,
target_language=target_language, target_language=target_language,
user_id=user_id, user_id=user_id,

View File

@@ -28,6 +28,7 @@ class Room(BaseModel):
room_mode: str room_mode: str
recording_type: str recording_type: str
recording_trigger: str recording_trigger: str
is_shared: bool
class Meeting(BaseModel): class Meeting(BaseModel):
@@ -49,6 +50,7 @@ class CreateRoom(BaseModel):
room_mode: str room_mode: str
recording_type: str recording_type: str
recording_trigger: str recording_trigger: str
is_shared: bool
class UpdateRoom(BaseModel): class UpdateRoom(BaseModel):
@@ -60,6 +62,7 @@ class UpdateRoom(BaseModel):
room_mode: str room_mode: str
recording_type: str recording_type: str
recording_trigger: str recording_trigger: str
is_shared: bool
class DeletionStatus(BaseModel): class DeletionStatus(BaseModel):
@@ -100,6 +103,7 @@ async def rooms_create(
room_mode=room.room_mode, room_mode=room.room_mode,
recording_type=room.recording_type, recording_type=room.recording_type,
recording_trigger=room.recording_trigger, recording_trigger=room.recording_trigger,
is_shared=room.is_shared,
) )

View File

@@ -9,6 +9,7 @@ from jose import jwt
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from reflector.db.migrate_user import migrate_user from reflector.db.migrate_user import migrate_user
from reflector.db.transcripts import ( from reflector.db.transcripts import (
SourceKind,
TranscriptParticipant, TranscriptParticipant,
TranscriptTopic, TranscriptTopic,
transcripts_controller, transcripts_controller,
@@ -59,6 +60,9 @@ class GetTranscript(BaseModel):
participants: list[TranscriptParticipant] | None participants: list[TranscriptParticipant] | None
reviewed: bool reviewed: bool
meeting_id: str | None meeting_id: str | None
source_kind: SourceKind
room_id: str | None = None
room_name: str | None = None
class CreateTranscript(BaseModel): class CreateTranscript(BaseModel):
@@ -85,6 +89,9 @@ class DeletionStatus(BaseModel):
@router.get("/transcripts", response_model=Page[GetTranscript]) @router.get("/transcripts", response_model=Page[GetTranscript])
async def transcripts_list( async def transcripts_list(
user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)], user: Annotated[Optional[auth.UserInfo], Depends(auth.current_user_optional)],
source_kind: SourceKind | None = None,
room_id: str | None = None,
search_term: str | None = None,
): ):
from reflector.db import database from reflector.db import database
@@ -101,6 +108,9 @@ async def transcripts_list(
database, database,
await transcripts_controller.get_all( await transcripts_controller.get_all(
user_id=user_id, user_id=user_id,
source_kind=SourceKind(source_kind) if source_kind else None,
room_id=room_id,
search_term=search_term,
order_by="-created_at", order_by="-created_at",
return_query=True, return_query=True,
), ),
@@ -115,6 +125,7 @@ async def transcripts_create(
user_id = user["sub"] if user else None user_id = user["sub"] if user else None
return await transcripts_controller.add( return await transcripts_controller.add(
info.name, info.name,
source_kind=SourceKind.LIVE,
source_language=info.source_language, source_language=info.source_language,
target_language=info.target_language, target_language=info.target_language,
user_id=user_id, user_id=user_id,

View File

@@ -8,7 +8,7 @@ import structlog
from celery import shared_task from celery import shared_task
from celery.utils.log import get_task_logger from celery.utils.log import get_task_logger
from reflector.db.meetings import meetings_controller from reflector.db.meetings import meetings_controller
from reflector.db.transcripts import transcripts_controller from reflector.db.transcripts import SourceKind, transcripts_controller
from reflector.pipelines.main_live_pipeline import asynctask, task_pipeline_process from reflector.pipelines.main_live_pipeline import asynctask, task_pipeline_process
from reflector.settings import settings from reflector.settings import settings
@@ -66,6 +66,7 @@ async def process_recording(bucket_name: str, object_key: str):
meeting = await meetings_controller.get_by_room_name(room_name) meeting = await meetings_controller.get_by_room_name(room_name)
transcript = await transcripts_controller.add( transcript = await transcripts_controller.add(
"", "",
source_kind=SourceKind.ROOM,
source_language="en", source_language="en",
target_language="en", target_language="en",
user_id=meeting.user_id, user_id=meeting.user_id,

View File

@@ -1,50 +1,70 @@
"use client"; "use client";
import React, { useEffect, useState } from "react"; import React, { useState, useEffect } from "react";
import { GetTranscript } from "../../api";
import Pagination from "./pagination";
import NextLink from "next/link";
import { FaArrowRotateRight, FaGear } from "react-icons/fa6";
import { FaCheck, FaTrash, FaStar, FaMicrophone } from "react-icons/fa";
import { MdError } from "react-icons/md";
import useTranscriptList from "../transcripts/useTranscriptList";
import { formatTimeMs } from "../../lib/time";
import useApi from "../../lib/useApi";
import { useError } from "../../(errors)/errorContext";
import { FaEllipsisVertical } from "react-icons/fa6";
import useSessionUser from "../../lib/useSessionUser";
import { import {
Flex, Flex,
Spinner, Spinner,
Heading, Heading,
Button, Box,
Card,
Link,
CardBody,
Stack,
Text, Text,
Link,
Stack,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
Button,
Divider,
Input,
Icon, Icon,
Grid, Tooltip,
IconButton,
Spacer,
Menu, Menu,
MenuButton, MenuButton,
MenuItem,
MenuList, MenuList,
MenuItem,
IconButton,
AlertDialog, AlertDialog,
AlertDialogOverlay, AlertDialogOverlay,
AlertDialogContent, AlertDialogContent,
AlertDialogHeader, AlertDialogHeader,
AlertDialogBody, AlertDialogBody,
AlertDialogFooter, AlertDialogFooter,
Tooltip, Spacer,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { PlusSquareIcon } from "@chakra-ui/icons"; import {
import { ExpandableText } from "../../lib/expandableText"; FaCheck,
FaTrash,
FaStar,
FaMicrophone,
FaGear,
FaEllipsisVertical,
FaArrowRotateRight,
} from "react-icons/fa6";
import useTranscriptList from "../transcripts/useTranscriptList";
import useSessionUser from "../../lib/useSessionUser";
import NextLink from "next/link";
import { Room, GetTranscript } from "../../api";
import Pagination from "./pagination";
import { formatTimeMs } from "../../lib/time";
import useApi from "../../lib/useApi";
import { useError } from "../../(errors)/errorContext";
import { SourceKind } from "../../api";
export default function TranscriptBrowser() { export default function TranscriptBrowser() {
const [page, setPage] = useState<number>(1); const [selectedSourceKind, setSelectedSourceKind] =
const { loading, response, refetch } = useTranscriptList(page); useState<SourceKind | null>(null);
const [selectedRoomId, setSelectedRoomId] = useState("");
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,
selectedRoomId,
searchTerm,
);
const userName = useSessionUser().name; const userName = useSessionUser().name;
const [deletionLoading, setDeletionLoading] = useState(false); const [deletionLoading, setDeletionLoading] = useState(false);
const api = useApi(); const api = useApi();
@@ -54,10 +74,48 @@ export default function TranscriptBrowser() {
React.useState<string>(); React.useState<string>();
const [deletedItemIds, setDeletedItemIds] = 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(() => { useEffect(() => {
setDeletedItemIds([]); setDeletedItemIds([]);
}, [page, response]); }, [page, response]);
useEffect(() => {
refetch();
}, [selectedRoomId, page, searchTerm]);
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]);
const handleFilterTranscripts = (
sourceKind: SourceKind | null,
roomId: string,
) => {
setSelectedSourceKind(sourceKind);
setSelectedRoomId(roomId);
setPage(1);
};
const handleSearch = () => {
setPage(1);
setSearchTerm(searchInputValue);
setSelectedSourceKind(null);
setSelectedRoomId("");
refetch();
};
const handleKeyDown = (event) => {
if (event.key === "Enter") {
handleSearch();
}
};
if (loading && !response) if (loading && !response)
return ( return (
<Flex flexDir="column" align="center" justify="center" h="100%"> <Flex flexDir="column" align="center" justify="center" h="100%">
@@ -77,6 +135,7 @@ export default function TranscriptBrowser() {
</Text> </Text>
</Flex> </Flex>
); );
const onCloseDeletion = () => setTranscriptToDeleteId(undefined); const onCloseDeletion = () => setTranscriptToDeleteId(undefined);
const handleDeleteTranscript = (transcriptId) => (e) => { const handleDeleteTranscript = (transcriptId) => (e) => {
@@ -88,7 +147,6 @@ export default function TranscriptBrowser() {
.then(() => { .then(() => {
refetch(); refetch();
setDeletionLoading(false); setDeletionLoading(false);
refetch();
onCloseDeletion(); onCloseDeletion();
setDeletedItemIds((deletedItemIds) => [ setDeletedItemIds((deletedItemIds) => [
deletedItemIds, deletedItemIds,
@@ -120,169 +178,203 @@ export default function TranscriptBrowser() {
}); });
} }
}; };
return ( return (
<Flex <Flex
maxW="container.xl"
flexDir="column" flexDir="column"
margin="auto" w={{ base: "full", md: "container.xl" }}
gap={2} mx="auto"
overflowY="auto" p={4}
minH="100%"
> >
<Flex <Flex flexDir="row" justify="space-between" align="center" mb={4}>
flexDir="row" <Heading size="md">
justify="flex-end" {userName ? `${userName}'s Transcriptions` : "Your Transcriptions"}{" "}
align="center" {loading || (deletionLoading && <Spinner size="sm" />)}
flexWrap={"wrap-reverse"} </Heading>
mt={4}
>
{userName ? (
<Heading size="md">{userName}'s Meetings</Heading>
) : (
<Heading size="md">Your meetings</Heading>
)}
{loading || (deletionLoading && <Spinner></Spinner>)}
<Spacer />
<Pagination
page={page}
setPage={setPage}
total={response?.total || 0}
size={response?.size || 0}
/>
</Flex> </Flex>
<Grid
templateColumns={{
base: "repeat(1, 1fr)",
md: "repeat(2, 1fr)",
lg: "repeat(3, 1fr)",
}}
gap={{
base: 2,
lg: 4,
}}
maxH="100%"
overflowY="auto"
mb="4"
>
{response?.items
.filter((i) => !deletedItemIds?.includes(i.id))
.map((item: GetTranscript) => (
<Card key={item.id} border="gray.light" variant="outline">
<CardBody>
<Flex align={"center"} gap={2}>
<Heading size="md">
<Link
as={NextLink}
href={`/transcripts/${item.id}`}
noOfLines={2}
>
{item.title || item.name || "Unnamed Transcript"}
</Link>
</Heading>
<Spacer /> <Flex flexDir={{ base: "column", md: "row" }}>
<Menu closeOnSelect={true}> <Box w={{ base: "full", md: "300px" }} p={4} bg="gray.100">
<MenuButton <Stack spacing={3}>
as={IconButton} <Link
icon={<FaEllipsisVertical />} as={NextLink}
aria-label="actions" href="#"
/> onClick={() => handleFilterTranscripts(null, "")}
<MenuList> color={selectedSourceKind === null ? "blue.500" : "gray.600"}
<MenuItem _hover={{ color: "blue.300" }}
isDisabled={deletionLoading} fontWeight={selectedSourceKind === null ? "bold" : "normal"}
onClick={() => setTranscriptToDeleteId(item.id)} >
icon={<FaTrash color={"red.500"} />} All Transcripts
> </Link>
Delete
</MenuItem>
<MenuItem
isDisabled={item.status === "idle"}
onClick={handleProcessTranscript(item.id)}
icon={<FaArrowRotateRight />}
>
Process
</MenuItem>
<AlertDialog
isOpen={transcriptToDeleteId === item.id}
leastDestructiveRef={cancelRef}
onClose={onCloseDeletion}
>
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
Delete{" "}
{item.title || item.name || "Unnamed Transcript"}
</AlertDialogHeader>
<AlertDialogBody> <Divider />
Are you sure? You can't undo this action
afterwards.
</AlertDialogBody>
<AlertDialogFooter> {myRooms.length > 0 && (
<Button ref={cancelRef} onClick={onCloseDeletion}> <>
Cancel <Heading size="sm">My Rooms</Heading>
</Button>
<Button {myRooms.map((room) => (
colorScheme="red" <Link
onClick={handleDeleteTranscript(item.id)} key={room.id}
ml={3} as={NextLink}
> href="#"
Delete onClick={() => handleFilterTranscripts("room", room.id)}
</Button> color={
</AlertDialogFooter> selectedSourceKind === "room" &&
</AlertDialogContent> selectedRoomId === room.id
</AlertDialogOverlay> ? "blue.500"
</AlertDialog> : "gray.600"
</MenuList> }
</Menu> _hover={{ color: "blue.300" }}
</Flex> fontWeight={
<Stack mt="4" spacing="2"> selectedSourceKind === "room" &&
<Flex align={"center"} gap={2}> selectedRoomId === room.id
{item.status == "ended" && ( ? "bold"
<Tooltip label="Processing done"> : "normal"
<span> }
<Icon color="green" as={FaCheck} /> ml={4}
</span> >
</Tooltip> {room.name}
)} </Link>
{item.status == "error" && ( ))}
<Tooltip label="Processing error"> </>
<span> )}
<Icon color="red.primary" as={MdError} />
</span> {sharedRooms.length > 0 && (
</Tooltip> <>
)} <Heading size="sm">Shared Rooms</Heading>
{item.status == "idle" && (
<Tooltip label="New meeting, no recording"> {sharedRooms.map((room) => (
<span> <Link
<Icon color="yellow.500" as={FaStar} /> key={room.id}
</span> as={NextLink}
</Tooltip> href="#"
)} onClick={() => handleFilterTranscripts("room", room.id)}
{item.status == "processing" && ( color={
<Tooltip label="Processing in progress"> selectedSourceKind === "room" &&
<span> selectedRoomId === room.id
<Icon ? "blue.500"
color="grey.primary" : "gray.600"
as={FaGear} }
transition={"all 2s ease"} _hover={{ color: "blue.300" }}
transform={"rotate(0deg)"} fontWeight={
_hover={{ transform: "rotate(360deg)" }} selectedSourceKind === "room" &&
/> selectedRoomId === room.id
</span> ? "bold"
</Tooltip> : "normal"
)} }
{item.status == "recording" && ( ml={4}
<Tooltip label="Recording in progress"> >
<span> {room.name}
<Icon color="blue.primary" as={FaMicrophone} /> </Link>
</span> ))}
</Tooltip> </>
)} )}
<Text fontSize="small">
<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>
<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>
<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: GetTranscript) => (
<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>
{new Date(item.created_at).toLocaleString("en-US", { {new Date(item.created_at).toLocaleString("en-US", {
year: "numeric", year: "numeric",
month: "long", month: "long",
@@ -290,18 +382,146 @@ export default function TranscriptBrowser() {
hour: "numeric", hour: "numeric",
minute: "numeric", minute: "numeric",
})} })}
{"\u00A0"}-{"\u00A0"} </Td>
{formatTimeMs(item.duration)} <Td>{formatTimeMs(item.duration)}</Td>
</Text> <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: GetTranscript) => (
<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: {new Date(item.created_at).toLocaleString()}
</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> </Flex>
<ExpandableText noOfLines={5}> </Box>
{item.short_summary} ))}
</ExpandableText> </Stack>
</Stack> </Box>
</CardBody> <Pagination
</Card> page={page}
))} setPage={setPage}
</Grid> total={response?.total || 0}
size={response?.items.length || 0}
/>
</Flex>
</Flex>
<AlertDialog
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>
</Flex> </Flex>
); );
} }

View File

@@ -65,6 +65,7 @@ const roomInitialState = {
roomMode: "normal", roomMode: "normal",
recordingType: "cloud", recordingType: "cloud",
recordingTrigger: "automatic-2nd-participant", recordingTrigger: "automatic-2nd-participant",
isShared: false,
}; };
export default function RoomsList() { export default function RoomsList() {
@@ -159,6 +160,7 @@ export default function RoomsList() {
room_mode: room.roomMode, room_mode: room.roomMode,
recording_type: room.recordingType, recording_type: room.recordingType,
recording_trigger: room.recordingTrigger, recording_trigger: room.recordingTrigger,
is_shared: room.isShared,
}; };
if (isEditing) { if (isEditing) {
@@ -203,6 +205,7 @@ export default function RoomsList() {
roomMode: roomData.room_mode, roomMode: roomData.room_mode,
recordingType: roomData.recording_type, recordingType: roomData.recording_type,
recordingTrigger: roomData.recording_trigger, recordingTrigger: roomData.recording_trigger,
isShared: roomData.is_shared,
}); });
setEditRoomId(roomId); setEditRoomId(roomId);
setIsEditing(true); setIsEditing(true);
@@ -236,6 +239,11 @@ export default function RoomsList() {
}); });
}; };
const myRooms =
response?.items.filter((roomData) => !roomData.is_shared) || [];
const sharedRooms =
response?.items.filter((roomData) => roomData.is_shared) || [];
if (loading && !response) if (loading && !response)
return ( return (
<Flex flexDir="column" align="center" justify="center" h="100%"> <Flex flexDir="column" align="center" justify="center" h="100%">
@@ -375,6 +383,15 @@ export default function RoomsList() {
isDisabled={!room.zulipAutoPost} isDisabled={!room.zulipAutoPost}
/> />
</FormControl> </FormControl>
<FormControl mt={4}>
<Checkbox
name="isShared"
isChecked={room.isShared}
onChange={handleRoomChange}
>
Shared room
</Checkbox>
</FormControl>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
@@ -396,9 +413,10 @@ export default function RoomsList() {
</Modal> </Modal>
</Flex> </Flex>
<VStack> <VStack align="start" mb={6} pt={4} gap={4}>
{response?.items && response.items.length > 0 ? ( <Heading size="md">My Rooms</Heading>
response.items.map((roomData) => ( {myRooms.length > 0 ? (
myRooms.map((roomData) => (
<Card w={"full"} key={roomData.id}> <Card w={"full"} key={roomData.id}>
<CardBody> <CardBody>
<Flex align={"center"}> <Flex align={"center"}>
@@ -445,9 +463,61 @@ export default function RoomsList() {
</Card> </Card>
)) ))
) : ( ) : (
<Flex flexDir="column" align="center" justify="center" h="100%"> <Text>No rooms found</Text>
<Text>No rooms found</Text> )}
</Flex> </VStack>
<VStack align="start">
<Heading size="md">Shared Rooms</Heading>
{sharedRooms.length > 0 ? (
sharedRooms.map((roomData) => (
<Card w={"full"} key={roomData.id}>
<CardBody>
<Flex align={"center"}>
<Heading size="md">
<Link href={`/${roomData.name}`}>{roomData.name}</Link>
</Heading>
<Spacer />
{linkCopied === roomData.name ? (
<Text mr={2} color="green.500">
Link copied!
</Text>
) : (
<IconButton
aria-label="Copy URL"
icon={<FaLink />}
onClick={() => handleCopyUrl(roomData.name)}
mr={2}
/>
)}
<Menu closeOnSelect={true}>
<MenuButton
as={IconButton}
icon={<FaEllipsisVertical />}
aria-label="actions"
/>
<MenuList>
<MenuItem
onClick={() => handleEditRoom(roomData.id, roomData)}
icon={<FaPencil />}
>
Edit
</MenuItem>
<MenuItem
onClick={() => handleDeleteRoom(roomData.id)}
icon={<FaTrash color={"red.500"} />}
>
Delete
</MenuItem>
</MenuList>
</Menu>
</Flex>
</CardBody>
</Card>
))
) : (
<Text>No shared rooms found</Text>
)} )}
</VStack> </VStack>
</Container> </Container>

View File

@@ -1,7 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useError } from "../../(errors)/errorContext"; import { useError } from "../../(errors)/errorContext";
import useApi from "../../lib/useApi"; import useApi from "../../lib/useApi";
import { Page_GetTranscript_ } from "../../api"; import { Page_GetTranscript_, SourceKind } from "../../api";
type TranscriptList = { type TranscriptList = {
response: Page_GetTranscript_ | null; response: Page_GetTranscript_ | null;
@@ -10,7 +10,12 @@ type TranscriptList = {
refetch: () => void; refetch: () => void;
}; };
const useTranscriptList = (page: number): TranscriptList => { const useTranscriptList = (
page: number,
sourceKind: SourceKind | null,
roomId: string | null,
searchTerm: string | null,
): TranscriptList => {
const [response, setResponse] = useState<Page_GetTranscript_ | null>(null); const [response, setResponse] = useState<Page_GetTranscript_ | null>(null);
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [error, setErrorState] = useState<Error | null>(null); const [error, setErrorState] = useState<Error | null>(null);
@@ -27,7 +32,12 @@ const useTranscriptList = (page: number): TranscriptList => {
if (!api) return; if (!api) return;
setLoading(true); setLoading(true);
api api
.v1TranscriptsList({ page }) .v1TranscriptsList({
page,
sourceKind,
roomId,
searchTerm,
})
.then((response) => { .then((response) => {
setResponse(response); setResponse(response);
setLoading(false); setLoading(false);
@@ -38,7 +48,7 @@ const useTranscriptList = (page: number): TranscriptList => {
setError(err); setError(err);
setErrorState(err); setErrorState(err);
}); });
}, [!api, page, refetchCount]); }, [!api, page, refetchCount, roomId, searchTerm]);
return { response, loading, error, refetch }; return { response, loading, error, refetch };
}; };

View File

@@ -263,6 +263,31 @@ export const $GetTranscript = {
], ],
title: "Meeting Id", title: "Meeting Id",
}, },
room_id: {
anyOf: [
{
type: "string",
},
{
type: "null",
},
],
title: "Room Id",
},
room_name: {
anyOf: [
{
type: "string",
},
{
type: "null",
},
],
title: "Room Name",
},
source_kind: {
$ref: "#/components/schemas/SourceKind",
},
}, },
type: "object", type: "object",
required: [ required: [
@@ -281,6 +306,9 @@ export const $GetTranscript = {
"participants", "participants",
"reviewed", "reviewed",
"meeting_id", "meeting_id",
"room_id",
"room_name",
"source_kind",
], ],
title: "GetTranscript", title: "GetTranscript",
} as const; } as const;
@@ -708,6 +736,10 @@ export const $Room = {
type: "string", type: "string",
title: "Recording Trigger", title: "Recording Trigger",
}, },
is_shared: {
type: "boolean",
title: "Is Shared",
},
}, },
type: "object", type: "object",
required: [ required: [
@@ -722,6 +754,7 @@ export const $Room = {
"room_mode", "room_mode",
"recording_type", "recording_type",
"recording_trigger", "recording_trigger",
"is_shared",
], ],
title: "Room", title: "Room",
} as const; } as const;
@@ -742,6 +775,12 @@ export const $RtcOffer = {
title: "RtcOffer", title: "RtcOffer",
} as const; } as const;
export const $SourceKind = {
type: "string",
enum: ["room", "live", "file"],
title: "SourceKind",
} as const;
export const $SpeakerAssignment = { export const $SpeakerAssignment = {
properties: { properties: {
speaker: { speaker: {

View File

@@ -199,18 +199,24 @@ export class DefaultService {
/** /**
* Transcripts List * Transcripts List
* @param data The data for the request. * @param data The data for the request.
* @param data.roomId
* @param data.searchTerm
* @param data.sourceKind
* @param data.page Page number * @param data.page Page number
* @param data.size Page size * @param data.size Page size
* @returns Page_GetTranscript_ Successful Response * @returns Page_GetTranscript_ Successful Response
* @throws ApiError * @throws ApiError
*/ */
public v1TranscriptsList( public v1TranscriptsList(
data: V1TranscriptsListData = {}, data: V1TranscriptsListData,
): CancelablePromise<V1TranscriptsListResponse> { ): CancelablePromise<V1TranscriptsListResponse> {
return this.httpRequest.request({ return this.httpRequest.request({
method: "GET", method: "GET",
url: "/v1/transcripts", url: "/v1/transcripts",
query: { query: {
room_id: data.roomId,
search_term: data.searchTerm,
source_kind: data.sourceKind,
page: data.page, page: data.page,
size: data.size, size: data.size,
}, },

View File

@@ -52,6 +52,9 @@ export type GetTranscript = {
participants: Array<TranscriptParticipant> | null; participants: Array<TranscriptParticipant> | null;
reviewed: boolean; reviewed: boolean;
meeting_id: string | null; meeting_id: string | null;
room_id: string | null;
room_name: string | null;
source_kind: SourceKind;
}; };
export type GetTranscriptSegmentTopic = { export type GetTranscriptSegmentTopic = {
@@ -140,6 +143,7 @@ export type Room = {
room_mode: string; room_mode: string;
recording_type: string; recording_type: string;
recording_trigger: string; recording_trigger: string;
is_shared: boolean;
}; };
export type RtcOffer = { export type RtcOffer = {
@@ -147,6 +151,8 @@ export type RtcOffer = {
type: string; type: string;
}; };
export type SourceKind = "room" | "live" | "file";
export type SpeakerAssignment = { export type SpeakerAssignment = {
speaker?: number | null; speaker?: number | null;
participant?: string | null; participant?: string | null;
@@ -274,10 +280,13 @@ export type V1TranscriptsListData = {
* Page number * Page number
*/ */
page?: number; page?: number;
roomId: string | null;
searchTerm: string | null;
/** /**
* Page size * Page size
*/ */
size?: number; size?: number;
sourceKind?: SourceKind | null;
}; };
export type V1TranscriptsListResponse = Page_GetTranscript_; export type V1TranscriptsListResponse = Page_GetTranscript_;