From f2bd38e5b9c544b167132baa82787853692be85d Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sun, 15 Feb 2026 10:18:47 +0000 Subject: [PATCH 1/6] feat: add Uppy-based audio upload for web app with shared storage helpers - Extract getTusEndpoint, buildObjectName, STORAGE_CONFIG from packages/supabase/src/storage.ts - Add @uppy/core, @uppy/tus, @uppy/react, @uppy/drag-drop to apps/web - Create useAudioUppy hook using headless Uppy + @uppy/tus for resumable uploads - Wire up authenticated file-transcription route to use Uppy instead of direct tus-js-client - Add upload progress percentage to FileInfo component Co-Authored-By: yujonglee --- apps/web/package.json | 4 + .../transcription/transcript-display.tsx | 6 +- apps/web/src/hooks/use-audio-uppy.ts | 164 ++++++ .../routes/_view/app/file-transcription.tsx | 54 +- packages/supabase/src/storage.ts | 32 +- pnpm-lock.yaml | 486 +++++++++++++++--- 6 files changed, 628 insertions(+), 118 deletions(-) create mode 100644 apps/web/src/hooks/use-audio-uppy.ts diff --git a/apps/web/package.json b/apps/web/package.json index 81a4c23db1..dc0e83d71d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -43,6 +43,10 @@ "@tanstack/react-start": "^1.159.5", "@tanstack/router-plugin": "^1.159.5", "@unpic/react": "^1.0.2", + "@uppy/core": "^5.2.0", + "@uppy/drag-drop": "^5.1.0", + "@uppy/react": "^5.2.0", + "@uppy/tus": "^5.1.1", "chart.js": "^4.5.1", "dayjs": "^1.11.19", "drizzle-orm": "^0.44.7", diff --git a/apps/web/src/components/transcription/transcript-display.tsx b/apps/web/src/components/transcription/transcript-display.tsx index 6b98182910..29c316a2ec 100644 --- a/apps/web/src/components/transcription/transcript-display.tsx +++ b/apps/web/src/components/transcription/transcript-display.tsx @@ -90,12 +90,14 @@ export function FileInfo({ onRemove, isUploading, isProcessing, + uploadProgress, }: { fileName: string; fileSize: number; onRemove: () => void; isUploading?: boolean; isProcessing?: boolean; + uploadProgress?: number; }) { const formatSize = (bytes: number) => { if (bytes < 1024) return `${bytes} B`; @@ -122,7 +124,9 @@ export function FileInfo({ {fileName}

- {isUploading ? "Uploading..." : formatSize(fileSize)} + {isUploading + ? `Uploading... ${uploadProgress != null ? `${Math.round(uploadProgress)}%` : ""}` + : formatSize(fileSize)}

diff --git a/apps/web/src/hooks/use-audio-uppy.ts b/apps/web/src/hooks/use-audio-uppy.ts new file mode 100644 index 0000000000..761ba86916 --- /dev/null +++ b/apps/web/src/hooks/use-audio-uppy.ts @@ -0,0 +1,164 @@ +import Uppy from "@uppy/core"; +import Tus from "@uppy/tus"; +import { useEffect, useMemo, useRef, useState } from "react"; + +import { + buildObjectName, + getTusEndpoint, + STORAGE_CONFIG, +} from "@hypr/supabase/storage"; + +import { env } from "@/env"; +import { getSupabaseBrowserClient } from "@/functions/supabase"; + +async function getAuthHeaders(): Promise> { + const supabase = getSupabaseBrowserClient(); + const { data } = await supabase.auth.getSession(); + const token = data?.session?.access_token; + if (!token) throw new Error("Not authenticated"); + return { + authorization: `Bearer ${token}`, + "x-upsert": "true", + }; +} + +type UploadState = { + status: "idle" | "uploading" | "done" | "error"; + progress: number; + fileId: string | null; + error: string | null; +}; + +export function useAudioUppy() { + const [state, setState] = useState({ + status: "idle", + progress: 0, + fileId: null, + error: null, + }); + + const uppyRef = useRef(null); + + const uppy = useMemo(() => { + const instance = new Uppy({ + restrictions: { + maxNumberOfFiles: 1, + allowedFileTypes: ["audio/*"], + }, + autoProceed: true, + }); + + instance.use(Tus, { + endpoint: getTusEndpoint(env.VITE_SUPABASE_URL!), + chunkSize: STORAGE_CONFIG.chunkSize, + retryDelays: [...STORAGE_CONFIG.retryDelays], + uploadDataDuringCreation: true, + removeFingerprintOnSuccess: true, + allowedMetaFields: [ + "bucketName", + "objectName", + "contentType", + "cacheControl", + ], + onBeforeRequest: async (req) => { + const headers = await getAuthHeaders(); + for (const [key, value] of Object.entries(headers)) { + req.setHeader(key, value); + } + }, + onShouldRetry: (err, _retryAttempt, _options, next) => next(err), + }); + + uppyRef.current = instance; + return instance; + }, []); + + useEffect(() => { + const onFileAdded = async (file: { id: string; name: string; type?: string }) => { + const supabase = getSupabaseBrowserClient(); + const { data } = await supabase.auth.getSession(); + const userId = data?.session?.user?.id; + if (!userId) { + setState((prev) => ({ + ...prev, + status: "error", + error: "Not authenticated", + })); + return; + } + + const objectName = buildObjectName(userId, file.name); + uppy.setFileMeta(file.id, { + bucketName: STORAGE_CONFIG.bucketName, + objectName, + contentType: file.type || "audio/mpeg", + cacheControl: "3600", + }); + + setState({ + status: "uploading", + progress: 0, + fileId: objectName, + error: null, + }); + }; + + const onProgress = (progress: number) => { + setState((prev) => ({ ...prev, progress })); + }; + + const onComplete = () => { + setState((prev) => ({ ...prev, status: "done", progress: 100 })); + }; + + const onError = (error: Error) => { + setState((prev) => ({ + ...prev, + status: "error", + error: error.message, + })); + }; + + uppy.on("file-added", onFileAdded); + uppy.on("progress", onProgress); + uppy.on("complete", onComplete); + uppy.on("error", onError); + + return () => { + uppy.off("file-added", onFileAdded); + uppy.off("progress", onProgress); + uppy.off("complete", onComplete); + uppy.off("error", onError); + }; + }, [uppy]); + + useEffect(() => { + return () => { + uppyRef.current?.cancelAll(); + }; + }, []); + + const addFile = (file: File) => { + uppy.cancelAll(); + setState({ status: "idle", progress: 0, fileId: null, error: null }); + uppy.addFile({ + name: file.name, + type: file.type, + data: file, + }); + }; + + const reset = () => { + uppy.cancelAll(); + setState({ status: "idle", progress: 0, fileId: null, error: null }); + }; + + return { + addFile, + reset, + status: state.status, + progress: state.progress, + fileId: state.fileId, + error: state.error, + }; +} diff --git a/apps/web/src/routes/_view/app/file-transcription.tsx b/apps/web/src/routes/_view/app/file-transcription.tsx index 7d99c4eb26..7542f5eabf 100644 --- a/apps/web/src/routes/_view/app/file-transcription.tsx +++ b/apps/web/src/routes/_view/app/file-transcription.tsx @@ -4,7 +4,6 @@ import { Play } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import type { SttStatusResponse } from "@hypr/api-client"; -import { uploadAudio } from "@hypr/supabase/storage"; import NoteEditor, { type JSONContent } from "@hypr/tiptap/editor"; import { EMPTY_TIPTAP_DOC } from "@hypr/tiptap/shared"; import "@hypr/tiptap/styles.css"; @@ -17,6 +16,7 @@ import { import { UploadArea } from "@/components/transcription/upload-area"; import { env } from "@/env"; import { getSupabaseBrowserClient } from "@/functions/supabase"; +import { useAudioUppy } from "@/hooks/use-audio-uppy"; const API_URL = env.VITE_API_URL; @@ -74,12 +74,20 @@ function Component() { const { id: searchId } = Route.useSearch(); const [file, setFile] = useState(null); - const [fileId, setFileId] = useState(null); const [pipelineId, setPipelineId] = useState(searchId ?? null); const [transcript, setTranscript] = useState(null); const [noteContent, setNoteContent] = useState(EMPTY_TIPTAP_DOC); const [isMounted, setIsMounted] = useState(false); + const { + addFile: uppyAddFile, + reset: uppyReset, + status: uppyStatus, + progress: uppyProgress, + fileId: uppyFileId, + error: uppyError, + } = useAudioUppy(); + useEffect(() => { setIsMounted(true); }, []); @@ -94,26 +102,6 @@ function Component() { return session; }; - const uploadMutation = useMutation({ - mutationFn: async (selectedFile: File) => { - const session = await getAccessToken(); - - const { promise } = uploadAudio({ - file: selectedFile, - fileName: selectedFile.name, - contentType: selectedFile.type, - supabaseUrl: env.VITE_SUPABASE_URL!, - accessToken: session.access_token, - userId: session.user.id, - }); - - return promise; - }, - onSuccess: (newFileId) => { - setFileId(newFileId); - }, - }); - const startPipelineMutation = useMutation({ mutationFn: async (fileIdArg: string) => { const session = await getAccessToken(); @@ -171,19 +159,17 @@ function Component() { if (pipelineStatus === "QUEUED" || pipelineId) { return "queued" as const; } - if (uploadMutation.isPending) { + if (uppyStatus === "uploading") { return "uploading" as const; } - if (fileId) { + if (uppyStatus === "done" && uppyFileId) { return "uploaded" as const; } return "idle" as const; })(); const errorMessage = - (uploadMutation.error instanceof Error - ? uploadMutation.error.message - : null) ?? + uppyError ?? (startPipelineMutation.error instanceof Error ? startPipelineMutation.error.message : null) ?? @@ -196,27 +182,24 @@ function Component() { const handleFileSelect = (selectedFile: File) => { setFile(selectedFile); - setFileId(null); setPipelineId(null); setTranscript(null); - uploadMutation.reset(); startPipelineMutation.reset(); - uploadMutation.mutate(selectedFile); + uppyAddFile(selectedFile); }; const handleStartTranscription = () => { - if (!fileId) return; - startPipelineMutation.mutate(fileId); + if (!uppyFileId) return; + startPipelineMutation.mutate(uppyFileId); }; const handleRemoveFile = () => { setFile(null); - setFileId(null); setPipelineId(null); setTranscript(null); setNoteContent(EMPTY_TIPTAP_DOC); - uploadMutation.reset(); startPipelineMutation.reset(); + uppyReset(); }; const mentionConfig = useMemo( @@ -286,8 +269,9 @@ function Component() { fileName={file.name} fileSize={file.size} onRemove={handleRemoveFile} - isUploading={uploadMutation.isPending} + isUploading={uppyStatus === "uploading"} isProcessing={isProcessing} + uploadProgress={uppyProgress} /> {status === "uploaded" && (