Skip to content

Commit 09696da

Browse files
committed
Implement upload progress tracking and error handling
- Added progress bar and status display for file uploads. - Enhanced error handling with user feedback for upload failures. - Updated Upload component to utilize new upload hooks for better state management. - Refactored upload logic to support both single and multipart uploads. - Improved UI elements for clarity and user experience during file uploads.
1 parent 4bfcd17 commit 09696da

File tree

16 files changed

+1079
-80
lines changed

16 files changed

+1079
-80
lines changed

Diff for: apps/cyberstorm-remix/app/upload/Upload.css

+61
Original file line numberDiff line numberDiff line change
@@ -155,4 +155,65 @@
155155
height: 200px;
156156
border: 4px solid #fff;
157157
}
158+
159+
.upload__progress {
160+
display: flex;
161+
flex-direction: column;
162+
gap: var(--gap-md);
163+
width: 100%;
164+
margin-top: var(--gap-md);
165+
}
166+
167+
.upload__progress-bar {
168+
width: 100%;
169+
height: 8px;
170+
border-radius: 4px;
171+
overflow: hidden;
172+
background-color: var(--color-surface-a9);
173+
}
174+
175+
.upload__progress-bar-fill {
176+
height: 100%;
177+
background-color: var(--color-accent);
178+
transition: width 0.3s ease;
179+
}
180+
181+
.upload__progress-info {
182+
display: flex;
183+
justify-content: space-between;
184+
color: var(--color-text-secondary);
185+
font-size: var(--font-size-body-sm);
186+
}
187+
188+
.upload__error {
189+
margin-top: 1rem;
190+
padding: 1rem;
191+
border: 1px solid #dee2e6;
192+
border-radius: 4px;
193+
color: #dc3545;
194+
background-color: #f8f9fa;
195+
}
196+
197+
.upload__error-field {
198+
margin-bottom: 0.5rem;
199+
}
200+
201+
.upload__error-field:last-child {
202+
margin-bottom: 0;
203+
}
204+
205+
.upload__error-field-name {
206+
margin: 0;
207+
margin-bottom: 0.25rem;
208+
font-weight: 600;
209+
}
210+
211+
.upload__error-message {
212+
margin: 0;
213+
padding-left: 1rem;
214+
}
215+
216+
.upload__error .cs-button {
217+
margin-top: 1rem;
218+
}
158219
}

Diff for: apps/cyberstorm-remix/app/upload/upload.tsx

+206-47
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,19 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
1212
import { PageHeader } from "../commonComponents/PageHeader/PageHeader";
1313
import { DnDFileInput } from "@thunderstore/react-dnd";
1414
import { useCallback, useEffect, useState } from "react";
15-
import { initMultipartUpload, IUploadHandle } from "@thunderstore/ts-uploader";
16-
import { useUploadProgress } from "@thunderstore/ts-uploader-react";
15+
import { MultipartUpload, IBaseUploadHandle } from "@thunderstore/ts-uploader";
16+
import {
17+
useUploadProgress,
18+
useUploadStatus,
19+
useUploadError,
20+
useUploadControls,
21+
} from "@thunderstore/ts-uploader-react";
1722
// import { useOutletContext } from "@remix-run/react";
1823
// import { OutletContextShape } from "../../root";
1924
import { useSession } from "@thunderstore/ts-api-react";
2025
import { faFileZip, faTreasureChest } from "@fortawesome/pro-solid-svg-icons";
2126
import { UserMedia } from "@thunderstore/ts-uploader/src/client/types";
22-
import { DapperTs } from "@thunderstore/dapper-ts";
27+
import { DapperTs, PackageSubmissionResponse } from "@thunderstore/dapper-ts";
2328
import { MetaFunction } from "@remix-run/node";
2429
import { useLoaderData } from "@remix-run/react";
2530

@@ -106,48 +111,108 @@ export default function Upload() {
106111
);
107112

108113
const [file, setFile] = useState<File | null>(null);
109-
const [handle, setHandle] = useState<IUploadHandle>();
114+
const [handle, setHandle] = useState<IBaseUploadHandle>();
110115
const [lock, setLock] = useState<boolean>(false);
111116
const [isDone, setIsDone] = useState<boolean>(false);
112117

118+
const error = useUploadError(handle);
119+
const controls = useUploadControls(handle);
120+
113121
const [usermedia, setUsermedia] = useState<UserMedia>();
114122

