Skip to content

Commit b76ba89

Browse files
authored
Merge pull request #783 from estyemma/feat
[FEAT] Implement Upload Page with XHR Progress
2 parents aa26266 + 319d8f2 commit b76ba89

31 files changed

Lines changed: 2139 additions & 167 deletions

.vscode/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
{
2+
"typescript.autoClosingTags": false
23
}

app/api/upload/route.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,11 @@ import { scanFile, VirusScanError, getScanConfig } from "@/app/lib/virusScan";
4444
import { checkCsrf } from "@/app/lib/csrf";
4545
import { jobStore } from "@/app/api/jobs/shared/jobStore";
4646
import { dispatchJob } from "@/app/lib/aiBackend";
47-
import { MAX_UPLOAD_SIZE_BYTES } from "@/app/lib/constants";
47+
import { MAX_UPLOAD_SIZE_BYTES, MAX_FILES_PER_REQUEST } from "@/app/lib/constants";
48+
import { applyRateLimit } from "@/app/lib/serverRateLimit";
4849
import { logger } from "@/app/lib/logger";
4950

50-
export { MAX_UPLOAD_SIZE_BYTES };
51+
export { MAX_UPLOAD_SIZE_BYTES, MAX_FILES_PER_REQUEST };
5152
const ALLOWED_TYPES = ["video/mp4", "video/quicktime", "video/x-msvideo", "video/x-matroska"];
5253
const ALLOWED_EXTENSIONS = [".mp4", ".mov", ".avi", ".mkv"];
5354

app/hooks/useUploadProgress.ts

Lines changed: 100 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,69 @@
11
/**
2-
* useUploadProgress — Issue #529
2+
* useUploadProgress
33
*
4-
* Custom hook that uploads files via XMLHttpRequest so that
5-
* onprogress events are available for per-file progress bars.
6-
* Returns progress state, upload function, and a cancel function.
4+
* XHR-based multi-file upload hook with:
5+
* - Per-file progress bars (0–100 %)
6+
* - Concurrency cap (default 3 simultaneous uploads)
7+
* - Per-file cancel via cancelFile(name)
8+
* - Cancel-all via cancelAll()
79
*/
810

11+
"use client";
12+
913
import { useState, useCallback, useRef } from "react";
14+
import { UPLOAD_CONCURRENCY } from "@/app/lib/constants";
15+
16+
// ─── Types ────────────────────────────────────────────────────────────────────
1017

11-
/** Tracking container object representing progress metrics for individual files */
18+
/** Per-file progress state */
1219
export type FileProgress = {
13-
/** 0–100 completion percentage indicator */
20+
/** Upload completion percentage 0–100 */
1421
percent: number;
15-
/** State machine status string tracking execution phases */
22+
/** Lifecycle status of this file's upload */
1623
status: "idle" | "uploading" | "done" | "error" | "cancelled";
17-
/** Optional failure details populating on errors */
24+
/** Human-readable error message when status === "error" */
1825
error?: string;
1926
};
2027

21-
/** Metadata payload mapping information returned on successful ingest */
28+
/** Metadata returned on a successful upload */
2229
export type UploadResult = {
23-
/** The server-side async tracking ID associated with processing the artifact */
30+
/** Server-assigned job ID for tracking processing status */
2431
jobId: string;
25-
/** The baseline name string matching the origin payload */
32+
/** Original filename */
2633
name: string;
27-
/** The remote asset destination access endpoint URL */
34+
/** Remote URL of the stored file */
2835
url: string;
2936
};
3037

