({
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;
+}) => (
+
+);
+
+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 = {
|