From 0aff0e11a1e2e4b4c3057a814cb03831ee3a972e Mon Sep 17 00:00:00 2001 From: kouloumos Date: Mon, 15 Jul 2024 14:59:53 +0300 Subject: [PATCH 1/2] refactor: support checkboxes in `BaseTable` add better support for checkboxes in the `BaseTable` as the previous implementation inside of `TableAction` was a mess. --- src/components/tables/AdminReviewsTable.tsx | 87 ++++------ src/components/tables/BaseTable.tsx | 107 ++++++++----- src/components/tables/QueueTable.tsx | 167 ++++++++------------ src/components/tables/TableItems.tsx | 45 +----- src/components/tables/UsersTable.tsx | 77 +++------ src/components/tables/types.ts | 1 - src/config/permissions.json | 2 + src/hooks/useHasPermissions.ts | 1 + 8 files changed, 207 insertions(+), 280 deletions(-) diff --git a/src/components/tables/AdminReviewsTable.tsx b/src/components/tables/AdminReviewsTable.tsx index 95929345..997b331b 100644 --- a/src/components/tables/AdminReviewsTable.tsx +++ b/src/components/tables/AdminReviewsTable.tsx @@ -1,5 +1,4 @@ import { - CheckboxGroup, Flex, Td, Text, @@ -18,6 +17,7 @@ import { dateFormatGeneral } from "@/utils"; import { getReviewStatus } from "@/utils/review"; import { format } from "date-fns"; import { useHasPermission } from "@/hooks/useHasPermissions"; +import { useRouter } from "next/router"; const tableStructure = [ { @@ -114,23 +114,23 @@ type Props = { refetch: () => void; }; -type AdminResetSelectProps = { - children: (props: { - handleReset: () => Promise; - hasAdminSelected: boolean; - isResetting: boolean; - }) => React.ReactNode; -}; -const AdminResetSelect = ({ children }: AdminResetSelectProps) => { +const AdminReviewsTable = ({ + isLoading, + isError, + hasFilters, + reviews, +}: Props) => { const [selectedIds, setSelectedIds] = useState([]); const toast = useToast(); + const router = useRouter(); const { data: userSession } = useSession(); const queryClient = useQueryClient(); const resetReview = useResetReview(); - const handleCheckboxToggle = (values: (string | number)[]) => { - setSelectedIds(values.map(String)); - }; + + const canResetReviews = useHasPermission("resetReviews"); + const { status } = router.query; + const handleReset = async () => { const ids = selectedIds.map(Number); @@ -164,46 +164,29 @@ const AdminResetSelect = ({ children }: AdminResetSelectProps) => { }; return ( - - {children({ - handleReset, - hasAdminSelected: selectedIds.length > 0, - isResetting: resetReview.isLoading, - })} - - ); -}; - -const AdminReviewsTable = ({ - isLoading, - isError, - hasFilters, - reviews, -}: Props) => { - const canResetReviews = useHasPermission("resetReviews"); - return ( - - {({ handleReset, hasAdminSelected, isResetting }) => ( - } - isLoading={isLoading} - isError={isError} - tableStructure={tableStructure} - showAdminControls={canResetReviews} - actionItems={ - <> - {hasAdminSelected && ( - - )} - - } - /> - )} - + <> + } + isLoading={isLoading} + isError={isError} + tableStructure={tableStructure} + enableCheckboxes={canResetReviews && status == "active"} + selectedRowIds={selectedIds} + onSelectedRowIdsChange={setSelectedIds} + getRowId={(row) => `${row.id}`} + actionItems={ + <> + {selectedIds.length > 0 && ( + + )} + + } + /> + ); }; diff --git a/src/components/tables/BaseTable.tsx b/src/components/tables/BaseTable.tsx index 08749e08..adacabf7 100644 --- a/src/components/tables/BaseTable.tsx +++ b/src/components/tables/BaseTable.tsx @@ -1,4 +1,15 @@ -import { Box, Flex, Heading, Table, Tbody, Thead, Tr } from "@chakra-ui/react"; +import { + Box, + Checkbox, + CheckboxGroup, + Flex, + Heading, + Table, + Tbody, + Thead, + Tr, + Td, +} from "@chakra-ui/react"; import { QueryObserverResult, RefetchOptions, @@ -26,7 +37,10 @@ type Props = { tableStructure: TableStructure[]; tableHeader?: string; tableHeaderComponent?: React.ReactNode; - showAdminControls?: boolean; + enableCheckboxes?: boolean; + selectedRowIds?: string[]; + onSelectedRowIdsChange?: (selectedRowIds: string[]) => void; + getRowId?: (row: T) => string; }; const BaseTable = ({ @@ -38,7 +52,10 @@ const BaseTable = ({ tableStructure, tableHeader, tableHeaderComponent, - showAdminControls = false, + enableCheckboxes = false, // Default to no checkboxes + selectedRowIds = [], + onSelectedRowIdsChange: setSelectedIds, + getRowId, }: Props) => { return ( @@ -53,36 +70,46 @@ const BaseTable = ({ {actionItems} {refetch && } - - - - - - {isLoading ? ( - - ) : !data ? ( - - ) : data?.length ? ( - data.map((dataRow, idx) => ( - + + + + + {isLoading ? ( + - )) - ) : ( - - )} - -
+ ) : !data ? ( + + ) : data?.length ? ( + data.map((dataRow, idx) => ( + + )) + ) : ( + + )} + + +
); }; @@ -90,21 +117,23 @@ const BaseTable = ({ const TableRow = ({ row, ts, - showControls, + enableCheckboxes, + rowId, }: { row: T; ts: TableStructure[]; - showControls: boolean; + enableCheckboxes: boolean; + rowId: string; }) => { return ( + {enableCheckboxes && ( + + + + )} {ts.map((tableItem) => ( - + ))} ); diff --git a/src/components/tables/QueueTable.tsx b/src/components/tables/QueueTable.tsx index edc3de67..ccffb5aa 100644 --- a/src/components/tables/QueueTable.tsx +++ b/src/components/tables/QueueTable.tsx @@ -14,7 +14,6 @@ import { import { useGithub } from "@/services/api/github"; import { Button, - CheckboxGroup, Flex, Text, useDisclosure, @@ -39,30 +38,42 @@ import Pagination from "./Pagination"; import { ArchiveButton } from "./TableItems"; import TitleWithTags from "./TitleWithTags"; import { TableStructure } from "./types"; +import { useHasPermission } from "@/hooks/useHasPermissions"; -type AdminArchiveSelectProps = { - children: (props: { - handleArchive: () => Promise; - hasAdminSelected: boolean; - isArchiving: boolean; - }) => React.ReactNode; -}; - -const AdminArchiveSelect = ({ children }: AdminArchiveSelectProps) => { +const QueueTable = () => { const [selectedIds, setSelectedIds] = useState([]); - const toast = useToast(); - const { data: userSession } = useSession(); const queryClient = useQueryClient(); const archiveTranscript = useArchiveTranscript(); + const canArchiveTranscripts = useHasPermission("archiveTranscripts"); + const { data: session, status } = useSession(); + const [currentPage, setCurrentPage] = useState(1); + const { + isOpen: showSuggestModal, + onClose: closeSuggestModal, + onOpen: openSuggestModal, + } = useDisclosure(); + const router = useRouter(); + const { claimTranscript } = useGithub(); + const { data, isLoading, isError, refetch } = useTranscripts(currentPage); + const hasExceededActiveReviewLimit = useHasExceededMaxActiveReviews( + session?.user?.id + ); + const [totalPages, setTotalPages] = useState(data?.totalPages || 0); + const toast = useToast(); + + const retriedClaim = useRef(0); + + const [selectedTranscriptId, setSelectedTranscriptId] = useState(-1); + const { data: multipleStatusData } = useUserMultipleReviews({ + userId: session?.user?.id, + multipleStatus: ["pending", "active", "inactive"], + }); - const handleCheckboxToggle = (values: (string | number)[]) => { - setSelectedIds(values.map(String)); - }; const handleArchive = async () => { const ids = selectedIds.map(Number); - if (userSession?.user?.id) { - const archivedBy = userSession?.user?.id; + if (session?.user?.id) { + const archivedBy = session?.user?.id; try { await Promise.all( ids.map((transcriptId) => @@ -93,42 +104,6 @@ const AdminArchiveSelect = ({ children }: AdminArchiveSelectProps) => { } }; - return ( - - {children({ - handleArchive, - hasAdminSelected: selectedIds.length > 0, - isArchiving: archiveTranscript.isLoading, - })} - - ); -}; - -const QueueTable = () => { - const { data: session, status } = useSession(); - const [currentPage, setCurrentPage] = useState(1); - const { - isOpen: showSuggestModal, - onClose: closeSuggestModal, - onOpen: openSuggestModal, - } = useDisclosure(); - const router = useRouter(); - const { claimTranscript } = useGithub(); - const { data, isLoading, isError, refetch } = useTranscripts(currentPage); - const hasExceededActiveReviewLimit = useHasExceededMaxActiveReviews( - session?.user?.id - ); - const [totalPages, setTotalPages] = useState(data?.totalPages || 0); - const toast = useToast(); - - const retriedClaim = useRef(0); - - const [selectedTranscriptId, setSelectedTranscriptId] = useState(-1); - const { data: multipleStatusData } = useUserMultipleReviews({ - userId: session?.user?.id, - multipleStatus: ["pending", "active", "inactive"], - }); - const retryLoginAndClaim = async (transcriptId: number) => { await signOut({ redirect: false }); if (retriedClaim.current < 2) { @@ -329,52 +304,48 @@ const QueueTable = () => { ); return ( - - {({ handleArchive, hasAdminSelected, isArchiving }) => ( - <> - - + <> + + - {hasAdminSelected && ( - - )} - - } - data={data?.data} - emptyView="There are no transcripts awaiting review" - isError={isError} - isLoading={isLoading} - refetch={refetch} - showAdminControls - tableHeader="Transcripts waiting for review" - tableStructure={tableStructure} - /> - - - - )} - + {selectedIds.length > 0 && ( + + )} + + } + data={data?.data} + emptyView="There are no transcripts awaiting review" + isError={isError} + isLoading={isLoading} + refetch={refetch} + enableCheckboxes={canArchiveTranscripts} + selectedRowIds={selectedIds} + onSelectedRowIdsChange={setSelectedIds} + getRowId={(row) => `${row.id}`} + tableHeader="Transcripts waiting for review" + tableStructure={tableStructure} + /> + + + ); }; diff --git a/src/components/tables/TableItems.tsx b/src/components/tables/TableItems.tsx index 80c13411..da1673e7 100644 --- a/src/components/tables/TableItems.tsx +++ b/src/components/tables/TableItems.tsx @@ -8,7 +8,6 @@ import config from "../../config/config.json"; import { Box, Button, - Checkbox, Flex, Icon, IconButton, @@ -25,7 +24,6 @@ import { Tr, useToast, } from "@chakra-ui/react"; -import { useSession } from "next-auth/react"; import Image from "next/image"; import Link from "next/link"; import { useMemo } from "react"; @@ -40,7 +38,6 @@ import { resolveGHApiUrl } from "@/utils/github"; import { getReviewStatus } from "@/utils/review"; import { AdminReview } from "@/services/api/admin/useReviews"; import { format } from "date-fns"; -import { useHasPermission } from "@/hooks/useHasPermissions"; import { UserRoles } from "../../../types"; import useCopyToClipboard from "@/hooks/useCopyToClipboard"; import CopyIcon from "../svgs/CopyIcon"; @@ -128,26 +125,12 @@ export const Tags = ({ export const TableAction = ({ tableItem, row, - showControls, -}: TableDataElement & { showControls: boolean }) => { - const { data: userSession } = useSession(); - +}: TableDataElement) => { const handleClick = () => { if (!tableItem.action) return; tableItem.action(row); }; - // checks if it a review if it isn't returns false - const isAdminReviews = getReviewStatus(row as AdminReview); - const isUsersTable = tableItem.actionTableType === "user"; - - const isAdmin = useHasPermission("accessAdminNav"); - const showCheckBox = isAdmin && showControls; - - /* Forced the type here because it uses a dynamic type so Ts isn't aware of Id in rows - also from other tables every row has an id - */ - const rowId = row as { id: number }; return ( @@ -165,19 +148,6 @@ export const TableAction = ({ {tableItem.actionName} )} - {/* For reviews */} - {isAdminReviews === "Active" && showCheckBox && ( - - )} - - {/* checkbox */} - {showCheckBox && !isAdminReviews && !isUsersTable && ( - - )} - - {isUsersTable && rowId?.id !== userSession?.user?.id && ( - - )} ); @@ -185,11 +155,14 @@ export const TableAction = ({ export const TableHeader = ({ tableStructure, + showCheckboxes, }: { tableStructure: TableStructure[]; + showCheckboxes: boolean; }) => { return ( + {showCheckboxes && } {tableStructure.map((tableItem, idx) => { return ( @@ -245,8 +218,7 @@ export const DataEmpty = ({ export const RowData = ({ row, tableItem, - showControls, -}: TableDataElement & { showControls: boolean }) => { +}: TableDataElement) => { switch (tableItem.type) { case "date": return ; @@ -262,12 +234,7 @@ export const RowData = ({ case "action": return ( - + ); default: diff --git a/src/components/tables/UsersTable.tsx b/src/components/tables/UsersTable.tsx index 8a209407..453c6140 100644 --- a/src/components/tables/UsersTable.tsx +++ b/src/components/tables/UsersTable.tsx @@ -1,4 +1,4 @@ -import { CheckboxGroup, Flex, Text, Tooltip, useToast } from "@chakra-ui/react"; +import { Flex, Text, Tooltip, useToast } from "@chakra-ui/react"; import BaseTable from "./BaseTable"; import type { TableStructure } from "./types"; @@ -29,7 +29,6 @@ const tableStructure = [ { name: "Joined", type: "action", - actionTableType: "user", modifier: (data) => data.createdAt, component: (data) => ( void; }; -type AdminUsersSelectProps = { - children: (props: { - handleUpdate: (role: UserRole) => Promise; - hasAdminSelected: boolean; - isUpdating: boolean; - }) => React.ReactNode; -}; -const AdminUsersSelect = ({ children }: AdminUsersSelectProps) => { +const UsersTable = ({ isLoading, isError, hasFilters, users }: Props) => { const [selectedIds, setSelectedIds] = useState([]); const toast = useToast(); const { data: userSession } = useSession(); const queryClient = useQueryClient(); const updateUser = useUpdateUserRole(); - const handleCheckboxToggle = (values: (string | number)[]) => { - setSelectedIds(values.map(String)); - }; + const handleUpdate = async (role: UserRole) => { const ids = selectedIds.map(Number); @@ -113,44 +103,29 @@ const AdminUsersSelect = ({ children }: AdminUsersSelectProps) => { }; return ( - - {children({ - handleUpdate, - hasAdminSelected: selectedIds.length > 0, - isUpdating: updateUser.isLoading, - })} - - ); -}; - -const UsersTable = ({ isLoading, isError, hasFilters, users }: Props) => { - return ( - - {({ handleUpdate, hasAdminSelected, isUpdating }) => ( - } - isLoading={isLoading} - isError={isError} - tableStructure={tableStructure} - showAdminControls - actionItems={ - <> - {hasAdminSelected && ( - - )} - - } - /> - )} - + <> + } + isLoading={isLoading} + isError={isError} + tableStructure={tableStructure} + enableCheckboxes + selectedRowIds={selectedIds} + onSelectedRowIdsChange={setSelectedIds} + getRowId={(row) => `${row.id}`} + actionItems={ + <> + {selectedIds.length > 0 && ( + + )} + + } + /> + ); }; diff --git a/src/components/tables/types.ts b/src/components/tables/types.ts index 46ed7b0c..74adf0c3 100644 --- a/src/components/tables/types.ts +++ b/src/components/tables/types.ts @@ -11,7 +11,6 @@ export type tableStructureItemType = export type TableStructure = { name: string; actionName?: string; - actionTableType?: string; type: tableStructureItemType; modifier: (data: T) => any; action?: (data: T) => void; diff --git a/src/config/permissions.json b/src/config/permissions.json index 7bf8a70a..8a79728d 100644 --- a/src/config/permissions.json +++ b/src/config/permissions.json @@ -3,6 +3,7 @@ "accessReviews": true, "accessUsers": true, "resetReviews": true, + "archiveTranscripts": true, "accessTransactions": true, "accessAdminNav": true, "submitToOwnRepo": true @@ -11,6 +12,7 @@ "accessReviews": true, "accessUsers": false, "resetReviews": false, + "archiveTranscripts": true, "accessTransactions": false, "accessAdminNav": true, "submitToOwnRepo": false diff --git a/src/hooks/useHasPermissions.ts b/src/hooks/useHasPermissions.ts index f1314e16..a2c87919 100644 --- a/src/hooks/useHasPermissions.ts +++ b/src/hooks/useHasPermissions.ts @@ -6,6 +6,7 @@ type Permissions = { accessReviews: boolean; accessUsers: boolean; resetReviews: boolean; + archiveTranscripts: boolean; accessTransactions: boolean; accessAdminNav: boolean; submitToOwnRepo: boolean; From 44a0715184025d042728ddc8ab166d0cd61b32ff Mon Sep 17 00:00:00 2001 From: kouloumos Date: Wed, 17 Jul 2024 10:29:41 +0300 Subject: [PATCH 2/2] feat: Transcription Management page Transcription Management page to handle the transcription process. The page features two tables: one for the transcription backlog and another for the transcription queue. Items from the backlog can be added to the queue for transcription. After transcription starts, it provides visibility on the state of active transcription jobs. Note: There is currently no visibility on past transcription jobs. --- src/components/navbar/Menu.tsx | 11 + src/components/tables/TitleWithTags.tsx | 9 +- .../tables/components/SelectSourceMenu.tsx | 49 +++++ src/components/tables/components/index.ts | 1 + src/config/permissions.json | 4 + src/config/ui-config.ts | 1 + src/hooks/useHasPermissions.ts | 1 + src/pages/admin/transcription.tsx | 192 +++++++++++++++++ src/services/api/admin/index.ts | 2 + .../api/admin/useTranscriptionBacklog.tsx | 107 ++++++++++ .../api/admin/useTranscriptionQueue.ts | 195 ++++++++++++++++++ types.ts | 12 ++ 12 files changed, 581 insertions(+), 3 deletions(-) create mode 100644 src/components/tables/components/SelectSourceMenu.tsx create mode 100644 src/components/tables/components/index.ts create mode 100644 src/pages/admin/transcription.tsx create mode 100644 src/services/api/admin/useTranscriptionBacklog.tsx create mode 100644 src/services/api/admin/useTranscriptionQueue.ts diff --git a/src/components/navbar/Menu.tsx b/src/components/navbar/Menu.tsx index 3a8c03b1..05dca44c 100644 --- a/src/components/navbar/Menu.tsx +++ b/src/components/navbar/Menu.tsx @@ -24,6 +24,7 @@ import { CgTranscript } from "react-icons/cg"; import { FaGithub } from "react-icons/fa"; import { FiUser, FiUsers } from "react-icons/fi"; import { HiOutlineBookOpen, HiOutlineSwitchHorizontal } from "react-icons/hi"; +import { MdOutlineSource } from "react-icons/md"; import MenuNav from "./MenuNav"; import AdminMenu from "./AdminMenu"; import { useHasPermission } from "@/hooks/useHasPermissions"; @@ -34,6 +35,7 @@ const Menu = () => { const canAccessAdminNav = useHasPermission("accessAdminNav"); const canAccessTransactions = useHasPermission("accessTransactions"); const canAccessUsers = useHasPermission("accessUsers"); + const canAccessTranscription = useHasPermission("accessTranscription"); const router = useRouter(); const currentRoute = router.asPath?.split("/")[1] ?? ""; const fullCurrentRoute = router.asPath; @@ -194,6 +196,15 @@ const Menu = () => { icon={FiUsers} /> )} + {canAccessTranscription && ( + + )} ) : null} diff --git a/src/components/tables/TitleWithTags.tsx b/src/components/tables/TitleWithTags.tsx index 6ac4ca76..556fb6ca 100644 --- a/src/components/tables/TitleWithTags.tsx +++ b/src/components/tables/TitleWithTags.tsx @@ -27,6 +27,7 @@ type TitleWithTagsProps = { categories: string | string[]; loc?: string; transcriptUrl?: string | null; + url?: string; allTags: string[]; length: number; shouldSlice?: boolean; @@ -37,6 +38,7 @@ const TitleWithTags = ({ categories, loc, transcriptUrl = null, + url, id, length, shouldSlice = true, @@ -50,13 +52,14 @@ const TitleWithTags = ({ ); const tags = shouldSlice ? allTags.slice(0, 1) : allTags; const transcript = resolveTranscriptUrl(transcriptUrl); + const hyperlink = transcript?.url || url; return ( - {!transcript && {title}} - {transcript && ( - + {!hyperlink && {title}} + {hyperlink && ( + {title} )} diff --git a/src/components/tables/components/SelectSourceMenu.tsx b/src/components/tables/components/SelectSourceMenu.tsx new file mode 100644 index 00000000..7f97a77e --- /dev/null +++ b/src/components/tables/components/SelectSourceMenu.tsx @@ -0,0 +1,49 @@ +import { + Button, + Flex, + Menu, + MenuButton, + MenuItem, + MenuList, + Text, +} from "@chakra-ui/react"; +import { MdArrowDropDown } from "react-icons/md"; + +const SelectSourceMenu = ({ + isLoading, + sources, + onSelection: selectSource, +}: { + isLoading?: boolean; + sources?: string[]; + onSelection: (source: string) => void; +}) => ( + + + + Select Source + + + + + {sources && + sources.map((source) => ( + { + selectSource(source); + }} + > + {source} + + ))} + + +); + +export default SelectSourceMenu; diff --git a/src/components/tables/components/index.ts b/src/components/tables/components/index.ts new file mode 100644 index 00000000..57d6c2bc --- /dev/null +++ b/src/components/tables/components/index.ts @@ -0,0 +1 @@ +export { default as SelectSourceMenu } from "./SelectSourceMenu"; diff --git a/src/config/permissions.json b/src/config/permissions.json index 8a79728d..8b0d4f21 100644 --- a/src/config/permissions.json +++ b/src/config/permissions.json @@ -4,6 +4,7 @@ "accessUsers": true, "resetReviews": true, "archiveTranscripts": true, + "accessTranscription": true, "accessTransactions": true, "accessAdminNav": true, "submitToOwnRepo": true @@ -13,6 +14,7 @@ "accessUsers": false, "resetReviews": false, "archiveTranscripts": true, + "accessTranscription": true, "accessTransactions": false, "accessAdminNav": true, "submitToOwnRepo": false @@ -21,6 +23,8 @@ "accessReviews": false, "accessUsers": false, "resetReviews": false, + "archiveTranscripts": false, + "accessTranscription": false, "accessTransactions": false, "accessAdminNav": false, "submitToOwnRepo": false diff --git a/src/config/ui-config.ts b/src/config/ui-config.ts index d11881d3..4c4a8b20 100644 --- a/src/config/ui-config.ts +++ b/src/config/ui-config.ts @@ -10,6 +10,7 @@ export const ROUTES_CONFIG = { ALL_REVIEWS: "admin/reviews?status=active", REVIEWS: "reviews", USERS: "admin/users", + TRANSCRIPTION: "admin/transcription", }; export const UI_CONFIG = { diff --git a/src/hooks/useHasPermissions.ts b/src/hooks/useHasPermissions.ts index a2c87919..107f3c7a 100644 --- a/src/hooks/useHasPermissions.ts +++ b/src/hooks/useHasPermissions.ts @@ -5,6 +5,7 @@ import permissions from "../config/permissions.json"; type Permissions = { accessReviews: boolean; accessUsers: boolean; + accessTranscription: boolean; resetReviews: boolean; archiveTranscripts: boolean; accessTransactions: boolean; diff --git a/src/pages/admin/transcription.tsx b/src/pages/admin/transcription.tsx new file mode 100644 index 00000000..2e8db1d6 --- /dev/null +++ b/src/pages/admin/transcription.tsx @@ -0,0 +1,192 @@ +import { useState } from "react"; +import { Button, Flex, Heading, Text } from "@chakra-ui/react"; + +import { useHasPermission } from "@/hooks/useHasPermissions"; +import AuthStatus from "@/components/transcript/AuthStatus"; +import { + useTranscriptionQueue, + useTranscriptionBacklog, +} from "@/services/api/admin"; +import { SelectSourceMenu } from "@/components/tables/components"; + +import BaseTable from "@/components/tables/BaseTable"; +import { convertStringToArray } from "@/utils"; +import TitleWithTags from "@/components/tables/TitleWithTags"; +import { TableStructure } from "@/components/tables/types"; +import { TranscriptMetadata, TranscriptionQueueItem } from "../../../types"; + +const Sources = () => { + const [selectedSource, setSelectedSource] = useState("all"); + const [removeFromQueueSelection, setRemoveFromQueueSelection] = useState< + string[] + >([]); + const [addToQueueSelection, setAddToQueueSelection] = useState([]); + const canAccessTranscription = useHasPermission("accessTranscription"); + + const { transcriptionBacklog, sources } = + useTranscriptionBacklog(selectedSource); + const { + transcriptionQueue, + remainingBacklog, + addToQueue, + removeFromQueue, + startTranscription, + isTranscribing, + refetch, + } = useTranscriptionQueue(transcriptionBacklog.data); + + const handleAddToQueue = async () => { + await addToQueue.mutateAsync(addToQueueSelection); + setAddToQueueSelection([]); + }; + + const handleRemoveFromQueue = async () => { + await removeFromQueue.mutateAsync(removeFromQueueSelection); + setRemoveFromQueueSelection([]); + }; + + const tableStructure = [ + { + name: "Title", + type: "default", + modifier: () => null, + component: (data) => { + const allTags = convertStringToArray(data.tags); + return ( + + ); + }, + }, + { + name: "speakers", + type: "text-long", + modifier: (data) => data.speakers.join(", "), + }, + { name: "Publish Date", type: "text-short", modifier: (data) => data.date }, + ] satisfies TableStructure[]; + + const transcriptionQueueTableStructure = [ + ...tableStructure, + { + name: "status", + type: "text-short", + modifier: (data) => data.status, + }, + ] satisfies TableStructure[]; + + if (!canAccessTranscription) { + return ( + + ); + } + + return ( + <> + + + {`Transcription Management`} + + + Transcription queue is empty + + } + isLoading={transcriptionQueue.isLoading} + isError={transcriptionQueue.isError} + tableStructure={transcriptionQueueTableStructure} + tableHeaderComponent={ + + Transcription Queue + + } + enableCheckboxes={!isTranscribing} + selectedRowIds={removeFromQueueSelection} + onSelectedRowIdsChange={setRemoveFromQueueSelection} + getRowId={(row) => row.media} + refetch={refetch} + actionItems={ + <> + {!isTranscribing && ( + + )} + + + } + /> + + + Transcription backlog is empty for the selected source + + + } + isLoading={transcriptionBacklog.isLoading} + isError={transcriptionBacklog.isError} + tableStructure={tableStructure} + tableHeaderComponent={ + + {`Transcription Backlog (${selectedSource})`} + + } + enableCheckboxes + selectedRowIds={addToQueueSelection} + onSelectedRowIdsChange={setAddToQueueSelection} + getRowId={(row) => row.media} + actionItems={ + <> + + setSelectedSource(source)} + /> + + } + /> + + + ); +}; + +export default Sources; diff --git a/src/services/api/admin/index.ts b/src/services/api/admin/index.ts index e0ff4c80..c6d14ff1 100644 --- a/src/services/api/admin/index.ts +++ b/src/services/api/admin/index.ts @@ -1,3 +1,5 @@ export * from "./useTransaction"; export * from "./useReviews"; export * from "./useUsers"; +export * from "./useTranscriptionQueue"; +export * from "./useTranscriptionBacklog"; diff --git a/src/services/api/admin/useTranscriptionBacklog.tsx b/src/services/api/admin/useTranscriptionBacklog.tsx new file mode 100644 index 00000000..80eb2d69 --- /dev/null +++ b/src/services/api/admin/useTranscriptionBacklog.tsx @@ -0,0 +1,107 @@ +import axios from "axios"; +import { useQuery } from "@tanstack/react-query"; + +import { TranscriptMetadata, SourceType } from "../../../../types"; + +// TODO: move this to a new folder when more endpoints are added +const transcriptionServerAxios = axios.create({ + baseURL: process.env.NEXT_PUBLIC_APP_TRANSCRIPTION_BASE_URL ?? "", +}); + +const getSources = async (): Promise => { + const result = await transcriptionServerAxios.post(`/curator/get_sources/`, { + // for now we only care for full coverage sources + coverage: "full", + }); + return result.data.data; +}; + +const getTranscriptionBacklogForSource = async ( + sources: SourceType[] +): Promise => { + const jsonString = JSON.stringify(sources); + const file = new File([jsonString], "sources.json", { + type: "application/json", + }); + const formData = new FormData(); + formData.append("source_file", file); + + const result = await transcriptionServerAxios.post( + "/transcription/preprocess/", + formData, + { + headers: { + "Content-Type": "multipart/form-data", + }, + } + ); + + return result.data.data; +}; + +const getTranscriptionBacklog = async (): Promise => { + const result = await transcriptionServerAxios.post( + "curator/get_transcription_backlog/" + ); + return result.data.data; +}; + +export const useTranscriptionBacklog = (selectedSource: string) => { + const { data: sources, isLoading: sourcesIsLoading } = useQuery( + { + queryFn: getSources, + queryKey: ["sources"], + refetchOnWindowFocus: false, + } + ); + + const filteredSources = + selectedSource === "all" + ? sources || [] + : (sources || []).filter((source) => source.loc === selectedSource); + + const queryGetTranscriptionBacklogForSource = useQuery({ + queryFn: () => getTranscriptionBacklogForSource(filteredSources), + queryKey: ["transcription-backlog-source", selectedSource], + refetchOnWindowFocus: false, + enabled: sources !== undefined, + }); + + const queryGetTranscriptionBacklog = useQuery({ + queryFn: getTranscriptionBacklog, + queryKey: ["transcription-backlog"], + refetchOnWindowFocus: false, + // we display the transcription backlog (`needs: transcript`) + // only when "all" is selected + enabled: selectedSource === "all", + }); + + const transcriptionBacklog = + selectedSource === "all" + ? [ + ...(queryGetTranscriptionBacklogForSource.data || []), + ...(queryGetTranscriptionBacklog.data || []), + ] + : queryGetTranscriptionBacklogForSource.data || []; + + const sourcesList = sources + ? [...sources.map((source) => source.loc), "all"] + : []; + + const isLoading = + queryGetTranscriptionBacklogForSource.isLoading || + (selectedSource === "all" && queryGetTranscriptionBacklog.isLoading); + + const isError = + queryGetTranscriptionBacklogForSource.isError || + (selectedSource === "all" && queryGetTranscriptionBacklog.isError); + + return { + transcriptionBacklog: { + data: transcriptionBacklog, + isLoading: isLoading, + isError: isError, + }, + sources: { data: sourcesList, isLoading: sourcesIsLoading }, + }; +}; diff --git a/src/services/api/admin/useTranscriptionQueue.ts b/src/services/api/admin/useTranscriptionQueue.ts new file mode 100644 index 00000000..3c5ce1e9 --- /dev/null +++ b/src/services/api/admin/useTranscriptionQueue.ts @@ -0,0 +1,195 @@ +import { useMemo } from "react"; +import axios from "axios"; +import { useSession } from "next-auth/react"; +import { useToast } from "@chakra-ui/react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { TranscriptMetadata, TranscriptionQueueItem } from "../../../../types"; + +// TODO: move this to a new folder when more endpoints are added +const transcriptionServerAxios = axios.create({ + baseURL: process.env.NEXT_PUBLIC_APP_TRANSCRIPTION_BASE_URL ?? "", +}); + +const addToQueue = async ({ + items, + githubUsername, +}: { + items: TranscriptMetadata[]; + githubUsername: string; +}) => { + const jsonString = JSON.stringify(items); + const file = new File([jsonString], "items_for_transcription.json", { + type: "application/json", + }); + const formData = new FormData(); + formData.append("source_file", file); + // default options + formData.append("nocheck", String(true)); + formData.append("deepgram", String(true)); + formData.append("diarize", String(true)); + formData.append("github", String(true)); + // record of who initiate the transcription + formData.append("username", githubUsername); + + const result = await transcriptionServerAxios.post( + `/transcription/add_to_queue/`, + formData, + { + headers: { + "Content-Type": "multipart/form-data", + }, + } + ); + return result.data; +}; + +const removeFromQueue = async (items: TranscriptMetadata[]) => { + const jsonString = JSON.stringify(items); + const file = new File([jsonString], "items_for_removal.json", { + type: "application/json", + }); + const formData = new FormData(); + formData.append("source_file", file); + const result = await transcriptionServerAxios.post( + `/transcription/remove_from_queue/`, + formData, + { + headers: { + "Content-Type": "multipart/form-data", + }, + } + ); + return result.data; +}; + +const getQueue = async (): Promise => { + const result = await transcriptionServerAxios.get("/transcription/queue/"); + return result.data.data; +}; + +const startTranscription = async () => { + const result = await transcriptionServerAxios.post("/transcription/start/"); + return result.data; +}; + +export const useTranscriptionQueue = ( + transcriptionBacklog: TranscriptMetadata[] +) => { + const toast = useToast(); + const queryClient = useQueryClient(); + const { data: userSession } = useSession(); + + const queryTranscriptionQueue = useQuery({ + queryFn: getQueue, + queryKey: ["transcription-queue"], + refetchInterval: 300000, // Refetch every 5 minute + refetchIntervalInBackground: true, + refetchOnWindowFocus: true, + }); + + const remainingBacklog = useMemo(() => { + if (!transcriptionBacklog || !queryTranscriptionQueue.data) { + return []; + } + const queueMediaIds = new Set( + queryTranscriptionQueue.data.map((item) => item.media) + ); + return transcriptionBacklog.filter( + (item) => !queueMediaIds.has(item.media) + ); + }, [transcriptionBacklog, queryTranscriptionQueue.data]); + + const isTranscribing = useMemo(() => { + if (!queryTranscriptionQueue.data) return false; + return queryTranscriptionQueue.data.some( + (item) => item.status === "in_progress" + ); + }, [queryTranscriptionQueue.data]); + + const mutationAddToQueue = useMutation(addToQueue, { + onSuccess: () => { + queryClient.invalidateQueries(["transcription-queue"]); + toast({ + title: "Source added to transcription queue", + status: "success", + }); + }, + onError: (error: any) => { + toast({ + title: "Error with transcription queue", + description: + error.response?.data?.detail || "An unknown error occurred.", + status: "error", + }); + }, + }); + + const mutationRemoveFromQueue = useMutation(removeFromQueue, { + onSuccess: () => { + queryClient.invalidateQueries(["transcription-queue"]); + toast({ + title: "Source removed from transcription queue", + status: "success", + }); + }, + onError: (error: any) => { + toast({ + title: "Error with removing from queue", + description: + error.response?.data?.detail || "An unknown error occurred.", + status: "error", + }); + }, + }); + + const addToQueueWithIds = async (ids: string[]) => { + if (!transcriptionBacklog) return; + const itemsToAdd = transcriptionBacklog.filter((item) => + ids.includes(item.media) + ); + await mutationAddToQueue.mutateAsync({ + items: itemsToAdd, + githubUsername: userSession?.user?.githubUsername ?? "", + }); + }; + + const removeFromQueueWithIds = async (ids: string[]) => { + if (!queryTranscriptionQueue.data) return; + const itemsToRemove = queryTranscriptionQueue.data.filter((item) => + ids.includes(item.media) + ); + await mutationRemoveFromQueue.mutateAsync(itemsToRemove); + }; + + const mutationStartTranscription = useMutation(startTranscription, { + onSuccess: () => { + queryClient.invalidateQueries(["transcription-queue"]); + toast({ + title: "Transcription process started", + status: "success", + }); + }, + onError: (error: any) => { + toast({ + title: "Error starting transcription", + description: + error.response?.data?.detail || "An unknown error occurred.", + status: "error", + }); + }, + }); + + return { + transcriptionQueue: queryTranscriptionQueue, + remainingBacklog, + addToQueue: { ...mutationAddToQueue, mutateAsync: addToQueueWithIds }, + removeFromQueue: { + ...mutationRemoveFromQueue, + mutateAsync: removeFromQueueWithIds, + }, + startTranscription: mutationStartTranscription, + isTranscribing, + refetch: queryTranscriptionQueue.refetch, + }; +}; diff --git a/types.ts b/types.ts index 7fb2145f..03ea82cf 100644 --- a/types.ts +++ b/types.ts @@ -88,6 +88,18 @@ export type TranscriptMetadata = { tags: string[]; title: string; transcript_by: string; + loc: string; +}; + +export type TranscriptionQueueItem = TranscriptMetadata & { status: string }; + +export type SourceType = { + title: string; + source: string; + categories: string[]; + loc: string; + cutoff_date: string; + transcription_coverage: "full" | "none"; }; export type DigitalPaperEditWord = {