31-
/**
32-
* Custom hook handling safe parallel multi-file file streaming contexts using underlying XHR event channels.
33-
*
34-
* @returns State properties, status maps, and asynchronous invocation tools for batch execution and manual abort overrides.
35-
*/
36-
export function useUploadProgress() {
38+
// ─── Hook ─────────────────────────────────────────────────────────────────────
39+
40+
export function useUploadProgress(concurrency = UPLOAD_CONCURRENCY) {
3741
const [progresses, setProgresses] = useState<Record<string, FileProgress>>({});
3842
const [results, setResults] = useState<UploadResult[]>([]);
3943
const [isUploading, setIsUploading] = useState(false);
4044

41-
// Keep a ref to all active XHRs so we can abort them
42-
const xhrRefs = useRef<XMLHttpRequest[]>([]);
45+
// Map from file name → active XHR (so individual files can be cancelled)
46+
const xhrMap = useRef<Map<string, XMLHttpRequest>>(new Map());
47+
48+
// ── Helpers ────────────────────────────────────────────────────────────────
4349

44-
/**
45-
* Dispatches micro-mutations matching granular progress shifts per file index.
46-
*/
4750
const setFileProgress = useCallback(
48-
(fileName: string, update: Partial<FileProgress>) => {
51+
(name: string, update: Partial<FileProgress>) => {
4952
setProgresses((prev) => ({
5053
...prev,
51-
[fileName]: { ...prev[fileName], ...update },
54+
[name]: { ...prev[name], ...update },
5255
}));
5356
},
54-
[]
57+
[],
5558
);
5659

57-
/**
58-
* Handles low-level XHR transport initialization, header bundling, and streaming callbacks for an explicit payload.
59-
* * @param file - The distinct binary file target instance requested for transmission.
60-
* @returns Resolves to a structural report object containing tracking metadata upon ingest completion.
61-
*/
60+
// ── Single-file upload ─────────────────────────────────────────────────────
61+
6262
const uploadFile = useCallback(
6363
(file: File): Promise<UploadResult> => {
6464
return new Promise((resolve, reject) => {
6565
const xhr = new XMLHttpRequest();
66-
xhrRefs.current.push(xhr);
66+
xhrMap.current.set(file.name, xhr);
6767

6868
const formData = new FormData();
6969
formData.append("files", file);
@@ -78,83 +78,122 @@ export function useUploadProgress() {
7878
};
7979

8080
xhr.onload = () => {
81+
xhrMap.current.delete(file.name);
82+
8183
if (xhr.status >= 200 && xhr.status < 300) {
8284
try {
8385
const data = JSON.parse(xhr.responseText);
86+
// API wraps in { data: { jobId, files: [...] }, error: null }
87+
const payload = data.data ?? data;
88+
const jobId: string =
89+
payload.jobId ?? payload.files?.[0]?.jobId ?? "";
90+
const url: string = payload.files?.[0]?.url ?? "";
91+
8492
setFileProgress(file.name, { percent: 100, status: "done" });
85-
resolve({
86-
jobId: data.jobId ?? data.files?.[0]?.jobId ?? "",
87-
name: file.name,
88-
url: data.files?.[0]?.url ?? "",
89-
});
93+
resolve({ jobId, name: file.name, url });
9094
} catch {
91-
setFileProgress(file.name, { status: "error", error: "Invalid server response" });
92-
reject(new Error("Invalid server response"));
95+
const msg = "Invalid server response";
96+
setFileProgress(file.name, { status: "error", error: msg });
97+
reject(new Error(msg));
9398
}
9499
} else {
95-
const msg = `Upload failed (HTTP ${xhr.status})`;
100+
let msg = `Upload failed (HTTP ${xhr.status})`;
101+
try {
102+
const body = JSON.parse(xhr.responseText);
103+
if (body?.error) msg = body.error;
104+
else if (body?.data?.error) msg = body.data.error;
105+
} catch { /* ignore parse error */ }
96106
setFileProgress(file.name, { status: "error", error: msg });
97107
reject(new Error(msg));
98108
}
99109
};
100110

101111
xhr.onerror = () => {
112+
xhrMap.current.delete(file.name);
102113
const msg = "Network error during upload";
103114
setFileProgress(file.name, { status: "error", error: msg });
104115
reject(new Error(msg));
105116
};
106117

107118
xhr.onabort = () => {
119+
xhrMap.current.delete(file.name);
108120
setFileProgress(file.name, { status: "cancelled" });
109121
reject(new DOMException("Upload cancelled", "AbortError"));
110122
};
111123

124+
setFileProgress(file.name, { percent: 0, status: "uploading" });
112125
xhr.send(formData);
113126
});
114127
},
115-
[setFileProgress]
128+
[setFileProgress],
116129
);
117130

131+
// ── Concurrency-limited queue ──────────────────────────────────────────────
132+
118133
/**
119-
* Orchestrates parallel batch processing pipelines across variable file array configurations.
120-
* * @param files - List of File targets slated for transit dispatching.
121-
* @returns Structured collection summarizing successfully loaded files and their active job associations.
134+
* Upload `files` with at most `concurrency` simultaneous XHRs.
135+
* Returns the list of successful UploadResults.
122136
*/
123137
const upload = useCallback(
124-
async (files: File[]) => {
125-
if (files.length === 0) return;
138+
async (files: File[]): Promise<UploadResult[]> => {
139+
if (files.length === 0) return [];
126140

127141
// Reset state
128-
xhrRefs.current = [];
142+
xhrMap.current = new Map();
129143
const initProgress: Record<string, FileProgress> = {};
130-
files.forEach((f) => (initProgress[f.name] = { percent: 0, status: "idle" }));
144+
files.forEach((f) => {
145+
initProgress[f.name] = { percent: 0, status: "idle" };
146+
});
131147
setProgresses(initProgress);
132148
setResults([]);
133149
setIsUploading(true);
134150

151+
const successful: UploadResult[] = [];
152+
135153
try {
136-
const uploadResults = await Promise.allSettled(files.map(uploadFile));
137-
const successful: UploadResult[] = [];
138-
uploadResults.forEach((r) => {
139-
if (r.status === "fulfilled") successful.push(r.value);
140-
});
154+
// Run the queue with a fixed concurrency slot pool
155+
let index = 0;
156+
157+
const worker = async () => {
158+
while (index < files.length) {
159+
const current = files[index++];
160+
const result = await uploadFile(current).catch(() => null);
161+
if (result) successful.push(result);
162+
}
163+
};
164+
165+
const cap = Math.min(concurrency, files.length);
166+
await Promise.all(Array.from({ length: cap }, worker));
167+
141168
setResults(successful);
142169
return successful;
143170
} finally {
144171
setIsUploading(false);
145-
xhrRefs.current = [];
172+
xhrMap.current.clear();
146173
}
147174
},
148-
[uploadFile]
175+
[uploadFile, concurrency],
149176
);
150177

151-
/**
152-
* Iterates through active XHR connections to force instant abort events.
153-
*/
178+
// ── Cancel helpers ─────────────────────────────────────────────────────────
179+
180+
/** Abort a single in-flight upload by filename */
181+
const cancelFile = useCallback((name: string) => {
182+
const xhr = xhrMap.current.get(name);
183+
if (xhr) {
184+
xhr.abort();
185+
xhrMap.current.delete(name);
186+
} else {
187+
// File may be queued but not yet uploading — mark cancelled proactively
188+
setFileProgress(name, { status: "cancelled" });
189+
}
190+
}, [setFileProgress]);
191+
192+
/** Abort all in-flight uploads */
154193
const cancelAll = useCallback(() => {
155-
xhrRefs.current.forEach((xhr) => xhr.abort());
156-
xhrRefs.current = [];
194+
xhrMap.current.forEach((xhr) => xhr.abort());
195+
xhrMap.current.clear();
157196
}, []);
158197

159-
return { progresses, results, isUploading, upload, cancelAll };
198+
return { progresses, results, isUploading, upload, cancelFile, cancelAll };
160199
}

app/lib/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
// Upload limits
22
export const MAX_UPLOAD_SIZE_BYTES = 500 * 1024 * 1024;
3+
export const MAX_FILES_PER_REQUEST = 10;
4+
export const UPLOAD_CONCURRENCY = 3;
35

46
// Crypto
57
export const PBKDF2_ITERATIONS = 100000;

0 commit comments

Comments
 (0)