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,
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[
"none", "prompt", "automatic", "automatic-2nd-participant"
] = "automatic-2nd-participant"
is_shared: bool = False
class RoomController:
@@ -98,6 +102,7 @@ class RoomController:
room_mode: str,
recording_type: str,
recording_trigger: str,
is_shared: bool,
):
"""
Add a new room
@@ -112,6 +117,7 @@ class RoomController:
room_mode=room_mode,
recording_type=recording_type,
recording_trigger=recording_trigger,
is_shared=is_shared,
)
query = rooms.insert().values(**room.model_dump())
try:

View File

@@ -1,3 +1,4 @@
import enum
import json
import os
import shutil
@@ -14,8 +15,16 @@ from reflector.db import database, metadata
from reflector.processors.types import Word as ProcessorWord
from reflector.settings import settings
from reflector.storage import Storage
from sqlalchemy import Enum
from sqlalchemy.sql import false
class SourceKind(enum.StrEnum):
ROOM = enum.auto()
LIVE = enum.auto()
FILE = enum.auto()
transcripts = sqlalchemy.Table(
"transcript",
metadata,
@@ -55,6 +64,11 @@ transcripts = sqlalchemy.Table(
sqlalchemy.String,
),
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
meeting_id: str | None = None
zulip_message_id: int | None = None
source_kind: SourceKind
def add_event(self, event: str, data: BaseModel) -> TranscriptEvent:
ev = TranscriptEvent(event=event, data=data.model_dump())
@@ -291,6 +306,9 @@ class TranscriptController:
order_by: str | None = None,
filter_empty: 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,
) -> list[Transcript]:
"""
@@ -303,8 +321,39 @@ class TranscriptController:
- `order_by`: field to order by, e.g. "-created_at"
- `filter_empty`: filter out empty transcripts
- `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:
field = getattr(transcripts.c, order_by[1:])
@@ -392,6 +441,7 @@ class TranscriptController:
async def add(
self,
name: str,
source_kind: SourceKind,
source_language: str = "en",
target_language: str = "en",
user_id: str | None = None,
@@ -403,6 +453,7 @@ class TranscriptController:
"""
transcript = Transcript(
name=name,
source_kind=source_kind,
source_language=source_language,
target_language=target_language,
user_id=user_id,

View File

@@ -28,6 +28,7 @@ class Room(BaseModel):
room_mode: str
recording_type: str
recording_trigger: str
is_shared: bool
class Meeting(BaseModel):
@@ -49,6 +50,7 @@ class CreateRoom(BaseModel):
room_mode: str
recording_type: str
recording_trigger: str
is_shared: bool
class UpdateRoom(BaseModel):
@@ -60,6 +62,7 @@ class UpdateRoom(BaseModel):
room_mode: str
recording_type: str
recording_trigger: str
is_shared: bool
class DeletionStatus(BaseModel):
@@ -100,6 +103,7 @@ async def rooms_create(
room_mode=room.room_mode,
recording_type=room.recording_type,
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 reflector.db.migrate_user import migrate_user
from reflector.db.transcripts import (
SourceKind,
TranscriptParticipant,
TranscriptTopic,
transcripts_controller,
@@ -59,6 +60,9 @@ class GetTranscript(BaseModel):
participants: list[TranscriptParticipant] | None
reviewed: bool
meeting_id: str | None
source_kind: SourceKind
room_id: str | None = None
room_name: str | None = None
class CreateTranscript(BaseModel):
@@ -85,6 +89,9 @@ class DeletionStatus(BaseModel):
@router.get("/transcripts", response_model=Page[GetTranscript])
async def transcripts_list(
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
@@ -101,6 +108,9 @@ async def transcripts_list(
database,
await transcripts_controller.get_all(
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",
return_query=True,
),
@@ -115,6 +125,7 @@ async def transcripts_create(
user_id = user["sub"] if user else None
return await transcripts_controller.add(
info.name,
source_kind=SourceKind.LIVE,
source_language=info.source_language,
target_language=info.target_language,
user_id=user_id,

View File

@@ -8,7 +8,7 @@ import structlog
from celery import shared_task
from celery.utils.log import get_task_logger
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.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)
transcript = await transcripts_controller.add(
"",
source_kind=SourceKind.ROOM,
source_language="en",
target_language="en",
user_id=meeting.user_id,

View File

@@ -1,50 +1,70 @@
"use client";
import React, { useEffect, useState } 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 React, { useState, useEffect } from "react";
import {
Flex,
Spinner,
Heading,
Button,
Card,
Link,
CardBody,
Stack,
Box,
Text,
Link,
Stack,
Table,
Thead,
Tbody,
Tr,
Th,
Td,
Button,
Divider,
Input,
Icon,
Grid,
IconButton,
Spacer,
Tooltip,
Menu,
MenuButton,
MenuItem,
MenuList,
MenuItem,
IconButton,
AlertDialog,
AlertDialogOverlay,
AlertDialogContent,
AlertDialogHeader,
AlertDialogBody,
AlertDialogFooter,
Tooltip,
Spacer,
} from "@chakra-ui/react";
import { PlusSquareIcon } from "@chakra-ui/icons";
import { ExpandableText } from "../../lib/expandableText";
import {
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() {
const [page, setPage] = useState<number>(1);
const { loading, response, refetch } = useTranscriptList(page);
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 [searchInputValue, setSearchInputValue] = useState("");
const { loading, response, refetch } = useTranscriptList(
page,
selectedSourceKind,
selectedRoomId,
searchTerm,
);
const userName = useSessionUser().name;
const [deletionLoading, setDeletionLoading] = useState(false);
const api = useApi();
@@ -54,10 +74,48 @@ 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]);
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)
return (
<Flex flexDir="column" align="center" justify="center" h="100%">
@@ -77,6 +135,7 @@ export default function TranscriptBrowser() {
</Text>
</Flex>
);
const onCloseDeletion = () => setTranscriptToDeleteId(undefined);
const handleDeleteTranscript = (transcriptId) => (e) => {
@@ -88,7 +147,6 @@ export default function TranscriptBrowser() {
.then(() => {
refetch();
setDeletionLoading(false);
refetch();
onCloseDeletion();
setDeletedItemIds((deletedItemIds) => [
deletedItemIds,
@@ -120,169 +178,203 @@ export default function TranscriptBrowser() {
});
}
};
return (
<Flex
maxW="container.xl"
flexDir="column"
margin="auto"
gap={2}
overflowY="auto"
minH="100%"
w={{ base: "full", md: "container.xl" }}
mx="auto"
p={4}
>
<Flex
flexDir="row"
justify="flex-end"
align="center"
flexWrap={"wrap-reverse"}
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 flexDir="row" justify="space-between" align="center" mb={4}>
<Heading size="md">
{userName ? `${userName}'s Transcriptions` : "Your Transcriptions"}{" "}
{loading || (deletionLoading && <Spinner size="sm" />)}
</Heading>
</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 />
<Menu closeOnSelect={true}>
<MenuButton
as={IconButton}
icon={<FaEllipsisVertical />}
aria-label="actions"
/>
<MenuList>
<MenuItem
isDisabled={deletionLoading}
onClick={() => setTranscriptToDeleteId(item.id)}
icon={<FaTrash color={"red.500"} />}
>
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>
<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>
<AlertDialogBody>
Are you sure? You can't undo this action
afterwards.
</AlertDialogBody>
<Divider />
<AlertDialogFooter>
<Button ref={cancelRef} onClick={onCloseDeletion}>
Cancel
</Button>
<Button
colorScheme="red"
onClick={handleDeleteTranscript(item.id)}
ml={3}
>
Delete
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
</MenuList>
</Menu>
</Flex>
<Stack mt="4" spacing="2">
<Flex align={"center"} gap={2}>
{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.primary" as={MdError} />
</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="grey.primary"
as={FaGear}
transition={"all 2s ease"}
transform={"rotate(0deg)"}
_hover={{ transform: "rotate(360deg)" }}
/>
</span>
</Tooltip>
)}
{item.status == "recording" && (
<Tooltip label="Recording in progress">
<span>
<Icon color="blue.primary" as={FaMicrophone} />
</span>
</Tooltip>
)}
<Text fontSize="small">
{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>
<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", {
year: "numeric",
month: "long",
@@ -290,18 +382,146 @@ export default function TranscriptBrowser() {
hour: "numeric",
minute: "numeric",
})}
{"\u00A0"}-{"\u00A0"}
{formatTimeMs(item.duration)}
</Text>
</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: 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>
<ExpandableText noOfLines={5}>
{item.short_summary}
</ExpandableText>
</Stack>
</CardBody>
</Card>
))}
</Grid>
</Box>
))}
</Stack>
</Box>
<Pagination
page={page}
setPage={setPage}
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>
);
}

