Skip to content

Commit 1c578fd

Browse files
feat: Uppy-based audio upload for web app with shared storage helpers (#3983)
* 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 <yujonglee.dev@gmail.com> * fix: remove unused @uppy/drag-drop, fix race condition with autoProceed Co-Authored-By: yujonglee <yujonglee.dev@gmail.com> * fix: address review feedback - error handling, race condition guard, remove unused deps, fix formatting Co-Authored-By: yujonglee <yujonglee.dev@gmail.com> * fix: increment generationRef in reset() to prevent stale handler execution Co-Authored-By: yujonglee <yujonglee.dev@gmail.com> * fix: map uppyStatus error to status state machine so TranscriptDisplay shows error UI Co-Authored-By: yujonglee <yujonglee.dev@gmail.com> * fix: add generation guard to all Uppy event handlers to prevent stale event corruption Co-Authored-By: yujonglee <yujonglee.dev@gmail.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: yujonglee <yujonglee.dev@gmail.com>
1 parent 4ca1b78 commit 1c578fd

File tree

6 files changed

+587
-118
lines changed

6 files changed

+587
-118
lines changed

apps/web/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@
4343
"@tanstack/react-start": "^1.159.5",
4444
"@tanstack/router-plugin": "^1.159.5",
4545
"@unpic/react": "^1.0.2",
46+
"@uppy/core": "^5.2.0",
47+
"@uppy/tus": "^5.1.1",
4648
"chart.js": "^4.5.1",
4749
"dayjs": "^1.11.19",
4850
"drizzle-orm": "^0.44.7",

apps/web/src/components/transcription/transcript-display.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,12 +90,14 @@ export function FileInfo({
9090
onRemove,
9191
isUploading,
9292
isProcessing,
93+
uploadProgress,
9394
}: {
9495
fileName: string;
9596
fileSize: number;
9697
onRemove: () => void;
9798
isUploading?: boolean;
9899
isProcessing?: boolean;
100+
uploadProgress?: number;
99101
}) {
100102
const formatSize = (bytes: number) => {
101103
if (bytes < 1024) return `${bytes} B`;
@@ -122,7 +124,9 @@ export function FileInfo({
122124
{fileName}
123125
</p>
124126
<p className="text-xs text-neutral-500">
125-
{isUploading ? "Uploading..." : formatSize(fileSize)}
127+
{isUploading
128+
? `Uploading... ${uploadProgress != null ? `${Math.round(uploadProgress)}%` : ""}`
129+
: formatSize(fileSize)}
126130
</p>
127131
</div>
128132
</div>
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import Uppy, { type UploadResult } from "@uppy/core";
2+
import Tus from "@uppy/tus";
3+
import { useEffect, useMemo, useRef, useState } from "react";
4+
5+
import {
6+
buildObjectName,
7+
getTusEndpoint,
8+
STORAGE_CONFIG,
9+
} from "@hypr/supabase/storage";
10+
11+
import { env } from "@/env";
12+
import { getSupabaseBrowserClient } from "@/functions/supabase";
13+
14+
async function getAuthHeaders(): Promise<Record<string, string>> {
15+
const supabase = getSupabaseBrowserClient();
16+
const { data } = await supabase.auth.getSession();
17+
const token = data?.session?.access_token;
18+
if (!token) throw new Error("Not authenticated");
19+
return {
20+
authorization: `Bearer ${token}`,
21+
"x-upsert": "true",
22+
};
23+
}
24+
25+
type UploadState = {
26+
status: "idle" | "uploading" | "done" | "error";
27+
progress: number;
28+
fileId: string | null;
29+
error: string | null;
30+
};
31+
32+
export function useAudioUppy() {
33+
const [state, setState] = useState<UploadState>({
34+
status: "idle",
35+
progress: 0,
36+
fileId: null,
37+
error: null,
38+
});
39+
40+
const uppyRef = useRef<Uppy | null>(null);
41+
const generationRef = useRef(0);
42+
const activeGenerationRef = useRef(0);
43+
44+
const uppy = useMemo(() => {
45+
const instance = new Uppy({
46+
restrictions: {
47+
maxNumberOfFiles: 1,
48+
allowedFileTypes: ["audio/*"],
49+
},
50+
autoProceed: false,
51+
});
52+
53+
instance.use(Tus, {
54+
endpoint: getTusEndpoint(env.VITE_SUPABASE_URL!),
55+
chunkSize: STORAGE_CONFIG.chunkSize,
56+
retryDelays: [...STORAGE_CONFIG.retryDelays],
57+
uploadDataDuringCreation: true,
58+
removeFingerprintOnSuccess: true,
59+
allowedMetaFields: [
60+
"bucketName",
61+
"objectName",
62+
"contentType",
63+
"cacheControl",
64+
],
65+
onBeforeRequest: async (req) => {
66+
const headers = await getAuthHeaders();
67+
for (const [key, value] of Object.entries(headers)) {
68+
req.setHeader(key, value);
69+
}
70+
},
71+
onShouldRetry: (err, _retryAttempt, _options, next) => next(err),
72+
});
73+
74+
uppyRef.current = instance;
75+
return instance;
76+
}, []);
77+
78+
useEffect(() => {
79+
const onFileAdded = async (file: {
80+
id: string;
81+
name: string;
82+
type?: string;
83+
}) => {
84+
const generation = generationRef.current;
85+
const supabase = getSupabaseBrowserClient();
86+
const { data } = await supabase.auth.getSession();
87+
if (generation !== generationRef.current) return;
88+
89+
const userId = data?.session?.user?.id;
90+
if (!userId) {
91+
setState((prev) => ({
92+
...prev,
93+
status: "error",
94+
error: "Not authenticated",
95+
}));
96+
return;
97+
}
98+
99+
const objectName = buildObjectName(userId, file.name);
100+
uppy.setFileMeta(file.id, {
101+
bucketName: STORAGE_CONFIG.bucketName,
102+
objectName,
103+
contentType: file.type || "audio/mpeg",
104+
cacheControl: "3600",
105+
});
106+
107+
activeGenerationRef.current = generationRef.current;
108+
109+
setState({
110+
status: "uploading",
111+
progress: 0,
112+
fileId: objectName,
113+
error: null,
114+
});
115+
116+
uppy.upload();
117+
};
118+
119+
const onProgress = (progress: number) => {
120+
if (generationRef.current !== activeGenerationRef.current) return;
121+
setState((prev) => ({ ...prev, progress }));
122+
};
123+
124+
const onComplete = (
125+
result: UploadResult<Record<string, unknown>, Record<string, never>>,
126+
) => {
127+
if (generationRef.current !== activeGenerationRef.current) return;
128+
if (result.failed && result.failed.length > 0) {
129+
setState((prev) => ({
130+
...prev,
131+
status: "error",
132+
error: "Upload failed",
133+
}));
134+
} else {
135+
setState((prev) => ({ ...prev, status: "done", progress: 100 }));
136+
}
137+
};
138+
139+
const onUploadError = (_file: unknown, error: Error) => {
140+
if (generationRef.current !== activeGenerationRef.current) return;
141+
setState((prev) => ({ ...prev, status: "error", error: error.message }));
142+
};
143+
144+
const onError = (error: Error) => {
145+
if (generationRef.current !== activeGenerationRef.current) return;
146+
setState((prev) => ({
147+
...prev,
148+
status: "error",
149+
error: error.message,
150+
}));
151+
};
152+
153+
uppy.on("file-added", onFileAdded);
154+
uppy.on("progress", onProgress);
155+
uppy.on("complete", onComplete);
156+
uppy.on("error", onError);
157+
uppy.on("upload-error", onUploadError);
158+
159+
return () => {
160+
uppy.off("file-added", onFileAdded);
161+
uppy.off("progress", onProgress);
162+
uppy.off("complete", onComplete);
163+
uppy.off("error", onError);
164+
uppy.off("upload-error", onUploadError);
165+
};
166+
}, [uppy]);
167+
168+
useEffect(() => {
169+
return () => {
170+
uppyRef.current?.cancelAll();
171+
};
172+
}, []);
173+
174+
const addFile = (file: File) => {
175+
generationRef.current++;
176+
uppy.cancelAll();
177+
setState({ status: "idle", progress: 0, fileId: null, error: null });
178+
uppy.addFile({
179+
name: file.name,
180+
type: file.type,
181+
data: file,
182+
});
183+
};
184+
185+
const reset = () => {
186+
generationRef.current++;
187+
uppy.cancelAll();
188+
setState({ status: "idle", progress: 0, fileId: null, error: null });
189+
};
190+
191+
return {
192+
addFile,
193+
reset,
194+
status: state.status,
195+
progress: state.progress,
196+
fileId: state.fileId,
197+
error: state.error,
198+
};
199+
}

apps/web/src/routes/_view/app/file-transcription.tsx

Lines changed: 22 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { Play } from "lucide-react";
44
import { useEffect, useMemo, useState } from "react";
55

66
import type { SttStatusResponse } from "@hypr/api-client";
7-
import { uploadAudio } from "@hypr/supabase/storage";
87
import NoteEditor, { type JSONContent } from "@hypr/tiptap/editor";
98
import { EMPTY_TIPTAP_DOC } from "@hypr/tiptap/shared";
109
import "@hypr/tiptap/styles.css";
@@ -17,6 +16,7 @@ import {
1716
import { UploadArea } from "@/components/transcription/upload-area";
1817
import { env } from "@/env";
1918
import { getSupabaseBrowserClient } from "@/functions/supabase";
19+
import { useAudioUppy } from "@/hooks/use-audio-uppy";
2020

2121
const API_URL = env.VITE_API_URL;
2222

@@ -74,12 +74,20 @@ function Component() {
7474
const { id: searchId } = Route.useSearch();
7575

7676
const [file, setFile] = useState<File | null>(null);
77-
const [fileId, setFileId] = useState<string | null>(null);
7877
const [pipelineId, setPipelineId] = useState<string | null>(searchId ?? null);
7978
const [transcript, setTranscript] = useState<string | null>(null);
8079
const [noteContent, setNoteContent] = useState<JSONContent>(EMPTY_TIPTAP_DOC);
8180
const [isMounted, setIsMounted] = useState(false);
8281

82+
const {
83+
addFile: uppyAddFile,
84+
reset: uppyReset,
85+
status: uppyStatus,
86+
progress: uppyProgress,
87+
fileId: uppyFileId,
88+
error: uppyError,
89+
} = useAudioUppy();
90+
8391
useEffect(() => {
8492
setIsMounted(true);
8593
}, []);
@@ -94,26 +102,6 @@ function Component() {
94102
return session;
95103
};
96104

97-
const uploadMutation = useMutation({
98-
mutationFn: async (selectedFile: File) => {
99-
const session = await getAccessToken();
100-
101-
const { promise } = uploadAudio({
102-
file: selectedFile,
103-
fileName: selectedFile.name,
104-
contentType: selectedFile.type,
105-
supabaseUrl: env.VITE_SUPABASE_URL!,
106-
accessToken: session.access_token,
107-
userId: session.user.id,
108-
});
109-
110-
return promise;
111-
},
112-
onSuccess: (newFileId) => {
113-
setFileId(newFileId);
114-
},
115-
});
116-
117105
const startPipelineMutation = useMutation({
118106
mutationFn: async (fileIdArg: string) => {
119107
const session = await getAccessToken();
@@ -171,19 +159,20 @@ function Component() {
171159
if (pipelineStatus === "QUEUED" || pipelineId) {
172160
return "queued" as const;
173161
}
174-
if (uploadMutation.isPending) {
162+
if (uppyStatus === "uploading") {
175163
return "uploading" as const;
176164
}
177-
if (fileId) {
165+
if (uppyStatus === "error") {
166+
return "error" as const;
167+
}
168+
if (uppyStatus === "done" && uppyFileId) {
178169
return "uploaded" as const;
179170
}
180171
return "idle" as const;
181172
})();
182173

183174
const errorMessage =
184-
(uploadMutation.error instanceof Error
185-
? uploadMutation.error.message
186-
: null) ??
175+
uppyError ??
187176
(startPipelineMutation.error instanceof Error
188177
? startPipelineMutation.error.message
189178
: null) ??
@@ -196,27 +185,24 @@ function Component() {
196185

197186
const handleFileSelect = (selectedFile: File) => {
198187
setFile(selectedFile);
199-
setFileId(null);
200188
setPipelineId(null);
201189
setTranscript(null);
202-
uploadMutation.reset();
203190
startPipelineMutation.reset();
204-
uploadMutation.mutate(selectedFile);
191+
uppyAddFile(selectedFile);
205192
};
206193

207194
const handleStartTranscription = () => {
208-
if (!fileId) return;
209-
startPipelineMutation.mutate(fileId);
195+
if (!uppyFileId) return;
196+
startPipelineMutation.mutate(uppyFileId);
210197
};
211198

212199
const handleRemoveFile = () => {
213200
setFile(null);
214-
setFileId(null);
215201
setPipelineId(null);
216202
setTranscript(null);
217203
setNoteContent(EMPTY_TIPTAP_DOC);
218-
uploadMutation.reset();
219204
startPipelineMutation.reset();
205+
uppyReset();
220206
};
221207

222208
const mentionConfig = useMemo(
@@ -286,8 +272,9 @@ function Component() {
286272
fileName={file.name}
287273
fileSize={file.size}
288274
onRemove={handleRemoveFile}
289-
isUploading={uploadMutation.isPending}
275+
isUploading={uppyStatus === "uploading"}
290276
isProcessing={isProcessing}
277+
uploadProgress={uppyProgress}
291278
/>
292279
{status === "uploaded" && (
293280
<button

0 commit comments

Comments
 (0)