From 55f83cf5f4b94eba797810e8420f51bb97de3b70 Mon Sep 17 00:00:00 2001 From: Mathieu Virbel Date: Thu, 28 Aug 2025 00:07:47 -0600 Subject: [PATCH] feat: migrate components to React Query hooks - Add comprehensive API hooks for all operations - Migrate rooms page to use React Query mutations - Update transcript title component to use mutation hook - Refactor share/privacy component with proper error handling - Remove direct API client usage in favor of hooks --- www/app/(app)/rooms/page.tsx | 86 ++--- www/app/(app)/transcripts/shareAndPrivacy.tsx | 34 +- www/app/(app)/transcripts/transcriptTitle.tsx | 17 +- www/app/lib/api-hooks.ts | 319 ++++++++++++++++++ 4 files changed, 385 insertions(+), 71 deletions(-) diff --git a/www/app/(app)/rooms/page.tsx b/www/app/(app)/rooms/page.tsx index 979de31c..0a8f68d8 100644 --- a/www/app/(app)/rooms/page.tsx +++ b/www/app/(app)/rooms/page.tsx @@ -15,9 +15,15 @@ import { useDisclosure, } from "@chakra-ui/react"; import { useEffect, useState } from "react"; -import useApi from "../../lib/useApi"; import useRoomList from "./useRoomList"; import { ApiError, Room } from "../../lib/api-types"; +import { + useRoomCreate, + useRoomUpdate, + useRoomDelete, + useZulipStreams, + useZulipTopics, +} from "../../lib/api-hooks"; import { RoomList } from "./_components/RoomList"; import { PaginationPage } from "../browse/_components/Pagination"; @@ -75,64 +81,42 @@ export default function RoomsList() { const [room, setRoom] = useState(roomInitialState); const [isEditing, setIsEditing] = useState(false); const [editRoomId, setEditRoomId] = useState(""); - const api = useApi(); // TODO seems to be no setPage calls const [page, setPage] = useState(1); const { loading, response, refetch } = useRoomList(PaginationPage(page)); - const [streams, setStreams] = useState([]); - const [topics, setTopics] = useState([]); const [nameError, setNameError] = useState(""); const [linkCopied, setLinkCopied] = useState(""); - interface Stream { - stream_id: number; - name: string; - } + const [selectedStreamId, setSelectedStreamId] = useState(null); + // React Query hooks + const createRoomMutation = useRoomCreate(); + const updateRoomMutation = useRoomUpdate(); + const deleteRoomMutation = useRoomDelete(); + const { data: streams = [] } = useZulipStreams(); + const { data: topics = [] } = useZulipTopics(selectedStreamId); interface Topic { name: string; } + // Update selected stream ID when zulip stream changes useEffect(() => { - const fetchZulipStreams = async () => { - if (!api) return; - - try { - const response = await api.v1ZulipGetStreams(); - setStreams(response); - } catch (error) { - console.error("Error fetching Zulip streams:", error); + if (room.zulipStream && streams.length > 0) { + const selectedStream = streams.find( + (s: any) => s.name === room.zulipStream, + ); + if (selectedStream) { + setSelectedStreamId((selectedStream as any).stream_id); } - }; - - if (room.zulipAutoPost) { - fetchZulipStreams(); + } else { + setSelectedStreamId(null); } - }, [room.zulipAutoPost, !api]); + }, [room.zulipStream, streams]); - useEffect(() => { - const fetchZulipTopics = async () => { - if (!api || !room.zulipStream) return; - try { - const selectedStream = streams.find((s) => s.name === room.zulipStream); - if (selectedStream) { - const response = await api.v1ZulipGetTopics({ - streamId: selectedStream.stream_id, - }); - setTopics(response); - } - } catch (error) { - console.error("Error fetching Zulip topics:", error); - } - }; - - fetchZulipTopics(); - }, [room.zulipStream, streams, api]); - - const streamOptions: SelectOption[] = streams.map((stream) => { + const streamOptions: SelectOption[] = streams.map((stream: any) => { return { label: stream.name, value: stream.name }; }); - const topicOptions: SelectOption[] = topics.map((topic) => ({ + const topicOptions: SelectOption[] = topics.map((topic: any) => ({ label: topic.name, value: topic.name, })); @@ -175,13 +159,15 @@ export default function RoomsList() { }; if (isEditing) { - await api?.v1RoomsUpdate({ - roomId: editRoomId, - requestBody: roomData, + await updateRoomMutation.mutateAsync({ + params: { + path: { room_id: editRoomId }, + }, + body: roomData, }); } else { - await api?.v1RoomsCreate({ - requestBody: roomData, + await createRoomMutation.mutateAsync({ + body: roomData, }); } @@ -226,8 +212,10 @@ export default function RoomsList() { const handleDeleteRoom = async (roomId: string) => { try { - await api?.v1RoomsDelete({ - roomId, + await deleteRoomMutation.mutateAsync({ + params: { + path: { room_id: roomId }, + }, }); refetch(); } catch (err) { diff --git a/www/app/(app)/transcripts/shareAndPrivacy.tsx b/www/app/(app)/transcripts/shareAndPrivacy.tsx index 37e84bb3..4c46630c 100644 --- a/www/app/(app)/transcripts/shareAndPrivacy.tsx +++ b/www/app/(app)/transcripts/shareAndPrivacy.tsx @@ -19,7 +19,7 @@ import { createListCollection, } from "@chakra-ui/react"; import { LuShare2 } from "react-icons/lu"; -import useApi from "../../lib/useApi"; +import { useTranscriptUpdate } from "../../lib/api-hooks"; import useSessionUser from "../../lib/useSessionUser"; import { CustomSession } from "../../lib/types"; import ShareLink from "./shareLink"; @@ -54,12 +54,9 @@ export default function ShareAndPrivacy(props: ShareAndPrivacyProps) { ); const [shareLoading, setShareLoading] = useState(false); const requireLogin = featureEnabled("requireLogin"); - const api = useApi(); + const updateTranscriptMutation = useTranscriptUpdate(); const updateShareMode = async (selectedValue: string) => { - if (!api) - throw new Error("ShareLink's API should always be ready at this point"); - const selectedOption = shareOptionsData.find( (option) => option.value === selectedValue, ); @@ -71,16 +68,23 @@ export default function ShareAndPrivacy(props: ShareAndPrivacyProps) { share_mode: selectedValue as "public" | "semi-private" | "private", }; - const updatedTranscript = await api.v1TranscriptUpdate({ - transcriptId: props.transcriptResponse.id, - requestBody, - }); - setShareMode( - shareOptionsData.find( - (option) => option.value === updatedTranscript.share_mode, - ) || shareOptionsData[0], - ); - setShareLoading(false); + try { + const updatedTranscript = await updateTranscriptMutation.mutateAsync({ + params: { + path: { transcript_id: props.transcriptResponse.id }, + }, + body: requestBody, + }); + setShareMode( + shareOptionsData.find( + (option) => option.value === (updatedTranscript as any).share_mode, + ) || shareOptionsData[0], + ); + } catch (err) { + console.error("Failed to update share mode:", err); + } finally { + setShareLoading(false); + } }; const userId = useSessionUser().id; diff --git a/www/app/(app)/transcripts/transcriptTitle.tsx b/www/app/(app)/transcripts/transcriptTitle.tsx index 2413de10..4a8468b3 100644 --- a/www/app/(app)/transcripts/transcriptTitle.tsx +++ b/www/app/(app)/transcripts/transcriptTitle.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { UpdateTranscript } from "../../lib/api-types"; -import useApi from "../../lib/useApi"; +import { useTranscriptUpdate } from "../../lib/api-hooks"; import { Heading, IconButton, Input, Flex, Spacer } from "@chakra-ui/react"; import { LuPen } from "react-icons/lu"; @@ -14,24 +14,27 @@ const TranscriptTitle = (props: TranscriptTitle) => { const [displayedTitle, setDisplayedTitle] = useState(props.title); const [preEditTitle, setPreEditTitle] = useState(props.title); const [isEditing, setIsEditing] = useState(false); - const api = useApi(); + const updateTranscriptMutation = useTranscriptUpdate(); const updateTitle = async (newTitle: string, transcriptId: string) => { - if (!api) return; try { const requestBody: UpdateTranscript = { title: newTitle, }; - const updatedTranscript = await api?.v1TranscriptUpdate({ - transcriptId, - requestBody, + await updateTranscriptMutation.mutateAsync({ + params: { + path: { transcript_id: transcriptId }, + }, + body: requestBody, }); if (props.onUpdate) { props.onUpdate(newTitle); } - console.log("Updated transcript:", updatedTranscript); + console.log("Updated transcript title:", newTitle); } catch (err) { console.error("Failed to update transcript:", err); + // Revert title on error + setDisplayedTitle(preEditTitle); } }; diff --git a/www/app/lib/api-hooks.ts b/www/app/lib/api-hooks.ts index 4b67e7af..55a5be2f 100644 --- a/www/app/lib/api-hooks.ts +++ b/www/app/lib/api-hooks.ts @@ -193,3 +193,322 @@ export function useZulipTopics(streamId: number | null) { }, ); } + +// Transcript mutations +export function useTranscriptUpdate() { + const { setError } = useError(); + const queryClient = useQueryClient(); + + return $api.useMutation("patch", "/v1/transcripts/{transcript_id}", { + onSuccess: (data, variables) => { + // Invalidate and refetch transcript data + queryClient.invalidateQueries({ + queryKey: $api.queryOptions("get", "/v1/transcripts/{transcript_id}", { + params: { + path: { transcript_id: variables.params.path.transcript_id }, + }, + }).queryKey, + }); + }, + onError: (error) => { + setError(error as Error, "There was an error updating the transcript"); + }, + }); +} + +export function useTranscriptPostToZulip() { + const { setError } = useError(); + + return $api.useMutation("post", "/v1/transcripts/{transcript_id}/zulip", { + onError: (error) => { + setError(error as Error, "There was an error posting to Zulip"); + }, + }); +} + +export function useTranscriptUploadAudio() { + const { setError } = useError(); + const queryClient = useQueryClient(); + + return $api.useMutation( + "post", + "/v1/transcripts/{transcript_id}/record/upload", + { + onSuccess: (data, variables) => { + // Invalidate transcript to refresh status + queryClient.invalidateQueries({ + queryKey: $api.queryOptions( + "get", + "/v1/transcripts/{transcript_id}", + { + params: { + path: { transcript_id: variables.params.path.transcript_id }, + }, + }, + ).queryKey, + }); + }, + onError: (error) => { + setError(error as Error, "There was an error uploading the audio file"); + }, + }, + ); +} + +// Transcript queries +export function useTranscriptWaveform(transcriptId: string | null) { + const { setError } = useError(); + + return $api.useQuery( + "get", + "/v1/transcripts/{transcript_id}/audio/waveform", + { + params: { + path: { transcript_id: transcriptId || "" }, + }, + }, + { + enabled: !!transcriptId, + onError: (error) => { + setError(error as Error, "There was an error fetching the waveform"); + }, + }, + ); +} + +export function useTranscriptMP3(transcriptId: string | null) { + const { setError } = useError(); + + return $api.useQuery( + "get", + "/v1/transcripts/{transcript_id}/audio/mp3", + { + params: { + path: { transcript_id: transcriptId || "" }, + }, + }, + { + enabled: !!transcriptId, + onError: (error) => { + setError(error as Error, "There was an error fetching the MP3"); + }, + }, + ); +} + +export function useTranscriptTopics(transcriptId: string | null) { + const { setError } = useError(); + + return $api.useQuery( + "get", + "/v1/transcripts/{transcript_id}/topics", + { + params: { + path: { transcript_id: transcriptId || "" }, + }, + }, + { + enabled: !!transcriptId, + onError: (error) => { + setError(error as Error, "There was an error fetching topics"); + }, + }, + ); +} + +export function useTranscriptTopicsWithWords(transcriptId: string | null) { + const { setError } = useError(); + + return $api.useQuery( + "get", + "/v1/transcripts/{transcript_id}/topics/with-words", + { + params: { + path: { transcript_id: transcriptId || "" }, + }, + }, + { + enabled: !!transcriptId, + onError: (error) => { + setError( + error as Error, + "There was an error fetching topics with words", + ); + }, + }, + ); +} + +// Participant operations +export function useTranscriptParticipants(transcriptId: string | null) { + const { setError } = useError(); + + return $api.useQuery( + "get", + "/v1/transcripts/{transcript_id}/participants", + { + params: { + path: { transcript_id: transcriptId || "" }, + }, + }, + { + enabled: !!transcriptId, + onError: (error) => { + setError(error as Error, "There was an error fetching participants"); + }, + }, + ); +} + +export function useTranscriptParticipantUpdate() { + const { setError } = useError(); + const queryClient = useQueryClient(); + + return $api.useMutation( + "patch", + "/v1/transcripts/{transcript_id}/participants/{participant_id}", + { + onSuccess: (data, variables) => { + // Invalidate participants list + queryClient.invalidateQueries({ + queryKey: $api.queryOptions( + "get", + "/v1/transcripts/{transcript_id}/participants", + { + params: { + path: { transcript_id: variables.params.path.transcript_id }, + }, + }, + ).queryKey, + }); + }, + onError: (error) => { + setError(error as Error, "There was an error updating the participant"); + }, + }, + ); +} + +export function useTranscriptSpeakerAssign() { + const { setError } = useError(); + const queryClient = useQueryClient(); + + return $api.useMutation( + "post", + "/v1/transcripts/{transcript_id}/speaker/assign", + { + onSuccess: (data, variables) => { + // Invalidate transcript and participants + queryClient.invalidateQueries({ + queryKey: $api.queryOptions( + "get", + "/v1/transcripts/{transcript_id}", + { + params: { + path: { transcript_id: variables.params.path.transcript_id }, + }, + }, + ).queryKey, + }); + queryClient.invalidateQueries({ + queryKey: $api.queryOptions( + "get", + "/v1/transcripts/{transcript_id}/participants", + { + params: { + path: { transcript_id: variables.params.path.transcript_id }, + }, + }, + ).queryKey, + }); + }, + onError: (error) => { + setError(error as Error, "There was an error assigning the speaker"); + }, + }, + ); +} + +export function useTranscriptSpeakerMerge() { + const { setError } = useError(); + const queryClient = useQueryClient(); + + return $api.useMutation( + "post", + "/v1/transcripts/{transcript_id}/speaker/merge", + { + onSuccess: (data, variables) => { + // Invalidate transcript and participants + queryClient.invalidateQueries({ + queryKey: $api.queryOptions( + "get", + "/v1/transcripts/{transcript_id}", + { + params: { + path: { transcript_id: variables.params.path.transcript_id }, + }, + }, + ).queryKey, + }); + queryClient.invalidateQueries({ + queryKey: $api.queryOptions( + "get", + "/v1/transcripts/{transcript_id}/participants", + { + params: { + path: { transcript_id: variables.params.path.transcript_id }, + }, + }, + ).queryKey, + }); + }, + onError: (error) => { + setError(error as Error, "There was an error merging speakers"); + }, + }, + ); +} + +// Meeting operations +export function useMeetingAudioConsent() { + const { setError } = useError(); + + return $api.useMutation("post", "/v1/meetings/{meeting_id}/consent", { + onError: (error) => { + setError(error as Error, "There was an error recording consent"); + }, + }); +} + +// WebRTC operations +export function useTranscriptWebRTC() { + const { setError } = useError(); + + return $api.useMutation( + "post", + "/v1/transcripts/{transcript_id}/record/webrtc", + { + onError: (error) => { + setError(error as Error, "There was an error with WebRTC connection"); + }, + }, + ); +} + +// Transcript creation +export function useTranscriptCreate() { + const { setError } = useError(); + const queryClient = useQueryClient(); + + return $api.useMutation("post", "/v1/transcripts", { + onSuccess: () => { + // Invalidate transcripts list + queryClient.invalidateQueries({ + queryKey: $api.queryOptions("get", "/v1/transcripts/search").queryKey, + }); + }, + onError: (error) => { + setError(error as Error, "There was an error creating the transcript"); + }, + }); +}