115-
const startUpload = useCallback(() => {
123+
const [submissionError, setSubmissionError] =
124+
useState<PackageSubmissionResponse>({});
125+
126+
const startUpload = useCallback(async () => {
116127
if (!file) return;
117-
const config = session.getConfig();
118-
setLock(true);
119-
initMultipartUpload(file, {
120-
api: {
121-
domain: config.apiHost ?? "https://thunderstore.io",
122-
authorization: `Session ${config.sessionId}`,
123-
},
124-
}).then((handle) => {
125-
setHandle(handle);
126-
handle.startUpload().then(
127-
(value) => {
128-
setUsermedia(value);
129-
setIsDone(true);
128+
129+
try {
130+
const config = session.getConfig();
131+
if (!config.apiHost) {
132+
throw new Error("API host is not configured");
133+
}
134+
const upload = new MultipartUpload({
135+
file,
136+
api: {
137+
domain: config.apiHost,
138+
authorization: config.sessionId
139+
? `Session ${config.sessionId}`
140+
: undefined,
130141
},
131-
(reason) => {
132-
console.log(reason);
133-
}
134-
);
135-
});
142+
});
143+
144+
setLock(true);
145+
await upload.start();
146+
setHandle(upload);
147+
setUsermedia(upload.uploadHandle);
148+
setIsDone(true);
149+
} catch (error) {
150+
console.error("Upload failed:", error);
151+
if (error instanceof Error) {
152+
alert(`Upload failed: ${error.message}`);
153+
}
154+
} finally {
155+
setLock(false);
156+
}
136157
}, [file, session]);
137158

138-
const submit = useCallback(() => {
139-
const config = session.getConfig();
140-
const dapper = new DapperTs(() => config);
141-
dapper.postPackageSubmissionMetadata(
142-
team ?? "",
143-
selectedCategories.map((cat) => cat.categoryId),
144-
selectedCommunities.map(
145-
(community) => (community as NewSelectOption).value
146-
),
147-
NSFW,
148-
usermedia?.uuid ?? "",
149-
[] // TODO: wth are community categories??
150-
);
159+
const submit = useCallback(async () => {
160+
if (!usermedia?.uuid) {
161+
setSubmissionError({
162+
__all__: ["Upload not completed"],
163+
});
164+
return;
165+
}
166+
167+
try {
168+
setSubmissionError({});
169+
const config = session.getConfig();
170+
if (!config.apiHost) {
171+
throw new Error("API host is not configured");
172+
}
173+
const dapper = new DapperTs(() => config);
174+
const result = await dapper.postPackageSubmissionMetadata(
175+
team ?? "",
176+
selectedCommunities.map(
177+
(community) => (community as NewSelectOption).value
178+
),
179+
NSFW,
180+
usermedia.uuid,
181+
selectedCategories.map((cat) => cat.categoryId),
182+
{}
183+
);
184+
185+
// Check if the result is a SubmissionError
186+
if (
187+
"__all__" in result ||
188+
"author_name" in result ||
189+
"communities" in result
190+
) {
191+
setSubmissionError(result);
192+
return;
193+
}
194+
195+
// Handle successful submission
196+
if ("task_error" in result && result.task_error) {
197+
setSubmissionError({
198+
__all__: [`Submission failed: ${result.result}`],
199+
});
200+
return;
201+
}
202+
203+
alert("Package submitted successfully!");
204+
} catch (error) {
205+
console.error("Submission failed:", error);
206+
if (error instanceof Error) {
207+
setSubmissionError({
208+
__all__: [error.message],
209+
});
210+
} else {
211+
setSubmissionError({
212+
__all__: ["An unexpected error occurred during submission"],
213+
});
214+
}
215+
}
151216
}, [usermedia, NSFW, team, selectedCommunities, selectedCategories, session]);
152217

153218
useEffect(() => {
@@ -175,6 +240,16 @@ export default function Upload() {
175240
}
176241
}, [selectedCommunities]);
177242

243+
// Helper function to format field names for display
244+
const formatFieldName = (field: string) => {
245+
return field
246+
.split("_")
247+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
248+
.join(" ");
249+
};
250+
251+
// console.log(submissionError);
252+
178253
return (
179254
<div className="container container--y container--full layout__content">
180255
<NewBreadCrumbs>
@@ -252,8 +327,17 @@ export default function Upload() {
252327
<div className="upload__content">
253328
<NewSelectSearch
254329
options={[
255-
{ value: "team1", label: "Team 1" },
256-
{ value: "team2", label: "Team 2" },
330+
{ value: "Test_Team_0", label: "Test_Team_0" },
331+
{ value: "Test_Team_1", label: "Test_Team_1" },
332+
{ value: "Test_Team_2", label: "Test_Team_2" },
333+
{ value: "Test_Team_3", label: "Test_Team_3" },
334+
{ value: "Test_Team_4", label: "Test_Team_4" },
335+
{ value: "Test_Team_5", label: "Test_Team_5" },
336+
{ value: "Test_Team_6", label: "Test_Team_6" },
337+
{ value: "Test_Team_7", label: "Test_Team_7" },
338+
{ value: "Test_Team_8", label: "Test_Team_8" },
339+
{ value: "Test_Team_9", label: "Test_Team_9" },
340+
{ value: "Test_Team_10", label: "Test_Team_10" },
257341
]}
258342
onChange={(val) => setTeam(val?.value)}
259343
value={team ? { value: team, label: team } : undefined}
@@ -374,21 +458,30 @@ export default function Upload() {
374458
<div className="upload__content">
375459
<div className="upload__buttons">
376460
<NewButton
377-
// onClick={startUpload}
461+
onClick={() => {
462+
setFile(null);
463+
setHandle(undefined);
464+
setUsermedia(undefined);
465+
setIsDone(false);
466+
setSelectedCommunities([]);
467+
setSelectedCategories([]);
468+
setTeam(undefined);
469+
setNSFW(false);
470+
}}
378471
csVariant="secondary"
379472
csSize="big"
380473
>
381474
Reset
382475
</NewButton>
383476
{isDone ? (
384477
<NewButton
385-
// disabled={!file || !!handle || lock}
478+
disabled={!usermedia?.uuid || !selectedCommunities.length}
386479
onClick={submit}
387480
csVariant="accent"
388481
csSize="big"
389482
rootClasses="upload__submit"
390483
>
391-
Submit
484+
Submit Package
392485
</NewButton>
393486
) : (
394487
<NewButton
@@ -398,12 +491,43 @@ export default function Upload() {
398491
csSize="big"
399492
rootClasses="upload__submit"
400493
>
401-
Start upload
494+
Upload File
402495
</NewButton>
403496
)}
404497
</div>
405498
<UploadProgressDisplay handle={handle} />
406-
<p>Upload success: {isDone.toString()}</p>
499+
{error && (
500+
<div className="upload__error">
501+
<p>{error.message}</p>
502+
{error.retryable && (
503+
<NewButton onClick={controls.retry}>Retry</NewButton>
504+
)}
505+
</div>
506+
)}
507+
{isDone && !usermedia?.uuid && (
508+
<div className="upload__error">
509+
<p>
510+
Upload completed but no UUID was received. Please try again.
511+
</p>
512+
</div>
513+
)}
514+
{isDone && usermedia?.uuid && !selectedCommunities.length && (
515+
<div className="upload__error">
516+
<p>Please select at least one community.</p>
517+
</div>
518+
)}
519+
{Object.keys(submissionError).length > 0 && (
520+
<div className="upload__error">
521+
{Object.entries(submissionError).map(([field, errors]) => (
522+
<div key={field}>
523+
{field !== "__all__" && (
524+
<strong>{formatFieldName(field)}: </strong>
525+
)}
526+
{Array.isArray(errors) ? errors.join(", ") : String(errors)}
527+
</div>
528+
))}
529+
</div>
530+
)}
407531
</div>
408532
</div>
409533
{/* <div className="upload-form">
@@ -433,12 +557,47 @@ export default function Upload() {
433557
);
434558
}
435559

436-
const UploadProgressDisplay = (props: { handle?: IUploadHandle }) => {
560+
const UploadProgressDisplay = (props: { handle?: IBaseUploadHandle }) => {
437561
const progress = useUploadProgress(props.handle);
562+
const status = useUploadStatus(props.handle);
563+
const error = useUploadError(props.handle);
564+
const controls = useUploadControls(props.handle);
565+
438566
if (!progress) return <p>Upload not started</p>;
567+
568+
const percent = Math.round((progress.complete / progress.total) * 100);
569+
const speed = Math.round(progress.metrics.bytesPerSecond / 1024 / 1024); // MB/s
570+
439571
return (
440-
<p>
441-
Progress: {progress.complete} / {progress.total}
442-
</p>
572+
<div className="upload__progress">
573+
<div className="upload__progress-bar">
574+
<div
575+
className="upload__progress-bar-fill"
576+
style={{ width: `${percent}%` }}
577+
/>
578+
</div>
579+
<div className="upload__progress-info">
580+
<span>{percent}%</span>
581+
<span>{speed} MB/s</span>
582+
<span>{status}</span>
583+
</div>
584+
{error && (
585+
<div className="upload__error">
586+
<p>{error.message}</p>
587+
{error.retryable && (
588+
<NewButton onClick={controls.retry}>Retry</NewButton>
589+
)}
590+
</div>
591+
)}
592+
{status === "running" && (
593+
<NewButton onClick={controls.pause}>Pause</NewButton>
594+
)}
595+
{status === "paused" && (
596+
<NewButton onClick={controls.resume}>Resume</NewButton>
597+
)}
598+
{(status === "running" || status === "paused") && (
599+
<NewButton onClick={controls.abort}>Cancel</NewButton>
600+
)}
601+
</div>
443602
);
444603
};

0 commit comments

Comments
 (0)