Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
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";
import { type PackageListingStatus } from "@thunderstore/dapper/types";

export interface ManagementToolsProps {
packagePermissions: Awaited<ReturnType<typeof fetchPackagePermissions>>;
listing: Awaited<ReturnType<DapperTsInterface["getPackageListingDetails"]>>;
listingStatus: PackageListingStatus;
toast: ReturnType<typeof useToast>;
requestConfig: () => RequestConfig;
}

export function ManagementTools({
packagePermissions,
listing,
listingStatus,
toast,
requestConfig,
}: ManagementToolsProps) {
const perms = packagePermissions.permissions;
const pkg = packagePermissions.package;

return (
<div className="package-listing-management-tools">
{/* Review Package */}
{perms.can_moderate && (
<div className="package-listing-management-tools__island">
<ReviewPackageForm
communityId={listing.community_identifier}
namespaceId={listing.namespace}
packageId={listing.name}
packageListingStatus={listingStatus}
toast={toast}
config={requestConfig}
/>

{/* Package Listing admin link */}
{perms.can_view_listing_admin_page && (
<NewButton
csSize="small"
csVariant="secondary"
primitiveType="link"
href=""
>
<NewIcon csMode="inline" noWrapper>
<FontAwesomeIcon icon={faList} />
</NewIcon>
Listing admin
<NewIcon csMode="inline" noWrapper>
<FontAwesomeIcon icon={faArrowUpRight} />
</NewIcon>
</NewButton>
)}

{/* Package admin link */}
{perms.can_view_package_admin_page && (
<NewButton
csSize="small"
csVariant="secondary"
primitiveType="link"
href=""
>
<NewIcon csMode="inline" noWrapper>
<FontAwesomeIcon icon={faBoxOpen} />
</NewIcon>
Package admin
<NewIcon csMode="inline" noWrapper>
<FontAwesomeIcon icon={faArrowUpRight} />
</NewIcon>
</NewButton>
)}
</div>
)}

{/* Manage package */}
{perms.can_manage && (
<div className="package-listing-management-tools__island">
<NewButton
csSize="small"
primitiveType="cyberstormLink"
linkId="PackageEdit"
community={pkg.community_id}
namespace={pkg.namespace_id}
package={pkg.package_name}
>
<NewIcon csMode="inline" noWrapper>
<FontAwesomeIcon icon={faCog} />
</NewIcon>
Manage Package
</NewButton>
</div>
)}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
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";

import { type PackageListingStatus } from "@thunderstore/dapper/types";

export interface ReviewPackageFormProps {
communityId: string;
namespaceId: string;
packageId: string;
packageListingStatus: PackageListingStatus;
config: () => RequestConfig;
toast: ReturnType<typeof useToast>;
}

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 as keyof typeof reviewStatusColorMap] ??
"orange";

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 (
<Modal
csSize="small"
trigger={
<NewButton
csSize="small"
popoverTarget="reviewPackage"
popoverTargetAction="show"
>
<NewIcon csMode="inline" noWrapper>
<FontAwesomeIcon icon={faScaleBalanced} />
</NewIcon>
Review Package
</NewButton>
}
titleContent="Review Package"
>
<Modal.Body className="review-package__body">
<NewAlert csVariant="info">
Changes might take several minutes to show publicly! Info shown below
is always up to date.
</NewAlert>

<div className="review-package__block">
<p className="review-package__label">Review status</p>
<NewTag csVariant={reviewStatusColor} csModifiers={["dark"]}>
{reviewStatus}
</NewTag>
</div>

<div className="review-package__block">
<p className="review-package__label">
Reject reason (saved on reject)
</p>
<NewTextInput
value={rejectionReason}
onChange={(e) => setRejectionReason(e.target.value)}
placeholder="Invalid submission"
csSize="textarea"
rootClasses="review-package__textarea"
/>
</div>

<div className="review-package__block">
<p className="review-package__label">Internal notes</p>
<NewTextInput
value={internalNotes}
onChange={(e) => setInternalNotes(e.target.value)}
placeholder=".exe requires manual review"
csSize="textarea"
rootClasses="review-package__textarea"
/>
</div>
</Modal.Body>

<Modal.Footer className="modal-content__footer review-package__footer">
<NewButton
csVariant="danger"
onClick={() =>
rejectPackageAction({
config,
params: {
community: communityId,
namespace: namespaceId,
package: packageId,
},
queryParams: {},
data: {
rejection_reason: rejectionReason,
internal_notes: internalNotes ? internalNotes : null,
},
})
}
>
Reject
</NewButton>

<NewButton
csVariant="success"
onClick={() =>
approvePackageAction({
config,
params: {
community: communityId,
namespace: namespaceId,
package: packageId,
},
queryParams: {},
data: {
internal_notes: internalNotes ? internalNotes : null,
},
})
}
>
Approve
</NewButton>
</Modal.Footer>
</Modal>
);
}
73 changes: 73 additions & 0 deletions apps/cyberstorm-remix/app/p/listingUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
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 (e) {
const is404 = isApiError(e) && e.response?.status === 404;
if (!is404) {
throw e;
}
}

const privateListing = await dapper.getPackageListingDetails(
communityId,
namespaceId,
packageId,
true
);

if (!privateListing) {
throw new Response("Package not found", { status: 404 });
}

return privateListing;
}

Loading
Loading