From d86a71fd5a021100adcef5f5c1bac58412b890a1 Mon Sep 17 00:00:00 2001 From: Daniel Vainio Date: Wed, 10 Dec 2025 17:19:14 +0200 Subject: [PATCH 01/11] Implement listing status schemas - Implement object schema for package listing status - Implement response schema for package listing status --- .../thunderstore-api/src/schemas/objectSchemas.ts | 8 ++++++++ .../thunderstore-api/src/schemas/responseSchemas.ts | 11 +++++++++++ 2 files changed, 19 insertions(+) diff --git a/packages/thunderstore-api/src/schemas/objectSchemas.ts b/packages/thunderstore-api/src/schemas/objectSchemas.ts index caeaecc68..eae784f35 100644 --- a/packages/thunderstore-api/src/schemas/objectSchemas.ts +++ b/packages/thunderstore-api/src/schemas/objectSchemas.ts @@ -133,8 +133,16 @@ export const packageListingSchema = z.object({ size: z.number().int(), }); +export const packageListingStatusSchema = z.object({ + review_status: z.string().nullable().optional(), + rejection_reason: z.string().nullable().optional(), + internal_notes: z.string().nullable().optional(), +}); + export type PackageListing = z.infer; +export type PackageListingStatus = z.infer; + export const packageTeamSchema = z.object({ name: z.string().min(1), members: teamMemberSchema.array(), diff --git a/packages/thunderstore-api/src/schemas/responseSchemas.ts b/packages/thunderstore-api/src/schemas/responseSchemas.ts index 5d67c5939..104b89112 100644 --- a/packages/thunderstore-api/src/schemas/responseSchemas.ts +++ b/packages/thunderstore-api/src/schemas/responseSchemas.ts @@ -120,6 +120,17 @@ export type PackageListingDetailsResponseData = z.infer< typeof packageListingDetailsResponseDataSchema >; +// PackageListingStatusResponse +export const packageListingStatusResponseDataSchema = z.object({ + review_status: z.enum(["unreviewed", "approved", "rejected"]).nullable(), + rejection_reason: z.string().nullable(), + internal_notes: z.string().nullable(), +}); + +export type PackageListingStatusResponseData = z.infer< + typeof packageListingStatusResponseDataSchema +>; + // PackageVersionDetailsResponse export const packageVersionDetailsResponseDataSchema = z.object({ description: z.string(), From 0345650542edd03db2dba6fc0253f9f0485d870c Mon Sep 17 00:00:00 2001 From: Daniel Vainio Date: Wed, 10 Dec 2025 17:21:46 +0200 Subject: [PATCH 02/11] Implement function for fetching listing status - Implement a function for fetching a package listing's status from API - Update package details fetch function with useSession parameter --- .../src/get/packageListingDetails.ts | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/thunderstore-api/src/get/packageListingDetails.ts b/packages/thunderstore-api/src/get/packageListingDetails.ts index 9c98c3dc8..cc084d655 100644 --- a/packages/thunderstore-api/src/get/packageListingDetails.ts +++ b/packages/thunderstore-api/src/get/packageListingDetails.ts @@ -1,22 +1,49 @@ import { ApiEndpointProps } from "../index"; import { apiFetch } from "../apiFetch"; -import { packageListingDetailsSchema } from "../schemas/objectSchemas"; +import { + packageListingDetailsSchema, + packageListingStatusSchema, +} from "../schemas/objectSchemas"; import { PackageListingDetailsRequestParams } from "../schemas/requestSchemas"; -import { PackageListingDetailsResponseData } from "../schemas/responseSchemas"; +import { + PackageListingDetailsResponseData, + PackageListingStatusResponseData, +} from "../schemas/responseSchemas"; + +const basePath = "api/cyberstorm/listing/"; export async function fetchPackageListingDetails( props: ApiEndpointProps ): Promise { - const { config, params } = props; - const path = `api/cyberstorm/listing/${params.community_id}/${params.namespace_id}/${params.package_name}/`; + const { config, params, useSession } = props; + const path = `${basePath}${params.community_id}/${params.namespace_id}/${params.package_name}/`; return await apiFetch({ args: { config: config, path: path, + useSession: useSession, }, requestSchema: undefined, queryParamsSchema: undefined, responseSchema: packageListingDetailsSchema, }); } + +export async function fetchPackageListingStatus( + props: ApiEndpointProps +): Promise { + const { config, params } = props; + const path = `${basePath}${params.community_id}/${params.namespace_id}/${params.package_name}/status/`; + + return await apiFetch({ + args: { + config: config, + path: path, + useSession: true, + }, + requestSchema: undefined, + queryParamsSchema: undefined, + responseSchema: packageListingStatusSchema, + }); +} From ad25856097ca313c7439d61a9870069e47c82d72 Mon Sep 17 00:00:00 2001 From: Daniel Vainio Date: Wed, 10 Dec 2025 17:22:55 +0200 Subject: [PATCH 03/11] Implement dapper method for listing status - Implement a getter function for dapper for getting listing status - Update getPackageListing details with useSession parameter --- .../dapper-ts/src/methods/packageListings.ts | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/packages/dapper-ts/src/methods/packageListings.ts b/packages/dapper-ts/src/methods/packageListings.ts index 95a608986..3894a91f6 100644 --- a/packages/dapper-ts/src/methods/packageListings.ts +++ b/packages/dapper-ts/src/methods/packageListings.ts @@ -4,6 +4,7 @@ import { fetchNamespacePackageListings, fetchPackageDependantsListings, fetchPackageListingDetails, + fetchPackageListingStatus, PackageListingsOrderingEnum, PackageListingsRequestQueryParams, } from "@thunderstore/thunderstore-api"; @@ -121,7 +122,8 @@ export async function getPackageListingDetails( this: DapperTsInterface, communityId: string, namespaceId: string, - packageName: string + packageName: string, + useSession = false ) { const data = await fetchPackageListingDetails({ config: this.config, @@ -132,6 +134,27 @@ export async function getPackageListingDetails( }, data: {}, queryParams: {}, + useSession: useSession, + }); + + return data; +} + +export async function getPackageListingStatus( + this: DapperTsInterface, + communityId: string, + namespaceId: string, + packageName: string +) { + const data = await fetchPackageListingStatus({ + config: this.config, + params: { + community_id: communityId, + namespace_id: namespaceId, + package_name: packageName, + }, + data: {}, + queryParams: {}, }); return data; From 2b955734e3c6e17aff68fe67b8d581a1fe46365d Mon Sep 17 00:00:00 2001 From: Daniel Vainio Date: Wed, 10 Dec 2025 17:24:12 +0200 Subject: [PATCH 04/11] Add getPackageListingStatus to dapper index --- packages/dapper-ts/src/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/dapper-ts/src/index.ts b/packages/dapper-ts/src/index.ts index 7a7960eab..4e38aacc2 100644 --- a/packages/dapper-ts/src/index.ts +++ b/packages/dapper-ts/src/index.ts @@ -23,6 +23,7 @@ import { } from "./methods/package"; import { getPackageListingDetails, + getPackageListingStatus, getPackageListings, } from "./methods/packageListings"; import { @@ -56,6 +57,7 @@ export class DapperTs implements DapperTsInterface { this.getPackageChangelog = this.getPackageChangelog.bind(this); this.getPackageListings = this.getPackageListings.bind(this); this.getPackageListingDetails = this.getPackageListingDetails.bind(this); + this.getPackageListingStatus = this.getPackageListingStatus.bind(this); this.getPackageReadme = this.getPackageReadme.bind(this); this.getPackageVersionDetails = this.getPackageVersionDetails.bind(this); this.getPackageVersions = this.getPackageVersions.bind(this); @@ -85,6 +87,7 @@ export class DapperTs implements DapperTsInterface { public getPackageChangelog = getPackageChangelog; public getPackageListings = getPackageListings; public getPackageListingDetails = getPackageListingDetails; + public getPackageListingStatus = getPackageListingStatus; public getPackageReadme = getPackageReadme; public getPackageVersions = getPackageVersions; public getPackageVersionDependencies = getPackageVersionDependencies; From 792ca5c4bee4866cec031eaba215da709979d40d Mon Sep 17 00:00:00 2001 From: Daniel Vainio Date: Wed, 10 Dec 2025 17:25:08 +0200 Subject: [PATCH 05/11] Implement package detail utils Implement two functions responsible for getting public or private package listings from the same endpoint. This is required for users who should be able to see for instance rejected package listings. The function for getting public package listings is to be used in the SSR loader function, and the function for getting private package listings is to be used in the clientLoader function. --- apps/cyberstorm-remix/app/p/listingUtils.ts | 74 +++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 apps/cyberstorm-remix/app/p/listingUtils.ts diff --git a/apps/cyberstorm-remix/app/p/listingUtils.ts b/apps/cyberstorm-remix/app/p/listingUtils.ts new file mode 100644 index 000000000..c19cf1bb0 --- /dev/null +++ b/apps/cyberstorm-remix/app/p/listingUtils.ts @@ -0,0 +1,74 @@ +import { DapperTs } from "@thunderstore/dapper-ts"; +import { isApiError } from "@thunderstore/thunderstore-api"; + +export interface ListingIdentifiers { + communityId: string; + namespaceId: string; + packageId: string; +} + +/** + * Server-side listing fetcher: + * 1. Try public listing + * 2. If 404, return undefined + */ +export async function getPublicListing( + dapper: DapperTs, + ids: ListingIdentifiers +) { + const { communityId, namespaceId, packageId } = ids; + try { + return await dapper.getPackageListingDetails( + communityId, + namespaceId, + packageId + ); + } catch (e) { + if (isApiError(e) && e.response.status === 404) { + return undefined; + } else { + throw e; + } + } +} + +/** + * Client-side listing fetcher: + * 1. Try public listing + * 2. If 404, try private listing + * 3. If still missing, throw 404 + */ +export async function getPrivateListing( + dapper: DapperTs, + ids: ListingIdentifiers +) { + const { communityId, namespaceId, packageId } = ids; + + try { + return await dapper.getPackageListingDetails( + communityId, + namespaceId, + packageId + ); + } catch (err: any) { + const is404 = isApiError(err) && err.response?.status === 404; + if (!is404) { + throw err; + } + } + + const useSession = true; + const privateListing = await dapper.getPackageListingDetails( + communityId, + namespaceId, + packageId, + useSession + ); + + if (!privateListing) { + throw new Response("Package not found", { status: 404 }); + } + + return privateListing; +} + From 588c3dba864b3adaff32f7ecc44f08c3fcbe223d Mon Sep 17 00:00:00 2001 From: Daniel Vainio Date: Wed, 10 Dec 2025 17:27:30 +0200 Subject: [PATCH 06/11] Refactor existing package listing components - Move management tooling into an own file - Move ReviewPackageForm into an own file - Use revalidate in form to handle state updates - Update state usage in form - Add dynamic fetching of status colors - Add type interfaces - Small refactor to code for readability --- .../PackageListing/ManagementTools.tsx | 102 +++++++++ .../PackageListing/ReviewPackageForm.tsx | 198 ++++++++++++++++++ 2 files changed, 300 insertions(+) create mode 100644 apps/cyberstorm-remix/app/p/components/PackageListing/ManagementTools.tsx create mode 100644 apps/cyberstorm-remix/app/p/components/PackageListing/ReviewPackageForm.tsx diff --git a/apps/cyberstorm-remix/app/p/components/PackageListing/ManagementTools.tsx b/apps/cyberstorm-remix/app/p/components/PackageListing/ManagementTools.tsx new file mode 100644 index 000000000..009644af4 --- /dev/null +++ b/apps/cyberstorm-remix/app/p/components/PackageListing/ManagementTools.tsx @@ -0,0 +1,102 @@ +import { faCog, faList, faBoxOpen } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faArrowUpRight } from "@fortawesome/pro-solid-svg-icons"; +import { ReviewPackageForm } from "./ReviewPackageForm"; +import { type DapperTsInterface } from "@thunderstore/dapper-ts"; +import { NewButton, NewIcon, useToast } from "@thunderstore/cyberstorm"; +import { + fetchPackagePermissions, + type RequestConfig, +} from "@thunderstore/thunderstore-api"; + +export interface ManagementToolsProps { + packagePermissions: Awaited>; + listing: Awaited>; + listingStatus: any; //TODO: FIX THIS TYPE LATER + toast: ReturnType; + requestConfig: () => RequestConfig; +} + +export function ManagementTools({ + packagePermissions, + listing, + listingStatus, + toast, + requestConfig, +}: ManagementToolsProps) { + const perms = packagePermissions.permissions; + const pkg = packagePermissions.package; + + return ( +
+ {/* Review Package */} + {perms.can_moderate && ( +
+ + + {/* Package Listing admin link */} + {perms.can_view_listing_admin_page && ( + + + + + Listing admin + + + + + )} + + {/* Package admin link */} + {perms.can_view_package_admin_page && ( + + + + + Package admin + + + + + )} +
+ )} + + {/* Manage package */} + {perms.can_manage && ( +
+ + + + + Manage Package + +
+ )} +
+ ); +} diff --git a/apps/cyberstorm-remix/app/p/components/PackageListing/ReviewPackageForm.tsx b/apps/cyberstorm-remix/app/p/components/PackageListing/ReviewPackageForm.tsx new file mode 100644 index 000000000..80fc2ebd9 --- /dev/null +++ b/apps/cyberstorm-remix/app/p/components/PackageListing/ReviewPackageForm.tsx @@ -0,0 +1,198 @@ +import { useEffect, useState } from "react"; +import { useRevalidator } from "react-router"; +import { faScaleBalanced } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { ApiAction } from "@thunderstore/ts-api-react-actions"; +import { + Modal, + NewAlert, + NewButton, + NewIcon, + NewTag, + NewTextInput, + useToast, +} from "@thunderstore/cyberstorm"; +import { + packageListingApprove, + packageListingReject, + type RequestConfig, +} from "@thunderstore/thunderstore-api"; + +export interface ReviewPackageFormProps { + communityId: string; + namespaceId: string; + packageId: string; + packageListingStatus?: any; //TODO: FIX THIS TYPE LATER + config: () => RequestConfig; + toast: ReturnType; +} + +const reviewStatusColorMap = { + approved: "green", + rejected: "red", + unreviewed: "orange", +} as const; + +export function ReviewPackageForm({ + communityId, + namespaceId, + packageId, + packageListingStatus, + config, + toast, +}: ReviewPackageFormProps) { + const [rejectionReason, setRejectionReason] = useState( + packageListingStatus?.rejection_reason ?? "" + ); + + const [internalNotes, setInternalNotes] = useState( + packageListingStatus?.internal_notes ?? "" + ); + + const reviewStatus = packageListingStatus?.review_status ?? "unreviewed"; + const reviewStatusColor = reviewStatusColorMap[reviewStatus]; + const { revalidate } = useRevalidator(); + + useEffect(() => { + setRejectionReason(packageListingStatus?.rejection_reason ?? ""); + setInternalNotes(packageListingStatus?.internal_notes ?? ""); + }, [packageListingStatus]); + + const rejectPackageAction = ApiAction({ + endpoint: packageListingReject, + onSubmitSuccess: () => { + toast.addToast({ + csVariant: "success", + children: `Package rejected`, + duration: 4000, + }); + revalidate(); + }, + onSubmitError: (error) => { + toast.addToast({ + csVariant: "danger", + children: `Error occurred: ${error.message || "Unknown error"}`, + duration: 8000, + }); + }, + }); + + const approvePackageAction = ApiAction({ + endpoint: packageListingApprove, + onSubmitSuccess: () => { + toast.addToast({ + csVariant: "success", + children: `Package approved`, + duration: 4000, + }); + revalidate(); + }, + onSubmitError: (error) => { + toast.addToast({ + csVariant: "danger", + children: `Error occurred: ${error.message || "Unknown error"}`, + duration: 8000, + }); + }, + }); + + return ( + + + + + Review Package + + } + titleContent="Review Package" + > + + + Changes might take several minutes to show publicly! Info shown below + is always up to date. + + +
+

Review status

+ + {reviewStatus} + +
+ +
+

+ Reject reason (saved on reject) +

+ setRejectionReason(e.target.value)} + placeholder="Invalid submission" + csSize="textarea" + rootClasses="review-package__textarea" + /> +
+ +
+

