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); }