View File

@@ -65,6 +65,7 @@ const roomInitialState = {
roomMode: "normal",
recordingType: "cloud",
recordingTrigger: "automatic-2nd-participant",
isShared: false,
};
export default function RoomsList() {
@@ -159,6 +160,7 @@ export default function RoomsList() {
room_mode: room.roomMode,
recording_type: room.recordingType,
recording_trigger: room.recordingTrigger,
is_shared: room.isShared,
};
if (isEditing) {
@@ -203,6 +205,7 @@ export default function RoomsList() {
roomMode: roomData.room_mode,
recordingType: roomData.recording_type,
recordingTrigger: roomData.recording_trigger,
isShared: roomData.is_shared,
});
setEditRoomId(roomId);
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)
return (
<Flex flexDir="column" align="center" justify="center" h="100%">
@@ -375,6 +383,15 @@ export default function RoomsList() {
isDisabled={!room.zulipAutoPost}
/>
</FormControl>
<FormControl mt={4}>
<Checkbox
name="isShared"
isChecked={room.isShared}
onChange={handleRoomChange}
>
Shared room
</Checkbox>
</FormControl>
</ModalBody>
<ModalFooter>
@@ -396,9 +413,10 @@ export default function RoomsList() {
</Modal>
</Flex>
<VStack>
{response?.items && response.items.length > 0 ? (
response.items.map((roomData) => (
<VStack align="start" mb={6} pt={4} gap={4}>
<Heading size="md">My Rooms</Heading>
{myRooms.length > 0 ? (
myRooms.map((roomData) => (
<Card w={"full"} key={roomData.id}>
<CardBody>
<Flex align={"center"}>
@@ -445,9 +463,61 @@ export default function RoomsList() {
</Card>
))
) : (
<Flex flexDir="column" align="center" justify="center" h="100%">
<Text>No rooms found</Text>
</Flex>
<Text>No rooms found</Text>
)}
</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>
</Container>

View File

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

View File

@@ -263,6 +263,31 @@ export const $GetTranscript = {
],
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",
required: [
@@ -281,6 +306,9 @@ export const $GetTranscript = {
"participants",
"reviewed",
"meeting_id",
"room_id",
"room_name",
"source_kind",
],
title: "GetTranscript",
} as const;
@@ -708,6 +736,10 @@ export const $Room = {
type: "string",
title: "Recording Trigger",
},
is_shared: {
type: "boolean",
title: "Is Shared",
},
},
type: "object",
required: [
@@ -722,6 +754,7 @@ export const $Room = {
"room_mode",
"recording_type",
"recording_trigger",
"is_shared",
],
title: "Room",
} as const;
@@ -742,6 +775,12 @@ export const $RtcOffer = {
title: "RtcOffer",
} as const;
export const $SourceKind = {
type: "string",
enum: ["room", "live", "file"],
title: "SourceKind",
} as const;
export const $SpeakerAssignment = {
properties: {
speaker: {

View File

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

View File

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