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" && (