Internal notes

+ setInternalNotes(e.target.value)} + placeholder=".exe requires manual review" + csSize="textarea" + rootClasses="review-package__textarea" + /> +
+
+ + + + rejectPackageAction({ + config, + params: { + community: communityId, + namespace: namespaceId, + package: packageId, + }, + queryParams: {}, + data: { + rejection_reason: rejectionReason, + internal_notes: internalNotes ? internalNotes : null, + }, + }) + } + > + Reject + + + + approvePackageAction({ + config, + params: { + community: communityId, + namespace: namespaceId, + package: packageId, + }, + queryParams: {}, + data: { + internal_notes: internalNotes ? internalNotes : null, + }, + }) + } + > + Approve + + +
+ ); +} From b4363360f502c05b49c565a3aba2b28f68b27561 Mon Sep 17 00:00:00 2001 From: Daniel Vainio Date: Wed, 10 Dec 2025 17:30:37 +0200 Subject: [PATCH 07/11] Update and refactor package listing component - Update clientLoader and loader to use utils - Get public package listing with loader - Get public/private package listing in clientLoader - Get package listing status in clientLoader - Utilize getPackagePermissions in order to view/hide management tooling - Remove shouldRevalidate function - Utilize components split into other files - Simplify component code with less nested Suspense/Await blocks - Remove usage of useMemo for Promises --- .../cyberstorm-remix/app/p/packageListing.tsx | 925 ++++++------------ 1 file changed, 318 insertions(+), 607 deletions(-) diff --git a/apps/cyberstorm-remix/app/p/packageListing.tsx b/apps/cyberstorm-remix/app/p/packageListing.tsx index 6f0724c69..b58f3875e 100644 --- a/apps/cyberstorm-remix/app/p/packageListing.tsx +++ b/apps/cyberstorm-remix/app/p/packageListing.tsx @@ -3,7 +3,6 @@ import { type ReactElement, Suspense, useEffect, - useMemo, useRef, useState, } from "react"; @@ -14,7 +13,6 @@ import { useLocation, useOutletContext, type LoaderFunctionArgs, - type ShouldRevalidateFunctionArgs, } from "react-router"; import { useHydrated } from "remix-utils/use-hydrated"; import { @@ -24,8 +22,6 @@ import { faThumbsUp, faWarning, faCaretRight, - faScaleBalanced, - faCog, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faArrowUpRight, faLips } from "@fortawesome/pro-solid-svg-icons"; @@ -44,15 +40,11 @@ import { import { Drawer, Heading, - Modal, - NewAlert, NewButton, NewIcon, NewLink, NewTag, - NewTextInput, RelativeTime, - SkeletonBox, Tabs, ThunderstoreLogo, formatFileSize, @@ -61,16 +53,10 @@ import { useToast, } from "@thunderstore/cyberstorm"; import { PackageLikeAction } from "@thunderstore/cyberstorm-forms"; -import type { TagVariants } from "@thunderstore/cyberstorm-theme/src/components"; import type { CurrentUser } from "@thunderstore/dapper/types"; import { DapperTs, type DapperTsInterface } from "@thunderstore/dapper-ts"; -import { - fetchPackagePermissions, - packageListingApprove, - packageListingReject, - type RequestConfig, -} from "@thunderstore/thunderstore-api"; -import { ApiAction } from "@thunderstore/ts-api-react-actions"; +import { getPublicListing, getPrivateListing } from "./listingUtils"; +import { ManagementTools } from "./components/PackageListing/ManagementTools"; import "./packageListing.css"; @@ -79,28 +65,35 @@ type PackageListingOutletContext = OutletContextShape & { }; 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, - }; - }); + const { communityId, namespaceId, packageId } = params; - return { - community: await dapper.getCommunity(params.communityId), - communityFilters: await dapper.getCommunityFilters(params.communityId), - listing: await dapper.getPackageListingDetails( - params.communityId, - params.namespaceId, - params.packageId - ), - team: await dapper.getTeamDetails(params.namespaceId), - permissions: undefined, - }; + if (!communityId || !namespaceId || !packageId) { + throw new Response("Package not found", { status: 404 }); } - throw new Response("Package not found", { status: 404 }); + + const publicEnvVariables = getPublicEnvVariables(["VITE_API_URL"]); + const dapper = new DapperTs(() => ({ + apiHost: publicEnvVariables.VITE_API_URL, + sessionId: undefined, + })); + + const listing = await getPublicListing(dapper, { + communityId, + namespaceId, + packageId, + }); + + return { + community: await dapper.getCommunity(communityId), + communityFilters: await dapper.getCommunityFilters(communityId), + listing: listing, + listingStatus: undefined, + team: await dapper.getTeamDetails(namespaceId), + permissions: undefined, + community_identifier: communityId, + namespace_id: namespaceId, + package_id: packageId, + }; } async function getUserPermissions( @@ -112,63 +105,84 @@ async function getUserPermissions( ) { const cu = await tools.getSessionCurrentUser(); if (cu.username) { - return dapper.getPackagePermissions(communityId, namespaceId, packageId); + return await dapper.getPackagePermissions(communityId, namespaceId, packageId); } return undefined; } -// TODO: Needs to check if package is available for the logged in user -export async 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, - params.communityId, - params.namespaceId, - params.packageId - ), - }; +async function getPackageListingStatus( + tools: ReturnType, + dapper: DapperTs, + communityId: string, + namespaceId: string, + packageId: string +) { + const cu = await tools.getSessionCurrentUser(); + if (cu.username) { + return await dapper.getPackageListingStatus(communityId, namespaceId, packageId); } - throw new Response("Package not found", { status: 404 }); + return undefined; } -clientLoader.hydrate = true; +export async function clientLoader({ params }: LoaderFunctionArgs) { + const { communityId, namespaceId, packageId } = params; -export function shouldRevalidate(arg: ShouldRevalidateFunctionArgs) { - const oldPath = arg.currentUrl.pathname.split("/"); - const newPath = arg.nextUrl.pathname.split("/"); - // If we're staying on the same package page, don't revalidate - if ( - oldPath[2] === newPath[2] && - oldPath[3] === newPath[3] && - oldPath[5] === newPath[5] - ) { - return false; + if (!communityId || !namespaceId || !packageId) { + throw new Response("Package not found", { status: 404 }); } - return arg.defaultShouldRevalidate; + + const tools = getSessionTools(); + const config = tools.getConfig(); + const dapper = new DapperTs(() => ({ + apiHost: config.apiHost, + sessionId: config.sessionId, + })); + + const listing = await getPrivateListing(dapper, { + communityId, + namespaceId, + packageId, + }); + + return { + community: dapper.getCommunity(communityId), + communityFilters: dapper.getCommunityFilters(communityId), + listing: listing, + listingStatus: getPackageListingStatus( + tools, + dapper, + communityId, + namespaceId, + packageId + ), + team: dapper.getTeamDetails(namespaceId), + permissions: getUserPermissions( + tools, + dapper, + communityId, + namespaceId, + packageId + ), + community_identifier: communityId, + namespace_id: namespaceId, + package_id: packageId, + }; } +clientLoader.hydrate = true; + export default function PackageListing() { - const { community, listing, team, permissions } = useLoaderData< - typeof loader | typeof clientLoader - >(); + const { + community, + listing, + listingStatus, + team, + permissions, + community_identifier, + namespace_id, + package_id + } = useLoaderData(); + const location = useLocation(); @@ -182,14 +196,16 @@ export default function PackageListing() { const toast = useToast(); const { ReportPackageButton, ReportPackageModal } = useReportPackage({ - formPropsPromise: Promise.resolve(listing).then((listingData) => ({ - community: listingData.community_identifier, - namespace: listingData.namespace, - package: listingData.name, + formPropsPromise: new Promise(() => ({ + community: community_identifier, + namespace: namespace_id, + package: package_id, })), config: config, }); + // TODO: This needs to be fixes - listing is not a promise here + // TODO: no need to get namespace and name from listing if we have them in params const fetchAndSetRatedPackages = async () => { const rp = await dapper.getRatedPackages(); if (isPromise(listing)) { @@ -226,7 +242,14 @@ export default function PackageListing() { // https://react.dev/reference/react/StrictMode // If strict mode is removed from the entry.client.tsx, this should only run once useEffect(() => { - if (!startsHydrated.current && isHydrated) return; + if (!startsHydrated.current && isHydrated) { + return; + } + + if (!listing) { + return; + } + if (isPromise(listing)) { listing.then((listingData) => { setLastUpdated( @@ -258,36 +281,29 @@ export default function PackageListing() { const currentTab = location.pathname.split("/")[6] || "details"; - const listingAndCommunityPromise = useMemo( - () => Promise.all([listing, community]), - [] - ); - - const listingAndPermissionsPromise = useMemo( - () => Promise.all([listing, permissions]), - [] - ); - - const listingAndTeamPromise = useMemo(() => Promise.all([listing, team]), []); - const packageLikeAction = PackageLikeAction({ isLoggedIn: Boolean(currentUser?.username), dataUpdateTrigger: fetchAndSetRatedPackages, config: config, }); + if (!listing) { + return
Loading package...
; + } + + // TODO: some variables are available in props (communityId, namespaceId, packageId) return ( <> - - {(resolvedValue) => ( + + {(resolvedCommunity) => ( <> - + - + - + )} +
- - {(resolvedValue) => - resolvedValue && resolvedValue[1] ? ( + + {([resolvedStatus, resolvedPermissions]) => + resolvedPermissions ? (
- {managementTools( - resolvedValue[1], - resolvedValue[0], - toast, - config - )} +
) : null }
+
- + + + + + + {listing.namespace} + + + {listing.website_url ? ( + + {listing.website_url} + + + + + ) : null} + } > - - {(resolvedValue) => ( - - - - - - {resolvedValue.namespace} - - {resolvedValue.website_url ? ( - - {resolvedValue.website_url} - - - - - ) : null} - - } - > - {formatToDisplayName(resolvedValue.name)} - - )} - - + {formatToDisplayName(listing.name)} + +
+ - Details - - } + headerContent={Details} rootClasses="package-listing__drawer" > + {packageMeta(lastUpdated, firstUploaded, listing)} + Loading...

}> - - {(resolvedValue) => ( - <> - {packageMeta( - lastUpdated, - firstUploaded, - resolvedValue - )} - - )} - -
- Loading...

}> - - {(resolvedValue) => ( - <> - {packageBoxes( - resolvedValue[0], - resolvedValue[1], - domain - )} - - )} + + {(resolvedCommunity) => + packageBoxes(listing, resolvedCommunity, domain) + }
+ Loading...

}> - - {(resolvedValue) => ( + + {(resolvedTeam) => ( - - } - > - - {(resolvedValue) => ( - <> - - - Details - - - Required ({resolvedValue.dependency_count}) - - - Wiki - - - Changelog - - - Versions - - - Analysis - - -
- -
- - )} -
-
+ + <> + + + Details + + + + Required ({listing.dependency_count}) + + + + Wiki + + + + Changelog + + + + Versions + + + + Analysis + + + +
+ +
+
+ @@ -649,160 +595,6 @@ export default function PackageListing() { ); } -function ReviewPackageForm(props: { - communityId: string; - namespaceId: string; - packageId: string; - reviewStatus: string; - reviewStatusColor: TagVariants; - config: () => RequestConfig; - toast: ReturnType; -}) { - const { - communityId, - namespaceId, - packageId, - reviewStatus, - reviewStatusColor, - toast, - config, - } = props; - const [rejectionReason, setRejectionReason] = useState(""); - const [internalNotes, setInternalNotes] = useState(""); - const rejectPackageAction = ApiAction({ - endpoint: packageListingReject, - onSubmitSuccess: () => { - toast.addToast({ - csVariant: "success", - children: `Package rejected`, - duration: 4000, - }); - }, - onSubmitError: (error) => { - toast.addToast({ - csVariant: "danger", - children: `Error occurred: ${error.message || "Unknown error"}`, - duration: 8000, - }); - }, - }); - - const approvePackageAction = ApiAction({ - endpoint: packageListingApprove, - onSubmitSuccess: () => { - toast.addToast({ - csVariant: "success", - children: `Package approved`, - duration: 4000, - }); - }, - onSubmitError: (error) => { - toast.addToast({ - csVariant: "danger", - children: `Error occurred: ${error.message || "Unknown error"}`, - duration: 8000, - }); - }, - }); - - return ( - - - - - Review Package - - } - titleContent="Review Package" - > - - - Changes might take several minutes to show publicly! Info shown below - is always up to date. - -
-

Review status

- - {reviewStatus} - -
-
-

- Reject reason (saved on reject) -

- setRejectionReason(e.target.value)} - placeholder="Invalid submission" - csSize="textarea" - rootClasses="review-package__textarea" - /> -
-
-

Internal notes

- setInternalNotes(e.target.value)} - placeholder=".exe requires manual review" - csSize="textarea" - rootClasses="review-package__textarea" - /> -
-
- - - rejectPackageAction({ - config: config, - params: { - community: communityId, - namespace: namespaceId, - package: packageId, - }, - queryParams: {}, - data: { - rejection_reason: rejectionReason, - internal_notes: internalNotes ? internalNotes : null, - }, - }) - } - > - Reject - - - approvePackageAction({ - config: config, - params: { - community: communityId, - namespace: namespaceId, - package: packageId, - }, - queryParams: {}, - data: { - internal_notes: internalNotes ? internalNotes : null, - }, - }) - } - > - Approve - - -
- ); -} - -ReviewPackageForm.displayName = "ReviewPackageForm"; - function packageTags( listing: Awaited>, community: Awaited> @@ -876,87 +668,6 @@ function packageBoxes( ); } -// TODO: Enable when APIs are available -function managementTools( - packagePermissions: Awaited>, - listing: Awaited>, - toast: ReturnType, - requestConfig: () => RequestConfig -) { - return ( -
- {packagePermissions.permissions.can_moderate ? ( -
- {packagePermissions.permissions.can_moderate ? ( - - ) : null} - {/* {packagePermissions.permissions.can_view_listing_admin_page ? ( - - - - - Listing admin - - - - - ) : null} - {packagePermissions.permissions.can_view_package_admin_page ? ( - - - - - Package admin - - - - - ) : null} */} -
- ) : null} - {packagePermissions.permissions.can_manage ? ( -
- - - - - Manage Package - -
- ) : null} -
- ); -} - const Actions = memo(function Actions(props: { team: Awaited>; listing: Awaited>; From 82704fa4cece0cd8ce5cc7afc58de9c1a9cc465a Mon Sep 17 00:00:00 2001 From: Daniel Vainio Date: Thu, 11 Dec 2025 15:36:00 +0200 Subject: [PATCH 08/11] Fix types and reportform - Add and fix missing types - Fix styling issues - Fix report form --- .../PackageListing/ManagementTools.tsx | 3 +- .../PackageListing/ReviewPackageForm.tsx | 9 ++++-- apps/cyberstorm-remix/app/p/listingUtils.ts | 6 ++-- .../cyberstorm-remix/app/p/packageListing.tsx | 30 ++++++++++++++----- packages/dapper/src/types/package.ts | 6 ++++ .../src/schemas/objectSchemas.ts | 5 +++- 6 files changed, 44 insertions(+), 15 deletions(-) diff --git a/apps/cyberstorm-remix/app/p/components/PackageListing/ManagementTools.tsx b/apps/cyberstorm-remix/app/p/components/PackageListing/ManagementTools.tsx index 009644af4..ce0fa153f 100644 --- a/apps/cyberstorm-remix/app/p/components/PackageListing/ManagementTools.tsx +++ b/apps/cyberstorm-remix/app/p/components/PackageListing/ManagementTools.tsx @@ -8,11 +8,12 @@ import { fetchPackagePermissions, type RequestConfig, } from "@thunderstore/thunderstore-api"; +import { type PackageListingStatus } from "@thunderstore/dapper/types"; export interface ManagementToolsProps { packagePermissions: Awaited>; listing: Awaited>; - listingStatus: any; //TODO: FIX THIS TYPE LATER + listingStatus: PackageListingStatus; toast: ReturnType; requestConfig: () => RequestConfig; } diff --git a/apps/cyberstorm-remix/app/p/components/PackageListing/ReviewPackageForm.tsx b/apps/cyberstorm-remix/app/p/components/PackageListing/ReviewPackageForm.tsx index 80fc2ebd9..3bb873b07 100644 --- a/apps/cyberstorm-remix/app/p/components/PackageListing/ReviewPackageForm.tsx +++ b/apps/cyberstorm-remix/app/p/components/PackageListing/ReviewPackageForm.tsx @@ -18,11 +18,13 @@ import { type RequestConfig, } from "@thunderstore/thunderstore-api"; +import { type PackageListingStatus } from "@thunderstore/dapper/types"; + export interface ReviewPackageFormProps { communityId: string; namespaceId: string; packageId: string; - packageListingStatus?: any; //TODO: FIX THIS TYPE LATER + packageListingStatus: PackageListingStatus; config: () => RequestConfig; toast: ReturnType; } @@ -50,7 +52,10 @@ export function ReviewPackageForm({ ); const reviewStatus = packageListingStatus?.review_status ?? "unreviewed"; - const reviewStatusColor = reviewStatusColorMap[reviewStatus]; + const reviewStatusColor = + reviewStatusColorMap[reviewStatus as keyof typeof reviewStatusColorMap] ?? + "orange"; + const { revalidate } = useRevalidator(); useEffect(() => { diff --git a/apps/cyberstorm-remix/app/p/listingUtils.ts b/apps/cyberstorm-remix/app/p/listingUtils.ts index c19cf1bb0..70acd605b 100644 --- a/apps/cyberstorm-remix/app/p/listingUtils.ts +++ b/apps/cyberstorm-remix/app/p/listingUtils.ts @@ -50,10 +50,10 @@ export async function getPrivateListing( namespaceId, packageId ); - } catch (err: any) { - const is404 = isApiError(err) && err.response?.status === 404; + } catch (e) { + const is404 = isApiError(e) && e.response?.status === 404; if (!is404) { - throw err; + throw e; } } diff --git a/apps/cyberstorm-remix/app/p/packageListing.tsx b/apps/cyberstorm-remix/app/p/packageListing.tsx index b58f3875e..e60d78899 100644 --- a/apps/cyberstorm-remix/app/p/packageListing.tsx +++ b/apps/cyberstorm-remix/app/p/packageListing.tsx @@ -105,7 +105,11 @@ async function getUserPermissions( ) { const cu = await tools.getSessionCurrentUser(); if (cu.username) { - return await dapper.getPackagePermissions(communityId, namespaceId, packageId); + return await dapper.getPackagePermissions( + communityId, + namespaceId, + packageId + ); } return undefined; } @@ -119,7 +123,11 @@ async function getPackageListingStatus( ) { const cu = await tools.getSessionCurrentUser(); if (cu.username) { - return await dapper.getPackageListingStatus(communityId, namespaceId, packageId); + return await dapper.getPackageListingStatus( + communityId, + namespaceId, + packageId + ); } return undefined; } @@ -180,10 +188,9 @@ export default function PackageListing() { permissions, community_identifier, namespace_id, - package_id + package_id, } = useLoaderData(); - const location = useLocation(); const outletContext = useOutletContext() as OutletContextShape; @@ -196,11 +203,11 @@ export default function PackageListing() { const toast = useToast(); const { ReportPackageButton, ReportPackageModal } = useReportPackage({ - formPropsPromise: new Promise(() => ({ + formPropsPromise: Promise.resolve({ community: community_identifier, namespace: namespace_id, package: package_id, - })), + }), config: config, }); @@ -320,7 +327,10 @@ export default function PackageListing() { - + )} @@ -404,7 +414,11 @@ export default function PackageListing() { Details} + headerContent={ + + Details + + } rootClasses="package-listing__drawer" > {packageMeta(lastUpdated, firstUploaded, listing)} diff --git a/packages/dapper/src/types/package.ts b/packages/dapper/src/types/package.ts index a4d800978..91397474a 100644 --- a/packages/dapper/src/types/package.ts +++ b/packages/dapper/src/types/package.ts @@ -19,6 +19,12 @@ export interface PackageListing { export type PackageListings = PaginatedList; +export interface PackageListingStatus { + review_status: string; + rejection_reason: string | null; + internal_notes: string | null; +} + export interface PackageListingDetails extends PackageListing { community_name: string; datetime_created: string; diff --git a/packages/thunderstore-api/src/schemas/objectSchemas.ts b/packages/thunderstore-api/src/schemas/objectSchemas.ts index eae784f35..be49cc5f1 100644 --- a/packages/thunderstore-api/src/schemas/objectSchemas.ts +++ b/packages/thunderstore-api/src/schemas/objectSchemas.ts @@ -134,7 +134,10 @@ export const packageListingSchema = z.object({ }); export const packageListingStatusSchema = z.object({ - review_status: z.string().nullable().optional(), + review_status: z + .enum(["unreviewed", "approved", "rejected"]) + .nullable() + .optional(), rejection_reason: z.string().nullable().optional(), internal_notes: z.string().nullable().optional(), }); From 733c5c425f4cc3e1e2f1875804e04edd79ee7f8a Mon Sep 17 00:00:00 2001 From: Daniel Vainio Date: Fri, 12 Dec 2025 16:41:52 +0200 Subject: [PATCH 09/11] Move package detail URL & small refactoring - Move baseUrl to index.ts for re-usability - Simplify if statement in packageListing.tsx - Remove useSession boolean variable and just pass true to getPackageListingDetails --- apps/cyberstorm-remix/app/p/listingUtils.ts | 3 +-- apps/cyberstorm-remix/app/p/packageListing.tsx | 7 ++----- packages/thunderstore-api/src/get/packageListingDetails.ts | 6 +++--- packages/thunderstore-api/src/index.ts | 2 ++ 4 files changed, 8 insertions(+), 10 deletions(-) diff --git a/apps/cyberstorm-remix/app/p/listingUtils.ts b/apps/cyberstorm-remix/app/p/listingUtils.ts index 70acd605b..a5d3a65e1 100644 --- a/apps/cyberstorm-remix/app/p/listingUtils.ts +++ b/apps/cyberstorm-remix/app/p/listingUtils.ts @@ -57,12 +57,11 @@ export async function getPrivateListing( } } - const useSession = true; const privateListing = await dapper.getPackageListingDetails( communityId, namespaceId, packageId, - useSession + true ); if (!privateListing) { diff --git a/apps/cyberstorm-remix/app/p/packageListing.tsx b/apps/cyberstorm-remix/app/p/packageListing.tsx index e60d78899..74e821833 100644 --- a/apps/cyberstorm-remix/app/p/packageListing.tsx +++ b/apps/cyberstorm-remix/app/p/packageListing.tsx @@ -249,11 +249,7 @@ export default function PackageListing() { // https://react.dev/reference/react/StrictMode // If strict mode is removed from the entry.client.tsx, this should only run once useEffect(() => { - if (!startsHydrated.current && isHydrated) { - return; - } - - if (!listing) { + if (!listing || (!startsHydrated.current && isHydrated)) { return; } @@ -294,6 +290,7 @@ export default function PackageListing() { config: config, }); + // TODO: Add proper loading element if (!listing) { return
Loading package...
; } diff --git a/packages/thunderstore-api/src/get/packageListingDetails.ts b/packages/thunderstore-api/src/get/packageListingDetails.ts index cc084d655..189900891 100644 --- a/packages/thunderstore-api/src/get/packageListingDetails.ts +++ b/packages/thunderstore-api/src/get/packageListingDetails.ts @@ -10,13 +10,13 @@ import { PackageListingStatusResponseData, } from "../schemas/responseSchemas"; -const basePath = "api/cyberstorm/listing/"; +import { BASE_LISTING_PATH } from "../index"; export async function fetchPackageListingDetails( props: ApiEndpointProps ): Promise { const { config, params, useSession } = props; - const path = `${basePath}${params.community_id}/${params.namespace_id}/${params.package_name}/`; + const path = `${BASE_LISTING_PATH}${params.community_id}/${params.namespace_id}/${params.package_name}/`; return await apiFetch({ args: { @@ -34,7 +34,7 @@ export async function fetchPackageListingStatus( props: ApiEndpointProps ): Promise { const { config, params } = props; - const path = `${basePath}${params.community_id}/${params.namespace_id}/${params.package_name}/status/`; + const path = `${BASE_LISTING_PATH}${params.community_id}/${params.namespace_id}/${params.package_name}/status/`; return await apiFetch({ args: { diff --git a/packages/thunderstore-api/src/index.ts b/packages/thunderstore-api/src/index.ts index 9e4b0ffae..18f03ace2 100644 --- a/packages/thunderstore-api/src/index.ts +++ b/packages/thunderstore-api/src/index.ts @@ -14,6 +14,8 @@ export interface ApiEndpointProps { queryParams: QueryParams; } +export const BASE_LISTING_PATH = "api/cyberstorm/listing/"; + export * from "./delete/packageWiki"; export * from "./delete/teamDisband"; export * from "./delete/teamRemoveMember"; From 5bf60acdbcfee29508c6b278dd6ed8058ea20f43 Mon Sep 17 00:00:00 2001 From: Daniel Vainio Date: Mon, 15 Dec 2025 16:31:37 +0200 Subject: [PATCH 10/11] Prevent infinite render loop Use nested Promise/Await instead of Promise.all in component --- .../cyberstorm-remix/app/p/packageListing.tsx | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/apps/cyberstorm-remix/app/p/packageListing.tsx b/apps/cyberstorm-remix/app/p/packageListing.tsx index 74e821833..a28757665 100644 --- a/apps/cyberstorm-remix/app/p/packageListing.tsx +++ b/apps/cyberstorm-remix/app/p/packageListing.tsx @@ -337,20 +337,22 @@ export default function PackageListing() {
- - {([resolvedStatus, resolvedPermissions]) => - resolvedPermissions ? ( -
- -
- ) : null - } + + {(resolvedStatus) => ( + + {(resolvedPermissions) => + resolvedPermissions ? ( + + ) : null + } + + )}
From ed6ffad2bda667af77cea46b89dd5ea360411c36 Mon Sep 17 00:00:00 2001 From: Daniel Vainio Date: Tue, 16 Dec 2025 14:11:28 +0200 Subject: [PATCH 11/11] Run pre-commit --- apps/cyberstorm-remix/app/p/listingUtils.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/cyberstorm-remix/app/p/listingUtils.ts b/apps/cyberstorm-remix/app/p/listingUtils.ts index a5d3a65e1..da17906c2 100644 --- a/apps/cyberstorm-remix/app/p/listingUtils.ts +++ b/apps/cyberstorm-remix/app/p/listingUtils.ts @@ -70,4 +70,3 @@ export async function getPrivateListing( return privateListing; } -