11import "./Upload.css" ;
22import {
3+ Heading ,
34 NewButton ,
45 NewIcon ,
6+ NewLink ,
57 NewSelectSearch ,
68 NewSwitch ,
7- Heading ,
8- NewLink ,
99 NewTable ,
1010 NewTableSort ,
1111 NewTag ,
12+ SkeletonBox ,
1213 useToast ,
1314} from "@thunderstore/cyberstorm" ;
1415import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" ;
1516import { PageHeader } from "../commonComponents/PageHeader/PageHeader" ;
1617import { DnDFileInput } from "@thunderstore/react-dnd" ;
17- import { useCallback , useEffect , useReducer , useRef , useState } from "react" ;
18+ import {
19+ Suspense ,
20+ useCallback ,
21+ useEffect ,
22+ useReducer ,
23+ useRef ,
24+ useState ,
25+ } from "react" ;
1826import {
1927 MultipartUpload ,
2028 type IBaseUploadHandle ,
@@ -27,22 +35,35 @@ import {
2735} from "@fortawesome/pro-solid-svg-icons" ;
2836import { type UserMedia } from "@thunderstore/ts-uploader/src/uploaders/types" ;
2937import { DapperTs } from "@thunderstore/dapper-ts" ;
30- import { type MetaFunction } from "react-router" ;
31- import { useLoaderData , useOutletContext } from "react-router" ;
38+ import {
39+ Await ,
40+ type MetaFunction ,
41+ useLoaderData ,
42+ useOutletContext ,
43+ } from "react-router" ;
3244import {
3345 type PackageSubmissionResult ,
3446 type PackageSubmissionStatus ,
3547} from "@thunderstore/dapper/types" ;
36- import { type PackageSubmissionRequestData } from "@thunderstore/thunderstore-api" ;
48+ import {
49+ type PackageSubmissionRequestData ,
50+ UserFacingError ,
51+ formatUserFacingError ,
52+ } from "@thunderstore/thunderstore-api" ;
3753import { type OutletContextShape } from "../root" ;
3854import { useStrongForm } from "cyberstorm/utils/StrongForm/useStrongForm" ;
3955import { postPackageSubmissionMetadata } from "@thunderstore/dapper-ts/src/methods/package" ;
4056import { faCheckCircle } from "@fortawesome/free-solid-svg-icons" ;
4157import { classnames } from "@thunderstore/cyberstorm/src/utils/utils" ;
58+ import { handleLoaderError } from "cyberstorm/utils/errors/handleLoaderError" ;
4259import {
43- getPublicEnvVariables ,
44- getSessionTools ,
45- } from "cyberstorm/security/publicEnvVariables" ;
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 ) ;
4667
4768interface CommunityOption {
4869 value : string ;
@@ -64,36 +85,64 @@ export const meta: MetaFunction = () => {
6485 ] ;
6586} ;
6687
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+ */
6798export async function loader ( ) {
68- const publicEnvVariables = getPublicEnvVariables ( [ "VITE_API_URL" ] ) ;
69- const dapper = new DapperTs ( ( ) => {
99+ const { dapper } = getLoaderTools ( ) ;
100+ try {
101+ const communities = await dapper . getCommunities ( ) ;
102+
70103 return {
71- apiHost : publicEnvVariables . VITE_API_URL ,
72- sessionId : undefined ,
104+ communities,
73105 } ;
74- } ) ;
75- return await dapper . getCommunities ( ) ;
106+ } catch ( error ) {
107+ handleLoaderError ( error ) ;
108+ }
76109}
77110
111+ /**
112+ * Streams the community list promise so Suspense can render progress fallbacks.
113+ */
78114export async function clientLoader ( ) {
79- // console.log("clientloader context", getSessionTools(context));
80- const tools = getSessionTools ( ) ;
81- const dapper = new DapperTs ( ( ) => {
82- return {
83- apiHost : tools ?. getConfig ( ) . apiHost ,
84- sessionId : tools ?. getConfig ( ) . sessionId ,
85- } ;
86- } ) ;
87- return await dapper . getCommunities ( ) ;
115+ const { dapper } = getLoaderTools ( ) ;
116+ const communitiesPromise = dapper . getCommunities ( ) ;
117+
118+ return {
119+ communities : communitiesPromise ,
120+ } ;
88121}
89122
123+ /**
124+ * Streams communities via Suspense and delegates UI rendering to UploadContent.
125+ */
90126export default function Upload ( ) {
91- const uploadData = useLoaderData < typeof loader | typeof clientLoader > ( ) ;
92-
127+ const { communities } = useLoaderData < typeof loader | typeof clientLoader > ( ) ;
93128 const outletContext = useOutletContext ( ) as OutletContextShape ;
94- const requestConfig = outletContext . requestConfig ;
95- const currentUser = outletContext . currentUser ;
96- 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 ;
97146
98147 const toast = useToast ( ) ;
99148
@@ -115,13 +164,14 @@ export default function Upload() {
115164 } , [ currentUser ?. teams_full ] ) ;
116165
117166 // Community options
118- const communityOptions : CommunityOption [ ] = [ ] ;
119- for ( const community of uploadData . results ) {
120- communityOptions . push ( {
121- value : community . identifier ,
122- label : community . name ,
123- } ) ;
124- }
167+ const communityOptions : CommunityOption [ ] = communities . results . map (
168+ ( community ) => {
169+ return {
170+ value : community . identifier ,
171+ label : community . name ,
172+ } ;
173+ }
174+ ) ;
125175
126176 const [ submissionStatus , setSubmissionStatus ] =
127177 useState < PackageSubmissionStatus > ( ) ;
@@ -236,17 +286,29 @@ export default function Upload() {
236286 // TODO: Add sentry logging
237287 toast . addToast ( {
238288 csVariant : "danger" ,
239- children : `Error polling submission status: ${ error . message } ` ,
289+ children : `Error polling submission status: ${ getErrorMessage (
290+ error
291+ ) } `,
240292 duration : 8000 ,
241293 } ) ;
242294 } ) ;
243295 }
244296 } , [ submissionStatus ] ) ;
245297
246- const retryPolling = ( ) => {
247- if ( submissionStatus ?. id ) {
248- pollSubmission ( submissionStatus . id , true ) . then ( ( data ) => {
249- 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 ,
250312 } ) ;
251313 }
252314 } ;
@@ -282,25 +344,63 @@ export default function Upload() {
282344 } ) ;
283345
284346 useEffect ( ( ) => {
285- for ( const community of formInputs . communities ) {
286- // Skip if we already have categories for this community
287- if ( categoryOptions . some ( ( opt ) => opt . communityId === community ) ) {
288- continue ;
289- }
290- dapper . getCommunityFilters ( community ) . then ( ( filters ) => {
291- setCategoryOptions ( ( prev ) => [
292- ...prev ,
293- {
294- communityId : community ,
295- categories : filters . package_categories . map ( ( cat ) => ( {
296- value : cat . slug ,
297- label : cat . name ,
298- } ) ) ,
299- } ,
300- ] ) ;
301- } ) ;
347+ const communitiesToFetch = formInputs . communities . filter (
348+ ( community ) =>
349+ ! categoryOptions . some ( ( opt ) => opt . communityId === community )
350+ ) ;
351+
352+ if ( communitiesToFetch . length === 0 ) {
353+ return ;
302354 }
303- } , [ 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 ] ) ;
304404
305405 type SubmitorOutput = Awaited <
306406 ReturnType < typeof postPackageSubmissionMetadata >
@@ -328,7 +428,7 @@ export default function Upload() {
328428 PackageSubmissionRequestData ,
329429 Error ,
330430 SubmitorOutput ,
331- Error ,
431+ UserFacingError ,
332432 InputErrors
333433 > ( {
334434 inputs : formInputs ,
@@ -343,7 +443,7 @@ export default function Upload() {
343443 onSubmitError : ( error ) => {
344444 toast . addToast ( {
345445 csVariant : "danger" ,
346- children : `Error occurred: ${ error . message || "Unknown error" } ` ,
446+ children : formatUserFacingError ( error ) ,
347447 duration : 8000 ,
348448 } ) ;
349449 } ,
@@ -408,7 +508,7 @@ export default function Upload() {
408508 < p className = "upload__no-teams-text" > No teams available?</ p >
409509 < NewLink
410510 primitiveType = "link"
411- href = { `${ outletContext . domain } /settings/teams/` }
511+ href = { `${ domain } /settings/teams/` }
412512 csVariant = "cyber"
413513 rootClasses = "community__item"
414514 >
@@ -559,7 +659,7 @@ export default function Upload() {
559659 </ div >
560660 < div className = "upload__content" >
561661 { formInputs . communities . map ( ( community ) => {
562- const communityData = uploadData . results . find (
662+ const communityData = communities . results . find (
563663 ( c ) => c . identifier === community
564664 ) ;
565665 const categories =
@@ -749,6 +849,32 @@ export default function Upload() {
749849 ) ;
750850}
751851
852+ /**
853+ * Shows a lightweight skeleton while communities load after navigation.
854+ */
855+ function UploadSkeleton ( ) {
856+ return (
857+ < section className = "container container--y container--full upload" >
858+ { [ 0 , 1 , 2 ] . map ( ( index ) => (
859+ < div
860+ key = { index }
861+ className = "container container--x container--full upload__row"
862+ style = { { marginBottom : "1rem" , height : "3rem" } }
863+ >
864+ < SkeletonBox />
865+ </ div >
866+ ) ) }
867+ </ section >
868+ ) ;
869+ }
870+
871+ export function ErrorBoundary ( ) {
872+ return < NimbusDefaultRouteErrorBoundary /> ;
873+ }
874+
875+ /**
876+ * Converts byte counts into a human-readable string for upload status messaging.
877+ */
752878function formatBytes ( bytes : number , decimals = 2 ) {
753879 if ( ! + bytes ) return "0 Bytes" ;
754880
@@ -771,6 +897,9 @@ function formatBytes(bytes: number, decimals = 2) {
771897 return `${ parseFloat ( ( bytes / Math . pow ( k , i ) ) . toFixed ( dm ) ) } ${ sizes [ i ] } ` ;
772898}
773899
900+ /**
901+ * Displays the submission success summary once the package metadata API responds.
902+ */
774903const SubmissionResult = ( props : {
775904 submissionStatusResult : PackageSubmissionResult ;
776905} ) => {
0 commit comments