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/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/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/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/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/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..8b0d4f21 100644 --- a/src/config/permissions.json +++ b/src/config/permissions.json @@ -3,6 +3,8 @@ "accessReviews": true, "accessUsers": true, "resetReviews": true, + "archiveTranscripts": true, + "accessTranscription": true, "accessTransactions": true, "accessAdminNav": true, "submitToOwnRepo": true @@ -11,6 +13,8 @@ "accessReviews": true, "accessUsers": false, "resetReviews": false, + "archiveTranscripts": true, + "accessTranscription": true, "accessTransactions": false, "accessAdminNav": true, "submitToOwnRepo": false @@ -19,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 f1314e16..107f3c7a 100644 --- a/src/hooks/useHasPermissions.ts +++ b/src/hooks/useHasPermissions.ts @@ -5,7 +5,9 @@ import permissions from "../config/permissions.json"; type Permissions = { accessReviews: boolean; accessUsers: boolean; + accessTranscription: boolean; resetReviews: boolean; + archiveTranscripts: boolean; accessTransactions: boolean; accessAdminNav: boolean; submitToOwnRepo: 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 = {