1- import { faCheckCircle } from "@fortawesome/free-solid-svg-icons" ;
21import {
32 faArrowUpRight ,
3+ faCheckCircle ,
44 faFileZip ,
55 faTreasureChest ,
66 faUsers ,
77} from "@fortawesome/pro-solid-svg-icons" ;
88import { FontAwesomeIcon } from "@fortawesome/react-fontawesome" ;
9- import {
10- getPublicEnvVariables ,
11- getSessionTools ,
12- } from "cyberstorm/security/publicEnvVariables" ;
139import { 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" ;
10+ import {
11+ NimbusAwaitErrorElement ,
12+ NimbusDefaultRouteErrorBoundary ,
13+ } from "cyberstorm/utils/errors/NimbusErrorBoundary" ;
14+ import { handleLoaderError } from "cyberstorm/utils/errors/handleLoaderError" ;
15+ import { getLoaderTools } from "cyberstorm/utils/getLoaderTools" ;
16+ import {
17+ Suspense ,
18+ useCallback ,
19+ useEffect ,
20+ useReducer ,
21+ useRef ,
22+ useState ,
23+ } from "react" ;
24+ import {
25+ Await ,
26+ type MetaFunction ,
27+ useLoaderData ,
28+ useOutletContext ,
29+ } from "react-router" ;
30+ import { PageHeader } from "~/commonComponents/PageHeader/PageHeader" ;
31+ import type { OutletContextShape } from "~/root" ;
1732
1833import {
1934 Heading ,
@@ -25,31 +40,33 @@ import {
2540 NewTable ,
2641 NewTableSort ,
2742 NewTag ,
43+ SkeletonBox ,
2844 classnames ,
2945 useToast ,
3046} from "@thunderstore/cyberstorm" ;
31- import {
32- type PackageSubmissionResult ,
33- type PackageSubmissionStatus ,
47+ import type {
48+ PackageSubmissionResult ,
49+ PackageSubmissionStatus ,
3450} from "@thunderstore/dapper" ;
3551import {
3652 DapperTs ,
3753 postPackageSubmissionMetadata ,
3854} from "@thunderstore/dapper-ts" ;
3955import { DnDFileInput } from "@thunderstore/react-dnd" ;
40- import {
41- type PackageSubmissionRequestData ,
42- UserFacingError ,
43- } from "@thunderstore/thunderstore-api" ;
4456import {
4557 type IBaseUploadHandle ,
4658 MultipartUpload ,
47- type UserMedia ,
4859} from "@thunderstore/ts-uploader" ;
4960
50- import { PageHeader } from "../commonComponents/PageHeader/PageHeader" ;
51- import { type OutletContextShape } from "../root" ;
52- import "./Upload.css" ;
61+ import {
62+ type PackageSubmissionRequestData ,
63+ UserFacingError ,
64+ type UserMedia ,
65+ formatUserFacingError ,
66+ } from "../../../../packages/thunderstore-api/src" ;
67+
68+ const getErrorMessage = ( error : unknown ) =>
69+ error instanceof Error ? error . message : String ( error ) ;
5370
5471interface CommunityOption {
5572 value : string ;
@@ -71,36 +88,64 @@ export const meta: MetaFunction = () => {
7188 ] ;
7289} ;
7390
91+ type CommunitiesResult = Awaited < ReturnType < DapperTs [ "getCommunities" ] > > ;
92+
93+ interface UploadContentProps {
94+ communities : CommunitiesResult ;
95+ outletContext : OutletContextShape ;
96+ }
97+
98+ /**
99+ * Fetches the community list for the upload form during SSR.
100+ */
74101export async function loader ( ) {
75- const publicEnvVariables = getPublicEnvVariables ( [ "VITE_API_URL" ] ) ;
76- const dapper = new DapperTs ( ( ) => {
102+ const { dapper } = getLoaderTools ( ) ;
103+ try {
104+ const communities = await dapper . getCommunities ( ) ;
105+
77106 return {
78- apiHost : publicEnvVariables . VITE_API_URL ,
79- sessionId : undefined ,
107+ communities,
80108 } ;
81- } ) ;
82- return await dapper . getCommunities ( ) ;
109+ } catch ( error ) {
110+ handleLoaderError ( error ) ;
111+ }
83112}
84113
114+ /**
115+ * Streams the community list promise so Suspense can render progress fallbacks.
116+ */
85117export async function clientLoader ( ) {
86- // console.log("clientloader context", getSessionTools(context));
87- const tools = getSessionTools ( ) ;
88- const dapper = new DapperTs ( ( ) => {
89- return {
90- apiHost : tools ?. getConfig ( ) . apiHost ,
91- sessionId : tools ?. getConfig ( ) . sessionId ,
92- } ;
93- } ) ;
94- return await dapper . getCommunities ( ) ;
118+ const { dapper } = getLoaderTools ( ) ;
119+ const communitiesPromise = dapper . getCommunities ( ) ;
120+
121+ return {
122+ communities : communitiesPromise ,
123+ } ;
95124}
96125
126+ /**
127+ * Streams communities via Suspense and delegates UI rendering to UploadContent.
128+ */
97129export default function Upload ( ) {
98- const uploadData = useLoaderData < typeof loader | typeof clientLoader > ( ) ;
99-
130+ const { communities } = useLoaderData < typeof loader | typeof clientLoader > ( ) ;
100131 const outletContext = useOutletContext ( ) as OutletContextShape ;
101- const requestConfig = outletContext . requestConfig ;
102- const currentUser = outletContext . currentUser ;
103- const dapper = outletContext . dapper ;
132+
133+ return (
134+ < Suspense fallback = { < UploadSkeleton /> } >
135+ < Await resolve = { communities } errorElement = { < NimbusAwaitErrorElement /> } >
136+ { ( result ) => (
137+ < UploadContent communities = { result } outletContext = { outletContext } />
138+ ) }
139+ </ Await >
140+ </ Suspense >
141+ ) ;
142+ }
143+
144+ /**
145+ * Renders the upload workflow once community metadata resolves.
146+ */
147+ function UploadContent ( { communities, outletContext } : UploadContentProps ) {
148+ const { requestConfig, currentUser, dapper } = outletContext ;
104149
105150 const toast = useToast ( ) ;
106151
@@ -122,13 +167,14 @@ export default function Upload() {
122167 } , [ currentUser ?. teams_full ] ) ;
123168
124169 // Community options
125- const communityOptions : CommunityOption [ ] = [ ] ;
126- for ( const community of uploadData . results ) {
127- communityOptions . push ( {
128- value : community . identifier ,
129- label : community . name ,
130- } ) ;
131- }
170+ const communityOptions : CommunityOption [ ] = communities . results . map (
171+ ( community ) => {
172+ return {
173+ value : community . identifier ,
174+ label : community . name ,
175+ } ;
176+ }
177+ ) ;
132178
133179 const [ submissionStatus , setSubmissionStatus ] =
134180 useState < PackageSubmissionStatus > ( ) ;
@@ -243,17 +289,29 @@ export default function Upload() {
243289 // TODO: Add sentry logging
244290 toast . addToast ( {
245291 csVariant : "danger" ,
246- children : `Error polling submission status: ${ error . message } ` ,
292+ children : `Error polling submission status: ${ getErrorMessage (
293+ error
294+ ) } `,
247295 duration : 8000 ,
248296 } ) ;
249297 } ) ;
250298 }
251299 } , [ submissionStatus ] ) ;
252300
253- const retryPolling = ( ) => {
254- if ( submissionStatus ?. id ) {
255- pollSubmission ( submissionStatus . id , true ) . then ( ( data ) => {
256- setSubmissionStatus ( data ) ;
301+ const retryPolling = async ( ) => {
302+ const submissionId = submissionStatus ?. id ;
303+ if ( ! submissionId ) {
304+ return ;
305+ }
306+
307+ try {
308+ const data = await pollSubmission ( submissionId , true ) ;
309+ setSubmissionStatus ( data ) ;
310+ } catch ( error ) {
311+ toast . addToast ( {
312+ csVariant : "danger" ,
313+ children : `Error polling submission status: ${ getErrorMessage ( error ) } ` ,
314+ duration : 8000 ,
257315 } ) ;
258316 }
259317 } ;
@@ -289,25 +347,63 @@ export default function Upload() {
289347 } ) ;
290348
291349 useEffect ( ( ) => {
292- for ( const community of formInputs . communities ) {
293- // Skip if we already have categories for this community
294- if ( categoryOptions . some ( ( opt ) => opt . communityId === community ) ) {
295- continue ;
296- }
297- dapper . getCommunityFilters ( community ) . then ( ( filters ) => {
298- setCategoryOptions ( ( prev ) => [
299- ...prev ,
300- {
301- communityId : community ,
302- categories : filters . package_categories . map ( ( cat ) => ( {
303- value : cat . slug ,
304- label : cat . name ,
305- } ) ) ,
306- } ,
307- ] ) ;
308- } ) ;
350+ const communitiesToFetch = formInputs . communities . filter (
351+ ( community ) =>
352+ ! categoryOptions . some ( ( opt ) => opt . communityId === community )
353+ ) ;
354+
355+ if ( communitiesToFetch . length === 0 ) {
356+ return ;
309357 }
310- } , [ formInputs . communities ] ) ;
358+
359+ let isCancelled = false ;
360+
361+ const fetchFilters = async ( ) => {
362+ await Promise . all (
363+ communitiesToFetch . map ( async ( community ) => {
364+ try {
365+ const filters = await dapper . getCommunityFilters ( community ) ;
366+ if ( isCancelled ) {
367+ return ;
368+ }
369+
370+ setCategoryOptions ( ( prev ) => {
371+ if ( prev . some ( ( opt ) => opt . communityId === community ) ) {
372+ return prev ;
373+ }
374+
375+ return [
376+ ...prev ,
377+ {
378+ communityId : community ,
379+ categories : filters . package_categories . map ( ( cat ) => ( {
380+ value : cat . slug ,
381+ label : cat . name ,
382+ } ) ) ,
383+ } ,
384+ ] ;
385+ } ) ;
386+ } catch ( error ) {
387+ if ( ! isCancelled ) {
388+ toast . addToast ( {
389+ csVariant : "danger" ,
390+ children : `Failed to load categories: ${ getErrorMessage (
391+ error
392+ ) } `,
393+ duration : 8000 ,
394+ } ) ;
395+ }
396+ }
397+ } )
398+ ) ;
399+ } ;
400+
401+ fetchFilters ( ) ;
402+
403+ return ( ) => {
404+ isCancelled = true ;
405+ } ;
406+ } , [ categoryOptions , dapper , formInputs . communities , toast ] ) ;
311407
312408 type SubmitorOutput = Awaited <
313409 ReturnType < typeof postPackageSubmissionMetadata >
@@ -350,7 +446,7 @@ export default function Upload() {
350446 onSubmitError : ( error ) => {
351447 toast . addToast ( {
352448 csVariant : "danger" ,
353- children : `Error occurred: ${ error . message || "Unknown error" } ` ,
449+ children : formatUserFacingError ( error ) ,
354450 duration : 8000 ,
355451 } ) ;
356452 } ,
@@ -567,7 +663,7 @@ export default function Upload() {
567663 </ div >
568664 < div className = "upload__content" >
569665 { formInputs . communities . map ( ( community ) => {
570- const communityData = uploadData . results . find (
666+ const communityData = communities . results . find (
571667 ( c ) => c . identifier === community
572668 ) ;
573669 const categories =
@@ -757,6 +853,32 @@ export default function Upload() {
757853 ) ;
758854}
759855
856+ /**
857+ * Shows a lightweight skeleton while communities load after navigation.
858+ */
859+ function UploadSkeleton ( ) {
860+ return (
861+ < section className = "container container--y container--full upload" >
862+ { [ 0 , 1 , 2 ] . map ( ( index ) => (
863+ < div
864+ key = { index }
865+ className = "container container--x container--full upload__row"
866+ style = { { marginBottom : "1rem" , height : "3rem" } }
867+ >
868+ < SkeletonBox />
869+ </ div >
870+ ) ) }
871+ </ section >
872+ ) ;
873+ }
874+
875+ export function ErrorBoundary ( ) {
876+ return < NimbusDefaultRouteErrorBoundary /> ;
877+ }
878+
879+ /**
880+ * Converts byte counts into a human-readable string for upload status messaging.
881+ */
760882function formatBytes ( bytes : number , decimals = 2 ) {
761883 if ( ! + bytes ) return "0 Bytes" ;
762884
@@ -779,6 +901,9 @@ function formatBytes(bytes: number, decimals = 2) {
779901 return `${ parseFloat ( ( bytes / Math . pow ( k , i ) ) . toFixed ( dm ) ) } ${ sizes [ i ] } ` ;
780902}
781903
904+ /**
905+ * Displays the submission success summary once the package metadata API responds.
906+ */
782907const SubmissionResult = ( props : {
783908 submissionStatusResult : PackageSubmissionResult ;
784909} ) => {
0 commit comments