diff --git a/apps/cyberstorm-remix/app/p/packageListing.css b/apps/cyberstorm-remix/app/p/packageListing.css index 5f932c86f..36fdb59e6 100644 --- a/apps/cyberstorm-remix/app/p/packageListing.css +++ b/apps/cyberstorm-remix/app/p/packageListing.css @@ -35,6 +35,19 @@ align-items: center; } + .package-listing__error { + display: flex; + flex-direction: column; + gap: 1rem; + align-items: flex-start; + padding: 3rem 0; + } + + .package-listing__error-description { + max-width: 40rem; + color: var(--Color-text-muted, rgb(180 189 255 / 0.8)); + } + .package-listing-management-tools__island { display: flex; gap: 6px; diff --git a/apps/cyberstorm-remix/app/p/packageListing.tsx b/apps/cyberstorm-remix/app/p/packageListing.tsx index ce5eca67d..c592a6f10 100644 --- a/apps/cyberstorm-remix/app/p/packageListing.tsx +++ b/apps/cyberstorm-remix/app/p/packageListing.tsx @@ -19,11 +19,20 @@ import { getPublicEnvVariables, getSessionTools, } from "cyberstorm/security/publicEnvVariables"; +import { + NimbusAwaitErrorElement, + NimbusDefaultRouteErrorBoundary, +} from "cyberstorm/utils/errors/NimbusErrorBoundary"; +import { handleLoaderError } from "cyberstorm/utils/errors/handleLoaderError"; +import { createNotFoundMapping } from "cyberstorm/utils/errors/loaderMappings"; +import { throwUserFacingPayloadResponse } from "cyberstorm/utils/errors/userFacingErrorResponse"; +import { getLoaderTools } from "cyberstorm/utils/getLoaderTools"; import { isPromise } from "cyberstorm/utils/typeChecks"; import { type ReactElement, Suspense, memo, + useCallback, useEffect, useMemo, useRef, @@ -66,6 +75,7 @@ import { DapperTs, type DapperTsInterface } from "@thunderstore/dapper-ts"; import { type RequestConfig, fetchPackagePermissions, + formatUserFacingError, packageListingApprove, packageListingReject, } from "@thunderstore/thunderstore-api"; @@ -73,33 +83,52 @@ import { ApiAction } from "@thunderstore/ts-api-react-actions"; import "./packageListing.css"; +const packageNotFoundMappings = [ + createNotFoundMapping( + "Package not found.", + "We could not find the requested package." + ), +]; + +const getErrorMessage = (error: unknown) => + error instanceof Error ? error.message : String(error); + type PackageListingOutletContext = OutletContextShape & { packageDownloadUrl?: string; }; export async function loader({ params }: LoaderFunctionArgs) { if (params.communityId && params.namespaceId && params.packageId) { - const publicEnvVariables = getPublicEnvVariables(["VITE_API_URL"]); - const dapper = new DapperTs(() => { - return { - apiHost: publicEnvVariables.VITE_API_URL, - sessionId: undefined, - }; - }); - - return { - community: await dapper.getCommunity(params.communityId), - communityFilters: await dapper.getCommunityFilters(params.communityId), - listing: await dapper.getPackageListingDetails( + const { dapper } = getLoaderTools(); + try { + const community = await dapper.getCommunity(params.communityId); + const communityFilters = await dapper.getCommunityFilters( + params.communityId + ); + const listing = await dapper.getPackageListingDetails( params.communityId, params.namespaceId, params.packageId - ), - team: await dapper.getTeamDetails(params.namespaceId), - permissions: undefined, - }; + ); + const team = await dapper.getTeamDetails(params.namespaceId); + + return { + community, + communityFilters, + listing, + team, + permissions: undefined, + }; + } catch (error) { + handleLoaderError(error, { mappings: packageNotFoundMappings }); + } } - throw new Response("Package not found", { status: 404 }); + throwUserFacingPayloadResponse({ + headline: "Package not found.", + description: "We could not find the requested package.", + category: "not_found", + status: 404, + }); } async function getUserPermissions( @@ -117,35 +146,62 @@ async function getUserPermissions( } // TODO: Needs to check if package is available for the logged in user -export async function clientLoader({ params }: LoaderFunctionArgs) { +export function clientLoader({ params }: LoaderFunctionArgs) { if (params.communityId && params.namespaceId && params.packageId) { - const tools = getSessionTools(); - const dapper = new DapperTs(() => { - return { - apiHost: tools.getConfig().apiHost, - sessionId: tools.getConfig().sessionId, - }; - }); - - return { - community: dapper.getCommunity(params.communityId), - communityFilters: dapper.getCommunityFilters(params.communityId), - listing: dapper.getPackageListingDetails( - params.communityId, - params.namespaceId, - params.packageId - ), - team: dapper.getTeamDetails(params.namespaceId), - permissions: getUserPermissions( - tools, - dapper, + const { dapper, sessionTools } = getLoaderTools(); + const tools = sessionTools ?? getSessionTools(); + const community = dapper + .getCommunity(params.communityId) + .catch((error) => + handleLoaderError(error, { mappings: packageNotFoundMappings }) + ); + const communityFilters = dapper + .getCommunityFilters(params.communityId) + .catch((error) => + handleLoaderError(error, { mappings: packageNotFoundMappings }) + ); + const listing = dapper + .getPackageListingDetails( params.communityId, params.namespaceId, params.packageId - ), + ) + .catch((error) => + handleLoaderError(error, { mappings: packageNotFoundMappings }) + ); + const team = dapper + .getTeamDetails(params.namespaceId) + .catch((error) => + handleLoaderError(error, { mappings: packageNotFoundMappings }) + ); + const permissions = getUserPermissions( + tools, + dapper, + params.communityId, + params.namespaceId, + params.packageId + ).catch((error) => + handleLoaderError(error, { mappings: packageNotFoundMappings }) + ); + + return { + community, + communityFilters, + listing, + team, + permissions, }; } - throw new Response("Package not found", { status: 404 }); + throwUserFacingPayloadResponse({ + headline: "Package not found.", + description: "We could not find the requested package.", + category: "not_found", + status: 404, + }); +} + +export function ErrorBoundary() { + return ; } clientLoader.hydrate = true; @@ -189,28 +245,51 @@ export default function PackageListing() { config: config, }); - const fetchAndSetRatedPackages = async () => { - const rp = await dapper.getRatedPackages(); - if (isPromise(listing)) { - listing.then((listingData) => { + const fetchAndSetRatedPackages = useCallback( + async (options?: { isCancelled?: () => boolean }) => { + try { + const ratedPackages = await dapper.getRatedPackages(); + const listingData = await Promise.resolve(listing); + if (options?.isCancelled?.()) { + return; + } + setIsLiked( - rp.rated_packages.includes( + ratedPackages.rated_packages.includes( `${listingData.namespace}-${listingData.name}` ) ); - }); - } else { - setIsLiked( - rp.rated_packages.includes(`${listing.namespace}-${listing.name}`) - ); - } - }; + } catch (error) { + if (!options?.isCancelled?.()) { + console.error("Failed to load rated packages", error); + toast.addToast({ + csVariant: "danger", + children: `Failed to fetch rated packages: ${getErrorMessage( + error + )}`, + duration: 6000, + }); + } + } + }, + [dapper, listing, toast] + ); useEffect(() => { - if (currentUser?.username) { - fetchAndSetRatedPackages(); + if (!currentUser?.username) { + return; } - }, [currentUser]); + + let isCancelled = false; + + fetchAndSetRatedPackages({ + isCancelled: () => isCancelled, + }); + + return () => { + isCancelled = true; + }; + }, [currentUser?.username, fetchAndSetRatedPackages]); const isHydrated = useHydrated(); const startsHydrated = useRef(isHydrated); @@ -226,8 +305,29 @@ export default function PackageListing() { // If strict mode is removed from the entry.client.tsx, this should only run once useEffect(() => { if (!startsHydrated.current && isHydrated) return; - if (isPromise(listing)) { - listing.then((listingData) => { + + if (!isPromise(listing)) { + setLastUpdated( + + ); + setFirstUploaded( + + ); + return; + } + + let isCancelled = false; + + const resolveListingTimes = async () => { + try { + const listingData = await listing; + if (isCancelled) { + return; + } + setLastUpdated( ); - }); - } else { - setLastUpdated( - - ); - setFirstUploaded( - - ); - } - }, []); + } catch (error) { + if (!isCancelled) { + console.error("Failed to resolve listing metadata", error); + } + } + }; + + resolveListingTimes(); + + return () => { + isCancelled = true; + }; + }, [isHydrated, listing]); // END: For sidebar meta dates const currentTab = location.pathname.split("/")[6] || "details"; const listingAndCommunityPromise = useMemo( () => Promise.all([listing, community]), - [] + [listing, community] ); const listingAndPermissionsPromise = useMemo( () => Promise.all([listing, permissions]), - [] + [listing, permissions] ); - const listingAndTeamPromise = useMemo(() => Promise.all([listing, team]), []); + const listingAndTeamPromise = useMemo( + () => Promise.all([listing, team]), + [listing, team] + ); const packageLikeAction = PackageLikeAction({ isLoggedIn: Boolean(currentUser?.username), @@ -278,7 +381,10 @@ export default function PackageListing() { return ( <> - + } + > {(resolvedValue) => ( <>
- + } + > {(resolvedValue) => resolvedValue && resolvedValue[1] ? (
@@ -340,7 +450,10 @@ export default function PackageListing() { } > - + } + > {(resolvedValue) => ( Loading...

}> - + } + > {(resolvedValue) => ( <> {packageMeta( @@ -418,7 +534,10 @@ export default function PackageListing() {
Loading...

}> - + } + > {(resolvedValue) => ( <> {packageBoxes( @@ -432,7 +551,10 @@ export default function PackageListing() {
Loading...

}> - + } + > {(resolvedValue) => ( } > - + } + > {(resolvedValue) => ( <> @@ -571,7 +696,10 @@ export default function PackageListing() { } > - + } + > {(resolvedValue) => ( } > - + } + > {(resolvedValue) => ( } > - + } + > {(resolvedValue) => ( <> {packageMeta(lastUpdated, firstUploaded, resolvedValue)} @@ -630,7 +764,10 @@ export default function PackageListing() { } > - + } + > {(resolvedValue) => ( <> {packageBoxes(resolvedValue[0], resolvedValue[1], domain)} @@ -642,7 +779,6 @@ export default function PackageListing() {
- {ReportPackageModal} ); @@ -680,7 +816,7 @@ function ReviewPackageForm(props: { onSubmitError: (error) => { toast.addToast({ csVariant: "danger", - children: `Error occurred: ${error.message || "Unknown error"}`, + children: formatUserFacingError(error), duration: 8000, }); }, @@ -698,7 +834,7 @@ function ReviewPackageForm(props: { onSubmitError: (error) => { toast.addToast({ csVariant: "danger", - children: `Error occurred: ${error.message || "Unknown error"}`, + children: formatUserFacingError(error), duration: 8000, }); },