Skip to content

Commit 76a882e

Browse files
authored
Merge pull request #1666 from thunderstore-io/moderation-tools-review-and-panel-visbility
Moderation tools review and panel visbility (TS-2907 & TS-2908 & TS-2910)
2 parents d3c5101 + ed6ffad commit 76a882e

File tree

11 files changed

+794
-609
lines changed

11 files changed

+794
-609
lines changed
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { faCog, faList, faBoxOpen } from "@fortawesome/free-solid-svg-icons";
2+
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
3+
import { faArrowUpRight } from "@fortawesome/pro-solid-svg-icons";
4+
import { ReviewPackageForm } from "./ReviewPackageForm";
5+
import { type DapperTsInterface } from "@thunderstore/dapper-ts";
6+
import { NewButton, NewIcon, useToast } from "@thunderstore/cyberstorm";
7+
import {
8+
fetchPackagePermissions,
9+
type RequestConfig,
10+
} from "@thunderstore/thunderstore-api";
11+
import { type PackageListingStatus } from "@thunderstore/dapper/types";
12+
13+
export interface ManagementToolsProps {
14+
packagePermissions: Awaited<ReturnType<typeof fetchPackagePermissions>>;
15+
listing: Awaited<ReturnType<DapperTsInterface["getPackageListingDetails"]>>;
16+
listingStatus: PackageListingStatus;
17+
toast: ReturnType<typeof useToast>;
18+
requestConfig: () => RequestConfig;
19+
}
20+
21+
export function ManagementTools({
22+
packagePermissions,
23+
listing,
24+
listingStatus,
25+
toast,
26+
requestConfig,
27+
}: ManagementToolsProps) {
28+
const perms = packagePermissions.permissions;
29+
const pkg = packagePermissions.package;
30+
31+
return (
32+
<div className="package-listing-management-tools">
33+
{/* Review Package */}
34+
{perms.can_moderate && (
35+
<div className="package-listing-management-tools__island">
36+
<ReviewPackageForm
37+
communityId={listing.community_identifier}
38+
namespaceId={listing.namespace}
39+
packageId={listing.name}
40+
packageListingStatus={listingStatus}
41+
toast={toast}
42+
config={requestConfig}
43+
/>
44+
45+
{/* Package Listing admin link */}
46+
{perms.can_view_listing_admin_page && (
47+
<NewButton
48+
csSize="small"
49+
csVariant="secondary"
50+
primitiveType="link"
51+
href=""
52+
>
53+
<NewIcon csMode="inline" noWrapper>
54+
<FontAwesomeIcon icon={faList} />
55+
</NewIcon>
56+
Listing admin
57+
<NewIcon csMode="inline" noWrapper>
58+
<FontAwesomeIcon icon={faArrowUpRight} />
59+
</NewIcon>
60+
</NewButton>
61+
)}
62+
63+
{/* Package admin link */}
64+
{perms.can_view_package_admin_page && (
65+
<NewButton
66+
csSize="small"
67+
csVariant="secondary"
68+
primitiveType="link"
69+
href=""
70+
>
71+
<NewIcon csMode="inline" noWrapper>
72+
<FontAwesomeIcon icon={faBoxOpen} />
73+
</NewIcon>
74+
Package admin
75+
<NewIcon csMode="inline" noWrapper>
76+
<FontAwesomeIcon icon={faArrowUpRight} />
77+
</NewIcon>
78+
</NewButton>
79+
)}
80+
</div>
81+
)}
82+
83+
{/* Manage package */}
84+
{perms.can_manage && (
85+
<div className="package-listing-management-tools__island">
86+
<NewButton
87+
csSize="small"
88+
primitiveType="cyberstormLink"
89+
linkId="PackageEdit"
90+
community={pkg.community_id}
91+
namespace={pkg.namespace_id}
92+
package={pkg.package_name}
93+
>
94+
<NewIcon csMode="inline" noWrapper>
95+
<FontAwesomeIcon icon={faCog} />
96+
</NewIcon>
97+
Manage Package
98+
</NewButton>
99+
</div>
100+
)}
101+
</div>
102+
);
103+
}
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
import { useEffect, useState } from "react";
2+
import { useRevalidator } from "react-router";
3+
import { faScaleBalanced } from "@fortawesome/free-solid-svg-icons";
4+
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
5+
import { ApiAction } from "@thunderstore/ts-api-react-actions";
6+
import {
7+
Modal,
8+
NewAlert,
9+
NewButton,
10+
NewIcon,
11+
NewTag,
12+
NewTextInput,
13+
useToast,
14+
} from "@thunderstore/cyberstorm";
15+
import {
16+
packageListingApprove,
17+
packageListingReject,
18+
type RequestConfig,
19+
} from "@thunderstore/thunderstore-api";
20+
21+
import { type PackageListingStatus } from "@thunderstore/dapper/types";
22+
23+
export interface ReviewPackageFormProps {
24+
communityId: string;
25+
namespaceId: string;
26+
packageId: string;
27+
packageListingStatus: PackageListingStatus;
28+
config: () => RequestConfig;
29+
toast: ReturnType<typeof useToast>;
30+
}
31+
32+
const reviewStatusColorMap = {
33+
approved: "green",
34+
rejected: "red",
35+
unreviewed: "orange",
36+
} as const;
37+
38+
export function ReviewPackageForm({
39+
communityId,
40+
namespaceId,
41+
packageId,
42+
packageListingStatus,
43+
config,
44+
toast,
45+
}: ReviewPackageFormProps) {
46+
const [rejectionReason, setRejectionReason] = useState(
47+
packageListingStatus?.rejection_reason ?? ""
48+
);
49+
50+
const [internalNotes, setInternalNotes] = useState(
51+
packageListingStatus?.internal_notes ?? ""
52+
);
53+
54+
const reviewStatus = packageListingStatus?.review_status ?? "unreviewed";
55+
const reviewStatusColor =
56+
reviewStatusColorMap[reviewStatus as keyof typeof reviewStatusColorMap] ??
57+
"orange";
58+
59+
const { revalidate } = useRevalidator();
60+
61+
useEffect(() => {
62+
setRejectionReason(packageListingStatus?.rejection_reason ?? "");
63+
setInternalNotes(packageListingStatus?.internal_notes ?? "");
64+
}, [packageListingStatus]);
65+
66+
const rejectPackageAction = ApiAction({
67+
endpoint: packageListingReject,
68+
onSubmitSuccess: () => {
69+
toast.addToast({
70+
csVariant: "success",
71+
children: `Package rejected`,
72+
duration: 4000,
73+
});
74+
revalidate();
75+
},
76+
onSubmitError: (error) => {
77+
toast.addToast({
78+
csVariant: "danger",
79+
children: `Error occurred: ${error.message || "Unknown error"}`,
80+
duration: 8000,
81+
});
82+
},
83+
});
84+
85+
const approvePackageAction = ApiAction({
86+
endpoint: packageListingApprove,
87+
onSubmitSuccess: () => {
88+
toast.addToast({
89+
csVariant: "success",
90+
children: `Package approved`,
91+
duration: 4000,
92+
});
93+
revalidate();
94+
},
95+
onSubmitError: (error) => {
96+
toast.addToast({
97+
csVariant: "danger",
98+
children: `Error occurred: ${error.message || "Unknown error"}`,
99+
duration: 8000,
100+
});
101+
},
102+
});
103+
104+
return (
105+
<Modal
106+
csSize="small"
107+
trigger={
108+
<NewButton
109+
csSize="small"
110+
popoverTarget="reviewPackage"
111+
popoverTargetAction="show"
112+
>
113+
<NewIcon csMode="inline" noWrapper>
114+
<FontAwesomeIcon icon={faScaleBalanced} />
115+
</NewIcon>
116+
Review Package
117+
</NewButton>
118+
}
119+
titleContent="Review Package"
120+
>
121+
<Modal.Body className="review-package__body">
122+
<NewAlert csVariant="info">
123+
Changes might take several minutes to show publicly! Info shown below
124+
is always up to date.
125+
</NewAlert>
126+
127+
<div className="review-package__block">
128+
<p className="review-package__label">Review status</p>
129+
<NewTag csVariant={reviewStatusColor} csModifiers={["dark"]}>
130+
{reviewStatus}
131+
</NewTag>
132+
</div>
133+
134+
<div className="review-package__block">
135+
<p className="review-package__label">
136+
Reject reason (saved on reject)
137+
</p>
138+
<NewTextInput
139+
value={rejectionReason}
140+
onChange={(e) => setRejectionReason(e.target.value)}
141+
placeholder="Invalid submission"
142+
csSize="textarea"
143+
rootClasses="review-package__textarea"
144+
/>
145+
</div>
146+
147+
<div className="review-package__block">
148+
<p className="review-package__label">Internal notes</p>
149+
<NewTextInput
150+
value={internalNotes}
151+
onChange={(e) => setInternalNotes(e.target.value)}
152+
placeholder=".exe requires manual review"
153+
csSize="textarea"
154+
rootClasses="review-package__textarea"
155+
/>
156+
</div>
157+
</Modal.Body>
158+
159+
<Modal.Footer className="modal-content__footer review-package__footer">
160+
<NewButton
161+
csVariant="danger"
162+
onClick={() =>
163+
rejectPackageAction({
164+
config,
165+
params: {
166+
community: communityId,
167+
namespace: namespaceId,
168+
package: packageId,
169+
},
170+
queryParams: {},
171+
data: {
172+
rejection_reason: rejectionReason,
173+
internal_notes: internalNotes ? internalNotes : null,
174+
},
175+
})
176+
}
177+
>
178+
Reject
179+
</NewButton>
180+
181+
<NewButton
182+
csVariant="success"
183+
onClick={() =>
184+
approvePackageAction({
185+
config,
186+
params: {
187+
community: communityId,
188+
namespace: namespaceId,
189+
package: packageId,
190+
},
191+
queryParams: {},
192+
data: {
193+
internal_notes: internalNotes ? internalNotes : null,
194+
},
195+
})
196+
}
197+
>
198+
Approve
199+
</NewButton>
200+
</Modal.Footer>
201+
</Modal>
202+
);
203+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { DapperTs } from "@thunderstore/dapper-ts";
2+
import { isApiError } from "@thunderstore/thunderstore-api";
3+
4+
export interface ListingIdentifiers {
5+
communityId: string;
6+
namespaceId: string;
7+
packageId: string;
8+
}
9+
10+
/**
11+
* Server-side listing fetcher:
12+
* 1. Try public listing
13+
* 2. If 404, return undefined
14+
*/
15+
export async function getPublicListing(
16+
dapper: DapperTs,
17+
ids: ListingIdentifiers
18+
) {
19+
const { communityId, namespaceId, packageId } = ids;
20+
try {
21+
return await dapper.getPackageListingDetails(
22+
communityId,
23+
namespaceId,
24+
packageId
25+
);
26+
} catch (e) {
27+
if (isApiError(e) && e.response.status === 404) {
28+
return undefined;
29+
} else {
30+
throw e;
31+
}
32+
}
33+
}
34+
35+
/**
36+
* Client-side listing fetcher:
37+
* 1. Try public listing
38+
* 2. If 404, try private listing
39+
* 3. If still missing, throw 404
40+
*/
41+
export async function getPrivateListing(
42+
dapper: DapperTs,
43+
ids: ListingIdentifiers
44+
) {
45+
const { communityId, namespaceId, packageId } = ids;
46+
47+
try {
48+
return await dapper.getPackageListingDetails(
49+
communityId,
50+
namespaceId,
51+
packageId
52+
);
53+
} catch (e) {
54+
const is404 = isApiError(e) && e.response?.status === 404;
55+
if (!is404) {
56+
throw e;
57+
}
58+
}
59+
60+
const privateListing = await dapper.getPackageListingDetails(
61+
communityId,
62+
namespaceId,
63+
packageId,
64+
true
65+
);
66+
67+
if (!privateListing) {
68+
throw new Response("Package not found", { status: 404 });
69+
}
70+
71+
return privateListing;
72+
}

0 commit comments

Comments
 (0)