diff --git a/apps/cyberstorm-nextjs/app/c/[community]/(package)/@tspackage/p/[namespace]/(packageRoute)/[package]/(pkg)/@packageDetail/layout.tsx b/apps/cyberstorm-nextjs/app/c/[community]/(package)/@tspackage/p/[namespace]/(packageRoute)/[package]/(pkg)/@packageDetail/layout.tsx
index d37f0b78b..c8a70c75a 100644
--- a/apps/cyberstorm-nextjs/app/c/[community]/(package)/@tspackage/p/[namespace]/(packageRoute)/[package]/(pkg)/@packageDetail/layout.tsx
+++ b/apps/cyberstorm-nextjs/app/c/[community]/(package)/@tspackage/p/[namespace]/(packageRoute)/[package]/(pkg)/@packageDetail/layout.tsx
@@ -5,14 +5,15 @@ import {
faDonate,
faDownload,
faThumbsUp,
- faFlag,
+ // faFlag,
faBoxes,
} from "@fortawesome/pro-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import styles from "./PackageDetailsLayout.module.css";
import { Button, Icon, Tag } from "@thunderstore/cyberstorm";
-import { ReactNode, Suspense } from "react";
+import { ReactNode, Suspense, useState } from "react";
import { WrapperCard } from "@thunderstore/cyberstorm/src/components/WrapperCard/WrapperCard";
+import { PackageLikeAction } from "@thunderstore/cyberstorm-forms";
export default function PackageDetailsLayout({
packageMeta,
@@ -33,6 +34,22 @@ export default function PackageDetailsLayout({
params.namespace,
params.package,
]);
+ const teamData = usePromise(dapper.getTeamDetails, [params.namespace]);
+ const currentUser = usePromise(dapper.getCurrentUser, []);
+
+ const [isLiked, setIsLiked] = useState(
+ currentUser.rated_packages.includes(packageData.uuid4)
+ );
+
+ async function useUpdateLikeStatus() {
+ const dapper = useDapper();
+ const currentUser = await dapper.getCurrentUser();
+ if (currentUser.rated_packages.includes(packageData.uuid4)) {
+ setIsLiked(true);
+ } else {
+ setIsLiked(false);
+ }
+ }
const mappedPackageTagList = packageData.categories.map((category) => {
return (
@@ -51,9 +68,25 @@ export default function PackageDetailsLayout({
-
-
-
+ {teamData.donation_link ? (
+
+ ) : null}
+
+
+
+
+
+ {/* */}
TODO: SKELETON packageMeta
}>
{packageMeta}
@@ -82,28 +115,16 @@ export default function PackageDetailsLayout({
);
}
-const TODO = () => Promise.resolve();
+// const TODO = () => Promise.resolve();
-interface Clickable {
- onClick: () => Promise;
-}
+// interface Clickable {
+// onClick: () => Promise;
+// }
-const LikeButton = (props: Clickable) => (
+const DonateButton = (props: { donationLink: string }) => (
-
-
-
-
-);
-
-const DonateButton = (props: Clickable) => (
- (
);
-const ReportButton = (props: Clickable) => (
-
-
-
-
-
-);
+// TODO: Enable and finish, when we have endpoint for submitting
+// const ReportButton = (props: Clickable) => (
+//
+//
+//
+//
+//
+// );
const DownloadButton = () => (
diff --git a/apps/cyberstorm-nextjs/dapper/client.tsx b/apps/cyberstorm-nextjs/dapper/client.tsx
index cbae182bd..cdc911c81 100644
--- a/apps/cyberstorm-nextjs/dapper/client.tsx
+++ b/apps/cyberstorm-nextjs/dapper/client.tsx
@@ -10,6 +10,7 @@ export function ClientDapper(props: React.PropsWithChildren) {
const config = {
apiHost: process.env.NEXT_PUBLIC_API_DOMAIN || "https://thunderstore.io",
sessionId: getCookie("sessionid"),
+ csrfToken: getCookie("csrftoken"),
};
const dapperConstructor = () => new DapperTs(config);
diff --git a/packages/cyberstorm-forms/src/actions/PackageLikeAction.tsx b/packages/cyberstorm-forms/src/actions/PackageLikeAction.tsx
new file mode 100644
index 000000000..8f6e218cf
--- /dev/null
+++ b/packages/cyberstorm-forms/src/actions/PackageLikeAction.tsx
@@ -0,0 +1,44 @@
+"use client";
+
+import { useFormToaster } from "@thunderstore/cyberstorm-forms";
+import {
+ ApiAction,
+ packageLikeActionSchema,
+} from "@thunderstore/ts-api-react-actions";
+import { packageLike } from "@thunderstore/thunderstore-api";
+
+export function PackageLikeAction(props: {
+ packageName: string;
+ uuid4: string;
+ isLiked: boolean;
+ currentUserUpdateTrigger: () => Promise;
+}) {
+ const { onSubmitSuccess, onSubmitError } = useFormToaster({
+ successMessage: `${props.isLiked ? "Unliked" : "Liked"} package ${
+ props.packageName
+ }`,
+ });
+
+ function onActionSuccess() {
+ props.currentUserUpdateTrigger();
+ onSubmitSuccess();
+ }
+
+ function onActionError() {
+ onSubmitError();
+ }
+
+ const onSubmit = ApiAction({
+ schema: packageLikeActionSchema,
+ meta: { uuid4: props.uuid4 },
+ endpoint: packageLike,
+ onSubmitSuccess: onActionSuccess,
+ onSubmitError: onActionError,
+ });
+
+ return function () {
+ onSubmit({ target_state: props.isLiked ? "unrated" : "rated" });
+ };
+}
+
+PackageLikeAction.displayName = "PackageLikeAction";
diff --git a/packages/cyberstorm-forms/src/index.ts b/packages/cyberstorm-forms/src/index.ts
index 39c2f310a..f75356cff 100644
--- a/packages/cyberstorm-forms/src/index.ts
+++ b/packages/cyberstorm-forms/src/index.ts
@@ -1,5 +1,6 @@
export { useFormToaster } from "./useFormToaster";
export { TeamMemberChangeRoleAction } from "./actions/TeamMemberChangeRoleAction";
+export { PackageLikeAction } from "./actions/PackageLikeAction";
export { FormSubmitButton } from "./components/FormSubmitButton";
export { FormSelectSearch } from "./components/FormSelectSearch";
export { FormMultiSelectSearch } from "./components/FormMultiSelectSearch";
diff --git a/packages/cyberstorm/src/components/Button/Button.module.css b/packages/cyberstorm/src/components/Button/Button.module.css
index 8376fae5e..8b2bdf313 100644
--- a/packages/cyberstorm/src/components/Button/Button.module.css
+++ b/packages/cyberstorm/src/components/Button/Button.module.css
@@ -264,6 +264,16 @@
--bg-color: #27275d;
}
+.button__likeBlue {
+ background: #0b4162;
+
+ --button-border-color: #1ca3f5;
+}
+
+.button__likeBlue:hover {
+ background: #083149;
+}
+
/* Button padding sizes */
.padding__none {
diff --git a/packages/cyberstorm/src/components/Button/Button.tsx b/packages/cyberstorm/src/components/Button/Button.tsx
index 522e9b33f..feb938b82 100644
--- a/packages/cyberstorm/src/components/Button/Button.tsx
+++ b/packages/cyberstorm/src/components/Button/Button.tsx
@@ -40,6 +40,7 @@ export interface ButtonProps {
| "overwolf"
| "specialGreen"
| "specialPurple"
+ | "likeBlue"
| "transparentDanger"
| "transparentDefault"
| "transparentTertiary"
@@ -181,6 +182,7 @@ const getStyle = (scheme: string) => {
overwolf: styles.button__overwolf,
specialGreen: styles.button__specialGreen,
specialPurple: styles.button__specialPurple,
+ likeBlue: styles.button__likeBlue,
transparentDanger: styles.button__transparentDanger,
transparentDefault: styles.button__transparentDefault,
transparentTertiary: styles.button__transparentTertiary,
diff --git a/packages/dapper-fake/src/fakers/package.ts b/packages/dapper-fake/src/fakers/package.ts
index 4da93d04a..1e452a7c1 100644
--- a/packages/dapper-fake/src/fakers/package.ts
+++ b/packages/dapper-fake/src/fakers/package.ts
@@ -103,6 +103,7 @@ export const getFakePackageListingDetails = async (
return {
...getFakePackageListing(community, namespace, name),
+ uuid4: faker.string.uuid(),
community_name: faker.word.sample(),
datetime_created: faker.date.past({ years: 2 }).toISOString(),
dependant_count: faker.number.int({ min: 0, max: 2000 }),
diff --git a/packages/dapper-ts/src/methods/packageListings.ts b/packages/dapper-ts/src/methods/packageListings.ts
index 6addbc9e3..0fb982b05 100644
--- a/packages/dapper-ts/src/methods/packageListings.ts
+++ b/packages/dapper-ts/src/methods/packageListings.ts
@@ -106,6 +106,7 @@ const dependencyShema = z.object({
});
const packageListingDetailSchema = packageListingSchema.extend({
+ uuid4: z.string().nonempty(),
community_name: z.string().nonempty(),
datetime_created: z.string().datetime(),
dependant_count: z.number().int().gte(0),
diff --git a/packages/dapper/src/types/package.ts b/packages/dapper/src/types/package.ts
index 02eb36a91..a8213d939 100644
--- a/packages/dapper/src/types/package.ts
+++ b/packages/dapper/src/types/package.ts
@@ -20,6 +20,7 @@ export interface PackageListing {
export type PackageListings = PaginatedList;
export interface PackageListingDetails extends PackageListing {
+ uuid4: string;
community_name: string;
datetime_created: string;
dependant_count: number;
diff --git a/packages/thunderstore-api/src/apiFetch.ts b/packages/thunderstore-api/src/apiFetch.ts
index 581ebb46a..4ba043352 100644
--- a/packages/thunderstore-api/src/apiFetch.ts
+++ b/packages/thunderstore-api/src/apiFetch.ts
@@ -29,7 +29,12 @@ export async function apiFetch2(args: apiFetchArgs) {
return response.json();
}
-export function apiFetch(config: RequestConfig, path: string, query?: string) {
+export function apiFetch(
+ config: RequestConfig,
+ path: string,
+ query?: string,
+ request?: Omit
+) {
// TODO: Update the apiFetch signature to take in object args instead
// of positional arguments and then merge apiFetch and apiFetch2
// together. Someone else's job for now.
@@ -37,12 +42,16 @@ export function apiFetch(config: RequestConfig, path: string, query?: string) {
config,
path,
query,
+ request,
});
}
function getAuthHeaders(config: RequestConfig): RequestInit["headers"] {
return config.sessionId
- ? { Authorization: `Session ${config.sessionId}` }
+ ? {
+ Authorization: `Session ${config.sessionId}`,
+ "X-Csrftoken": config.csrfToken ? config.csrfToken : "",
+ }
: {};
}
diff --git a/packages/thunderstore-api/src/fetch/currentUser.ts b/packages/thunderstore-api/src/fetch/currentUser.ts
index cea646671..5fd9059c0 100644
--- a/packages/thunderstore-api/src/fetch/currentUser.ts
+++ b/packages/thunderstore-api/src/fetch/currentUser.ts
@@ -3,6 +3,7 @@ import { apiFetch } from "../apiFetch";
export async function fetchCurrentUser(config: RequestConfig) {
const path = "api/experimental/current-user/";
+ const request = { cache: "no-store" as RequestCache };
- return await apiFetch(config, path);
+ return await apiFetch(config, path, undefined, request);
}
diff --git a/packages/thunderstore-api/src/fetch/packageLike.ts b/packages/thunderstore-api/src/fetch/packageLike.ts
new file mode 100644
index 000000000..a08aec100
--- /dev/null
+++ b/packages/thunderstore-api/src/fetch/packageLike.ts
@@ -0,0 +1,28 @@
+import { RequestConfig } from "../index";
+import { apiFetch2 } from "../apiFetch";
+
+export type packageLikeMetaArgs = {
+ uuid4: string;
+};
+
+export type packageLikeApiArgs = {
+ target_state: "rated" | "unrated";
+};
+
+export function packageLike(
+ config: RequestConfig,
+ data: packageLikeApiArgs,
+ meta: packageLikeMetaArgs
+) {
+ const path = `/api/v1/package/${meta.uuid4}/rate/`;
+
+ return apiFetch2({
+ config,
+ path,
+ request: {
+ method: "POST",
+ cache: "no-store",
+ body: JSON.stringify(data),
+ },
+ });
+}
diff --git a/packages/thunderstore-api/src/index.ts b/packages/thunderstore-api/src/index.ts
index 6a961fa38..12cf16ecb 100644
--- a/packages/thunderstore-api/src/index.ts
+++ b/packages/thunderstore-api/src/index.ts
@@ -4,6 +4,7 @@ export interface RequestConfig {
// TODO: This should not be explicitly bound to a session ID but rather just
// accept any authorization header. Noting as currently out of scope.
sessionId?: string;
+ csrfToken?: string;
}
export * from "./fetch/community";
@@ -28,5 +29,6 @@ export * from "./fetch/teamServiceAccountRemove";
export * from "./fetch/userDelete";
export * from "./fetch/teamDisbandTeam";
export * from "./fetch/teamEditMember";
+export * from "./fetch/packageLike";
export * from "./fetch/teamRemoveMember";
export * from "./errors";
diff --git a/packages/ts-api-react-actions/src/index.ts b/packages/ts-api-react-actions/src/index.ts
index a6fb458ae..49f3b6a0e 100644
--- a/packages/ts-api-react-actions/src/index.ts
+++ b/packages/ts-api-react-actions/src/index.ts
@@ -1 +1,2 @@
export { ApiAction } from "./ApiAction";
+export { packageLikeActionSchema } from "./schema";
diff --git a/packages/ts-api-react-actions/src/schema.ts b/packages/ts-api-react-actions/src/schema.ts
new file mode 100644
index 000000000..82abb89e1
--- /dev/null
+++ b/packages/ts-api-react-actions/src/schema.ts
@@ -0,0 +1,5 @@
+import { z } from "zod";
+
+export const packageLikeActionSchema = z.object({
+ target_state: z.union([z.literal("rated"), z.literal("unrated")]),
+});
diff --git a/packages/ts-api-react/src/SessionContext.tsx b/packages/ts-api-react/src/SessionContext.tsx
index d1c881788..8888386fb 100644
--- a/packages/ts-api-react/src/SessionContext.tsx
+++ b/packages/ts-api-react/src/SessionContext.tsx
@@ -22,6 +22,8 @@ interface ContextInterface {
sessionId?: string;
/** Store session data in provider's state and localStorage. */
setSession: (sessionData: LoginResponse) => void;
+ /** Session id from provider's state or localStorage. */
+ csrfToken?: string;
/** Username from provider's state or localStorage. */
username?: string;
/** Domain of the session */
@@ -32,11 +34,13 @@ interface LoginResponse {
email: string;
sessionId: string;
username: string;
+ csrfToken?: string;
}
const SessionContext = createContext(null);
const EMAIL_KEY = "email";
const ID_KEY = "id";
+const CSRF_TOKEN = "csrftoken";
const USERNAME_KEY = "username";
interface Props extends PropsWithChildren {
@@ -70,10 +74,17 @@ export function SessionProvider(props: Props) {
if (sessionid) {
_storage.setValue(ID_KEY, sessionid);
}
+ const csrftoken = document.cookie
+ .split("; ")
+ .find((row) => row.startsWith("csrftoken="))
+ ?.split("=")[1];
+ if (csrftoken) {
+ _storage.setValue(CSRF_TOKEN, csrftoken);
+ }
}
}, []);
- const [isReady, sessionId] = useValidateSession(
+ const [isReady, sessionId, csrfToken] = useValidateSession(
_session,
_setSession,
_storage,
@@ -84,6 +95,9 @@ export function SessionProvider(props: Props) {
_setSession(sessionData);
_storage.setValue(EMAIL_KEY, sessionData.email);
_storage.setValue(ID_KEY, sessionData.sessionId);
+ if (sessionData.csrfToken) {
+ _storage.setValue(CSRF_TOKEN, sessionData.csrfToken);
+ }
_storage.setValue(USERNAME_KEY, sessionData.username);
};
@@ -91,6 +105,7 @@ export function SessionProvider(props: Props) {
_setSession(undefined);
_storage.removeValue(EMAIL_KEY);
_storage.removeValue(ID_KEY);
+ _storage.removeValue(CSRF_TOKEN);
_storage.removeValue(USERNAME_KEY);
};
@@ -99,6 +114,7 @@ export function SessionProvider(props: Props) {
email: _session?.email,
isReady,
sessionId,
+ csrfToken,
setSession,
username: _session?.username,
domain: props.domain,
@@ -116,6 +132,7 @@ export function SessionProvider(props: Props) {
*
* * isReady: boolean
* * sessionId?: string
+ * * csrfToken?: string
* * username?: string
* * email?: string
* * setSession: ({email: string, sessionId: string, username: string}) => void
@@ -146,16 +163,20 @@ const useValidateSession = (
/** Is the validation process ready? */
boolean,
/** Session id if it's valid, otherwise undefined */
+ string | undefined,
+ /** Session id if it's valid, otherwise undefined */
string | undefined
] => {
const [isValid, setIsValid] = useState();
const stateSessionId = _session?.sessionId;
+ const stateCsrfToken = _session?.csrfToken;
const storedSessionId = _storage.safeGetValue(ID_KEY);
+ const storedCsrfToken = _storage.safeGetValue(CSRF_TOKEN);
useEffect(() => {
// Session id stored in SessionProvider's state is always valid, no
// need to call backend to check it nor read values from localStorage.
- if (stateSessionId !== undefined) {
+ if (stateSessionId !== undefined && stateCsrfToken !== undefined) {
if (isValid !== true) {
setIsValid(true);
}
@@ -181,6 +202,7 @@ const useValidateSession = (
if (res.status === 401) {
_storage.removeValue(EMAIL_KEY);
_storage.removeValue(ID_KEY);
+ _storage.removeValue(CSRF_TOKEN);
_storage.removeValue(USERNAME_KEY);
Router.push("/");
return;
@@ -189,13 +211,22 @@ const useValidateSession = (
_setSession({
email: _storage.safeGetValue(EMAIL_KEY) || "",
sessionId: storedSessionId,
+ csrfToken: storedCsrfToken === null ? undefined : storedCsrfToken,
username: _storage.safeGetValue(USERNAME_KEY) || "",
});
setIsValid(true);
})();
- }, [isValid, setIsValid, stateSessionId, storedSessionId]);
+ }, [
+ isValid,
+ setIsValid,
+ stateSessionId,
+ storedSessionId,
+ stateCsrfToken,
+ storedCsrfToken,
+ ]);
const isReady = isValid !== undefined;
const sessionId = isValid ? storedSessionId || undefined : undefined;
- return [isReady, sessionId];
+ const csrfToken = isValid ? storedCsrfToken || undefined : undefined;
+ return [isReady, sessionId, csrfToken];
};
diff --git a/packages/ts-api-react/src/useApiCall.ts b/packages/ts-api-react/src/useApiCall.ts
index 8ed7a71a0..7cec3d059 100644
--- a/packages/ts-api-react/src/useApiCall.ts
+++ b/packages/ts-api-react/src/useApiCall.ts
@@ -13,6 +13,7 @@ export function useApiCall(
const apiConfig = {
apiHost: session.domain,
sessionId: session.sessionId,
+ csrfToken: session.csrfToken,
};
return (data: Data, meta: Meta) => endpoint(apiConfig, data, meta);
}