11import { faCheckCircle } from "@fortawesome/free-solid-svg-icons" ;
2- import {
3- faArrowUpRight ,
4- faFileZip ,
5- faTreasureChest ,
6- faUsers ,
7- } from "@fortawesome/pro-solid-svg-icons" ;
8- import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" ;
9- import {
10- getPublicEnvVariables ,
11- getSessionTools ,
12- } from "cyberstorm/security/publicEnvVariables" ;
13- import { useStrongForm } from "cyberstorm/utils/StrongForm/useStrongForm" ;
14- import { useCallback , useEffect , useReducer , useRef , useState } from "react" ;
15- import { type MetaFunction } from "react-router" ;
16- import { useLoaderData , useOutletContext } from "react-router" ;
17-
182import {
193 Heading ,
204 NewButton ,
@@ -25,26 +9,61 @@ import {
259 NewTable ,
2610 NewTableSort ,
2711 NewTag ,
12+ SkeletonBox ,
2813 useToast ,
2914} from "@thunderstore/cyberstorm" ;
30- import { classnames } from "@thunderstore/cyberstorm/src/utils/utils" ;
15+ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" ;
16+ import { PageHeader } from "../commonComponents/PageHeader/PageHeader" ;
17+ import { DnDFileInput } from "@thunderstore/react-dnd" ;
18+ import {
19+ Suspense ,
20+ useCallback ,
21+ useEffect ,
22+ useReducer ,
23+ useRef ,
24+ useState ,
25+ } from "react" ;
26+ import {
27+ MultipartUpload ,
28+ type IBaseUploadHandle ,
29+ } from "@thunderstore/ts-uploader" ;
30+ import {
31+ faFileZip ,
32+ faTreasureChest ,
33+ faUsers ,
34+ faArrowUpRight ,
35+ } from "@fortawesome/pro-solid-svg-icons" ;
36+ import { type UserMedia } from "@thunderstore/ts-uploader/src/uploaders/types" ;
3137import { DapperTs } from "@thunderstore/dapper-ts" ;
32- import { postPackageSubmissionMetadata } from "@thunderstore/dapper-ts/src/methods/package" ;
38+ import {
39+ Await ,
40+ type MetaFunction ,
41+ useLoaderData ,
42+ useOutletContext ,
43+ } from "react-router" ;
3344import {
3445 type PackageSubmissionResult ,
3546 type PackageSubmissionStatus ,
3647} from "@thunderstore/dapper/types" ;
37- import { DnDFileInput } from "@thunderstore/react-dnd" ;
38- import { type PackageSubmissionRequestData } from "@thunderstore/thunderstore-api" ;
3948import {
40- type IBaseUploadHandle ,
41- MultipartUpload ,
42- } from "@thunderstore/ts-uploader" ;
43- import { type UserMedia } from "@thunderstore/ts-uploader/src/uploaders/types" ;
44-
45- import { PageHeader } from "../commonComponents/PageHeader/PageHeader" ;
49+ type PackageSubmissionRequestData ,
50+ UserFacingError ,
51+ formatUserFacingError ,
52+ } from "@thunderstore/thunderstore-api" ;
4653import { type OutletContextShape } from "../root" ;
47- import "./Upload.css" ;
54+ import { useStrongForm } from "cyberstorm/utils/StrongForm/useStrongForm" ;
55+ import { postPackageSubmissionMetadata } from "@thunderstore/dapper-ts/src/methods/package" ;
56+ import { faCheckCircle } from "@fortawesome/free-solid-svg-icons" ;
57+ import { classnames } from "@thunderstore/cyberstorm/src/utils/utils" ;
58+ import { handleLoaderError } from "cyberstorm/utils/errors/handleLoaderError" ;
59+ import {
60+ NimbusAwaitErrorElement ,
61+ NimbusDefaultRouteErrorBoundary ,
62+ } from "../../cyberstorm/utils/errors/NimbusErrorBoundary" ;
63+ import { getLoaderTools } from "cyberstorm/utils/getLoaderTools" ;
64+
65+ const getErrorMessage = ( error : unknown ) =>
66+ error instanceof Error ? error . message : String ( error ) ;
4867
4968interface CommunityOption {
5069 value : string ;
@@ -66,36 +85,64 @@ export const meta: MetaFunction = () => {
6685 ] ;
6786} ;
6887
88+ type CommunitiesResult = Awaited < ReturnType < DapperTs [ "getCommunities" ] > > ;
89+
90+ interface UploadContentProps {
91+ communities : CommunitiesResult ;
92+ outletContext : OutletContextShape ;
93+ }
94+
95+ /**
96+ * Fetches the community list for the upload form during SSR.
97+ */
6998export async function loader ( ) {
70- const publicEnvVariables = getPublicEnvVariables ( [ "VITE_API_URL" ] ) ;
71- const dapper = new DapperTs ( ( ) => {
99+ const { dapper } = getLoaderTools ( ) ;
100+ try {
101+ const communities = await dapper . getCommunities ( ) ;
102+
72103 return {
73- apiHost : publicEnvVariables . VITE_API_URL ,
74- sessionId : undefined ,
104+ communities,
75105 } ;
76- } ) ;
77- return await dapper . getCommunities ( ) ;
106+ } catch ( error ) {
107+ handleLoaderError ( error ) ;
108+ }
78109}
79110
111+ /**
112+ * Streams the community list promise so Suspense can render progress fallbacks.
113+ */
80114export async function clientLoader ( ) {
81- // console.log("clientloader context", getSessionTools(context));
82- const tools = getSessionTools ( ) ;
83- const dapper = new DapperTs ( ( ) => {
84- return {
85- apiHost : tools ?. getConfig ( ) . apiHost ,
86- sessionId : tools ?. getConfig ( ) . sessionId ,
87- } ;
88- } ) ;
89- return await dapper . getCommunities ( ) ;
115+ const { dapper } = getLoaderTools ( ) ;
116+ const communitiesPromise = dapper . getCommunities ( ) ;
117+
118+ return {
119+ communities : communitiesPromise ,
120+ } ;
90121}
91122
123+ /**
124+ * Streams communities via Suspense and delegates UI rendering to UploadContent.
125+ */
92126export default function Upload ( ) {
93- const uploadData = useLoaderData < typeof loader | typeof clientLoader > ( ) ;
94-
127+ const { communities } = useLoaderData < typeof loader | typeof clientLoader > ( ) ;
95128 const outletContext = useOutletContext ( ) as OutletContextShape ;
96- const requestConfig = outletContext . requestConfig ;
97- const currentUser = outletContext . currentUser ;
98- const dapper = outletContext . dapper ;
129+
130+ return (
131+ < Suspense fallback = { < UploadSkeleton /> } >
132+ < Await resolve = { communities } errorElement = { < NimbusAwaitErrorElement /> } >
133+ { ( result ) => (
134+ < UploadContent communities = { result } outletContext = { outletContext } />
135+ ) }
136+ </ Await >
137+ </ Suspense >
138+ ) ;
139+ }
140+
141+ /**
142+ * Renders the upload workflow once community metadata resolves.
143+ */
144+ function UploadContent ( { communities, outletContext } : UploadContentProps ) {
145+ const { requestConfig, currentUser, dapper, domain } = outletContext ;
99146
100147 const toast = useToast ( ) ;
101148
@@ -117,13 +164,14 @@ export default function Upload() {
117164 } , [ currentUser ?. teams_full ] ) ;
118165
119166 // Community options
120- const communityOptions : CommunityOption [ ] = [ ] ;
121- for ( const community of uploadData . results ) {
122- communityOptions . push ( {
123- value : community . identifier ,
124- label : community . name ,
125- } ) ;
126- }
167+ const communityOptions : CommunityOption [ ] = communities . results . map (
168+ ( community ) => {
169+ return {
170+ value : community . identifier ,
171+ label : community . name ,
172+ } ;
173+ }
174+ ) ;
127175
128176 const [ submissionStatus , setSubmissionStatus ] =
129177 useState < PackageSubmissionStatus > ( ) ;
@@ -238,17 +286,29 @@ export default function Upload() {
238286 // TODO: Add sentry logging
239287 toast . addToast ( {
240288 csVariant : "danger" ,
241- children : `Error polling submission status: ${ error . message } ` ,
289+ children : `Error polling submission status: ${ getErrorMessage (
290+ error
291+ ) } `,
242292 duration : 8000 ,
243293 } ) ;
244294 } ) ;
245295 }
246296 } , [ submissionStatus ] ) ;
247297
248- const retryPolling = ( ) => {
249- if ( submissionStatus ?. id ) {
250- pollSubmission ( submissionStatus . id , true ) . then ( ( data ) => {
251- setSubmissionStatus ( data ) ;
298+ const retryPolling = async ( ) => {
299+ const submissionId = submissionStatus ?. id ;
300+ if ( ! submissionId ) {
301+ return ;
302+ }
303+
304+ try {
305+ const data = await pollSubmission ( submissionId , true ) ;
306+ setSubmissionStatus ( data ) ;
307+ } catch ( error ) {
308+ toast . addToast ( {
309+ csVariant : "danger" ,
310+ children : `Error polling submission status: ${ getErrorMessage ( error ) } ` ,
311+ duration : 8000 ,
252312 } ) ;
253313 }
254314 } ;
@@ -284,25 +344,63 @@ export default function Upload() {
284344 } ) ;
285345
286346 useEffect ( ( ) => {
287- for ( const community of formInputs . communities ) {
288- // Skip if we already have categories for this community
289- if ( categoryOptions . some ( ( opt ) => opt . communityId === community ) ) {
290- continue ;
291- }
292- dapper . getCommunityFilters ( community ) . then ( ( filters ) => {
293- setCategoryOptions ( ( prev ) => [
294- ...prev ,
295- {
296- communityId : community ,
297- categories : filters . package_categories . map ( ( cat ) => ( {
298- value : cat . slug ,
299- label : cat . name ,
300- } ) ) ,
301- } ,
302- ] ) ;
303- } ) ;
347+ const communitiesToFetch = formInputs . communities . filter (
348+ ( community ) =>
349+ ! categoryOptions . some ( ( opt ) => opt . communityId === community )
350+ ) ;
351+
352+ if ( communitiesToFetch . length === 0 ) {
353+ return ;
304354 }
305- } , [ formInputs . communities ] ) ;
355+
356+ let isCancelled = false ;
357+
358+ const fetchFilters = async ( ) => {
359+ await Promise . all (
360+ communitiesToFetch . map ( async ( community ) => {
361+ try {
362+ const filters = await dapper . getCommunityFilters ( community ) ;
363+ if ( isCancelled ) {
364+ return ;
365+ }
366+
367+ setCategoryOptions ( ( prev ) => {
368+ if ( prev . some ( ( opt ) => opt . communityId === community ) ) {
369+ return prev ;
370+ }
371+
372+ return [
373+ ...prev ,
374+ {
375+ communityId : community ,
376+ categories : filters . package_categories . map ( ( cat ) => ( {
377+ value : cat . slug ,
378+ label : cat . name ,
379+ } ) ) ,
380+ } ,
381+ ] ;
382+ } ) ;
383+ } catch ( error ) {
384+ if ( ! isCancelled ) {
385+ toast . addToast ( {
386+ csVariant : "danger" ,
387+ children : `Failed to load categories: ${ getErrorMessage (
388+ error
389+ ) } `,
390+ duration : 8000 ,
391+ } ) ;
392+ }
393+ }
394+ } )
395+ ) ;
396+ } ;
397+
398+ fetchFilters ( ) ;
399+
400+ return ( ) => {
401+ isCancelled = true ;
402+ } ;
403+ } , [ categoryOptions , dapper , formInputs . communities , toast ] ) ;
306404
307405 type SubmitorOutput = Awaited <
308406 ReturnType < typeof postPackageSubmissionMetadata >
@@ -330,7 +428,7 @@ export default function Upload() {
330428 PackageSubmissionRequestData ,
331429 Error ,
332430 SubmitorOutput ,
333- Error ,
431+ UserFacingError ,
334432 InputErrors
335433 > ( {
336434 inputs : formInputs ,
@@ -345,7 +443,7 @@ export default function Upload() {
345443 onSubmitError : ( error ) => {
346444 toast . addToast ( {
347445 csVariant : "danger" ,
348- children : `Error occurred: ${ error . message || "Unknown error" } ` ,
446+ children : formatUserFacingError ( error ) ,
349447 duration : 8000 ,
350448 } ) ;
351449 } ,
@@ -562,7 +660,7 @@ export default function Upload() {
562660 </ div >
563661 < div className = "upload__content" >
564662 { formInputs . communities . map ( ( community ) => {
565- const communityData = uploadData . results . find (
663+ const communityData = communities . results . find (
566664 ( c ) => c . identifier === community
567665 ) ;
568666 const categories =
@@ -752,6 +850,32 @@ export default function Upload() {
752850 ) ;
753851}
754852
853+ /**
854+ * Shows a lightweight skeleton while communities load after navigation.
855+ */
856+ function UploadSkeleton ( ) {
857+ return (
858+ < section className = "container container--y container--full upload" >
859+ { [ 0 , 1 , 2 ] . map ( ( index ) => (
860+ < div
861+ key = { index }
862+ className = "container container--x container--full upload__row"
863+ style = { { marginBottom : "1rem" , height : "3rem" } }
864+ >
865+ < SkeletonBox />
866+ </ div >
867+ ) ) }
868+ </ section >
869+ ) ;
870+ }
871+
872+ export function ErrorBoundary ( ) {
873+ return < NimbusDefaultRouteErrorBoundary /> ;
874+ }
875+
876+ /**
877+ * Converts byte counts into a human-readable string for upload status messaging.
878+ */
755879function formatBytes ( bytes : number , decimals = 2 ) {
756880 if ( ! + bytes ) return "0 Bytes" ;
757881
@@ -774,6 +898,9 @@ function formatBytes(bytes: number, decimals = 2) {
774898 return `${ parseFloat ( ( bytes / Math . pow ( k , i ) ) . toFixed ( dm ) ) } ${ sizes [ i ] } ` ;
775899}
776900
901+ /**
902+ * Displays the submission success summary once the package metadata API responds.
903+ */
777904const SubmissionResult = ( props : {
778905 submissionStatusResult : PackageSubmissionResult ;
779906} ) => {
0 commit comments