Compare commits

..

15 Commits

12 changed files with 621 additions and 277 deletions

View File

@@ -1,10 +1,11 @@
import { Surface } from "@heroui/react"; import { Surface } from "@heroui/react";
import type { AgentInfo } from "~/types/skin";
export function Agent({ agent_id }: { agent_id: string }) { export function Agent({ agent }: { agent: AgentInfo }) {
return ( return (
<Surface className="bg-[#1f2937] flex justify-center flex-1 min-h-0 p-4"> <Surface className="bg-[#1f2937] flex justify-center flex-1 min-h-0 p-4">
<img <img
src={`/skins/${agent_id}_big.png`} src={`/skins/${agent.name}_big.png`}
className="pointer-events-none object-contain object-bottom h-full w-full" className="pointer-events-none object-contain object-bottom h-full w-full"
alt="Agent image" alt="Agent image"
/> />

View File

@@ -1,22 +1,85 @@
export function Float({ float }: { float: number }) { import { Slider, Label, Typography, Input } from "@heroui/react";
import {
useEffect,
type ChangeEvent,
type Dispatch,
type SetStateAction,
} from "react";
import { AgentInfo, WeaponInfo } from "~/types/skin";
export function Float({
skin,
setSkin,
acceptInput = false,
hasLabel = false,
}: {
skin: WeaponInfo;
setSkin: Dispatch<SetStateAction<WeaponInfo | AgentInfo>>;
minFloat?: number;
maxFloat?: number;
acceptInput?: boolean;
hasLabel?: boolean;
}) {
useEffect(() => {
const newSkin = new WeaponInfo(skin);
newSkin.float = Math.max(
skin.min_float,
Math.min(skin.max_float, skin.float),
);
setSkin(newSkin);
}, [skin.min_float, skin.max_float]);
const handleChange = (newValue: number | number[]) => {
const val = Array.isArray(newValue) ? newValue[0] : newValue;
const newSkin = new WeaponInfo(skin);
newSkin.float = Math.max(skin.min_float, Math.min(skin.max_float, val));
setSkin(newSkin);
};
const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => {
const val = event.currentTarget.valueAsNumber;
const newSkin = new WeaponInfo(skin);
newSkin.float = Math.max(skin.min_float, Math.min(skin.max_float, val));
setSkin(newSkin);
};
return ( return (
<> <>
<div className="w-full flex justify-center relative"> {hasLabel && (
<div <div className="flex items-center gap-2">
className="absolute mx-1 h-4 w-1 mt-1 rounded-xl bg-white" <Typography.Paragraph>Float</Typography.Paragraph>
style={{ <Input
left: `calc(${float}*100% - var(--spacing))`, className="flex-1"
transform: "translateX(-50%)", id="float"
}} type="number"
></div> step={0.0001}
<div className="w-full h-2 flex my-2"> value={skin.float}
<div className="w-7/100 h-full rounded-l-xl bg-green-800"></div> onChange={handleInputChange}
<div className="w-8/100 h-full bg-green-500"></div> />
<div className="w-23/100 h-full bg-lime-400"></div>
<div className="w-7/100 h-full bg-orange-400"></div>
<div className="w-55/100 h-full rounded-r-xl bg-red-400"></div>
</div> </div>
</div> )}
<Slider
value={skin.float}
maxValue={1}
step={0.0001}
onChange={handleChange}
>
<Slider.Track
className="border-0 h-2 rounded-xl"
style={{
background:
"linear-gradient(90deg, var(--color-green-800) 7%, var(--color-green-500) 7%, var(--color-green-500) 15%, var(--color-lime-400) 15%, var(--color-lime-400) 38%, var(--color-orange-400) 38%, var(--color-orange-400) 38%, var(--color-orange-400) 45%, var(--color-red-400) 45%",
}}
>
<div
className="h-full rounded-xl"
style={{
background: `linear-gradient(90deg, rgba(0, 0, 0, 0.8) ${skin.min_float * 100}%, transparent ${skin.min_float * 100}%, transparent ${skin.max_float * 100}%, rgba(0, 0, 0, 0.8) ${skin.max_float * 100}%)`,
}}
/>
<Slider.Thumb className="w-1" isDisabled={!acceptInput} />
</Slider.Track>
</Slider>
</> </>
); );
} }

View File

@@ -4,6 +4,7 @@ import { Agent } from "./agent";
import { SkinTile } from "./skin_tile"; import { SkinTile } from "./skin_tile";
import { useNotification } from "~/hooks/notification"; import { useNotification } from "~/hooks/notification";
import { WeaponInfo, AgentInfo } from "~/types/skin"; import { WeaponInfo, AgentInfo } from "~/types/skin";
import { useApi } from "~/hooks/api";
export function Inventory({ side }: { side: "T" | "CT" }) { export function Inventory({ side }: { side: "T" | "CT" }) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -12,84 +13,29 @@ export function Inventory({ side }: { side: "T" | "CT" }) {
const [knife, setKnife] = useState<WeaponInfo>(); const [knife, setKnife] = useState<WeaponInfo>();
const [weapons, setWeapons] = useState<Array<WeaponInfo>>([]); const [weapons, setWeapons] = useState<Array<WeaponInfo>>([]);
const { notify } = useNotification(); const { notify } = useNotification();
const { get } = useApi();
const setWeapon = (index:number, newWeapon:WeaponInfo) => {
setWeapons(weapons.map((v,i) => (i===index ? newWeapon: v)))
}
useEffect(() => { useEffect(() => {
const fetch_weapons = async () => { const fetch_weapons = async () => {
let response = await fetch( let [success, data] = await get(`skin?team=${side}&category=Knives`)
`${import.meta.env.VITE_BASE_API_URL}/skin?team=${side}&category=Knives`,
{ credentials: "include" },
)
.then((r) => r.json())
.catch((e) => {
notify(
{
type: "danger",
loading: false,
title: "An error occured.",
message: `An error occured: ${e}`,
},
10000,
);
});
setKnife(new WeaponInfo(response[0])); setKnife(new WeaponInfo(data[0]));
response = await fetch( [success, data] = await get(`skin?team=${side}&category=Gloves`)
`${import.meta.env.VITE_BASE_API_URL}/skin?team=${side}&category=Gloves`,
{ credentials: "include" },
)
.then((r) => r.json())
.catch((e) => {
notify(
{
type: "danger",
loading: false,
title: "An error occured.",
message: `An error occured: ${e}`,
},
10000,
);
});
setGloves(new WeaponInfo(response[0])); setGloves(new WeaponInfo(data[0]));
response = await fetch( [success, data] = await get(`skin?team=${side}&category=Weapons`);
`${import.meta.env.VITE_BASE_API_URL}/skin?team=${side}`,
{ credentials: "include" },
)
.then((r) => r.json())
.catch((e) => {
notify(
{
type: "danger",
loading: false,
title: "An error occured.",
message: `An error occured: ${e}`,
},
10000,
);
});
setWeapons(response.map((item: any) => (new WeaponInfo(item)))); setWeapons(data.map((item: any) => (new WeaponInfo(item))));
response = await fetch( [success, data] = await get(`agent?team=${side}`);
`${import.meta.env.VITE_BASE_API_URL}/agent?team=${side}`,
{ credentials: "include" },
)
.then((r) => r.json())
.catch((e) => {
notify(
{
type: "danger",
loading: false,
title: "An error occured.",
message: `An error occured: ${e}`,
},
10000,
);
});
setAgent(new AgentInfo(response)); setAgent(new AgentInfo(data));
setLoading(false); setLoading(false);
}; };
@@ -137,21 +83,27 @@ export function Inventory({ side }: { side: "T" | "CT" }) {
className="flex flex-col rounded-xl w-1/3 p-4 gap-2 min-h-0" className="flex flex-col rounded-xl w-1/3 p-4 gap-2 min-h-0"
variant="secondary" variant="secondary"
> >
{agent && <Agent agent_id={agent["name"]}></Agent>} {agent && <Agent agent={agent}></Agent>}
<div className="flex gap-2"> <div className="flex gap-2">
{knife && ( {knife && (
<SkinTile <SkinTile
skin={knife} skin={knife}
setSkin={(newSkin) => setKnife(newSkin as WeaponInfo)}
side={side}
></SkinTile> ></SkinTile>
)} )}
{gloves && ( {gloves && (
<SkinTile <SkinTile
skin={gloves} skin={gloves}
setSkin={(newSkin) => setGloves(newSkin as WeaponInfo)}
side={side}
></SkinTile> ></SkinTile>
)} )}
{agent && ( {agent && (
<SkinTile <SkinTile
skin={agent} skin={agent}
setSkin={(newSkin) => setAgent(newSkin as AgentInfo)}
side={side}
></SkinTile> ></SkinTile>
)} )}
</div> </div>
@@ -165,6 +117,8 @@ export function Inventory({ side }: { side: "T" | "CT" }) {
<SkinTile <SkinTile
key={i} key={i}
skin={v} skin={v}
setSkin={(newWeapon) => setWeapon(i, newWeapon as WeaponInfo)}
side={side}
/> />
))} ))}
</div> </div>

View File

@@ -9,8 +9,11 @@ import {
Label, Label,
Typography, Typography,
Spinner, Spinner,
AlertDialog,
useOverlayState,
} from "@heroui/react"; } from "@heroui/react";
import { useState, type Dispatch, type SetStateAction } from "react"; import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
import { useApi } from "~/hooks/api";
import { useAuth } from "~/hooks/auth"; import { useAuth } from "~/hooks/auth";
import { useNotification } from "~/hooks/notification"; import { useNotification } from "~/hooks/notification";
@@ -21,9 +24,20 @@ export function Settings({
show: boolean; show: boolean;
setShow: Dispatch<SetStateAction<boolean>>; setShow: Dispatch<SetStateAction<boolean>>;
}) { }) {
const { user, update } = useAuth(); const { user, update, logout } = useAuth();
const { notify } = useNotification(); const { notify } = useNotification();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [canClose, setCanClose] = useState(true);
const deleteState = useOverlayState();
const { get, post } = useApi();
useEffect(() => {
if (!deleteState.isOpen) {
setTimeout(() => {
setCanClose(true);
}, 100);
}
}, [deleteState]);
const notifySucess = () => { const notifySucess = () => {
notify( notify(
@@ -49,29 +63,7 @@ export function Settings({
const save = async () => { const save = async () => {
setLoading(true); setLoading(true);
let ok = await fetch( let ok = await post("update", data);
`${import.meta.env.VITE_BASE_API_URL}/update`,
{
body: JSON.stringify(data),
credentials: "include",
method: "POST",
headers: {
"Content-Type": "application/json",
},
},
)
.then((r) => r.ok)
.catch((e) => {
notify(
{
type: "danger",
loading: false,
title: "An error occured.",
message: `An error occured: ${e}`,
},
10000,
);
});
if (ok) { if (ok) {
await update(); await update();
@@ -85,21 +77,7 @@ export function Settings({
const loadFromSteam = async () => { const loadFromSteam = async () => {
setLoading(true); setLoading(true);
let ok = await fetch(`${import.meta.env.VITE_BASE_API_URL}/reload`, { let [ok, _] = await get("reload");
credentials: "include",
})
.then((r) => r.ok)
.catch((e) => {
notify(
{
type: "danger",
loading: false,
title: "An error occured.",
message: `An error occured: ${e}`,
},
10000,
);
});
if (ok) { if (ok) {
await update(); await update();
@@ -109,12 +87,26 @@ export function Settings({
setLoading(false); setLoading(false);
}; };
const deleteAccount = async () => {
setLoading(true);
let [ok, _] = await get("delete");
if (ok) {
await logout();
notifySucess();
}
setLoading(false);
deleteState.close();
};
return ( return (
<div> <div>
<Modal isOpen={show}> <Modal isOpen={show}>
<Modal.Backdrop <Modal.Backdrop
variant="blur" variant="blur"
shouldCloseOnInteractOutside={(_element) => { shouldCloseOnInteractOutside={(_element) => {
if (!canClose) return false;
setShow(false); setShow(false);
return true; return true;
}} }}
@@ -158,14 +150,32 @@ export function Settings({
image url. image url.
</Description> </Description>
</TextField> </TextField>
<Button
className="w-full"
variant="tertiary"
isPending={loading}
onPress={() => {
loadFromSteam();
}}
>
{loading ? (
<Spinner
color="current"
size="md"
/>
) : (
"Load from Steam"
)}
</Button>
</Fieldset.Group> </Fieldset.Group>
<Fieldset.Actions className="flex justify-end"> <Fieldset.Actions className="flex justify-end">
<div className="flex-1"> <div className="flex-1">
<Button <Button
variant="tertiary" variant="danger"
isPending={loading} isPending={loading}
onPress={() => { onPress={() => {
loadFromSteam(); deleteState.open();
setCanClose(false);
}} }}
> >
{loading ? ( {loading ? (
@@ -174,10 +184,11 @@ export function Settings({
size="md" size="md"
/> />
) : ( ) : (
"Load from Steam" "Delete account"
)} )}
</Button> </Button>
</div> </div>
<Button <Button
variant="secondary" variant="secondary"
onPress={() => { onPress={() => {
@@ -208,6 +219,50 @@ export function Settings({
</Modal.Container> </Modal.Container>
</Modal.Backdrop> </Modal.Backdrop>
</Modal> </Modal>
<AlertDialog>
<AlertDialog.Backdrop
isOpen={deleteState.isOpen}
onOpenChange={deleteState.setOpen}
>
<AlertDialog.Container>
<AlertDialog.Dialog>
<AlertDialog.CloseTrigger />
<AlertDialog.Header>
<AlertDialog.Icon status="danger" />
<AlertDialog.Heading>
Delete account permanently?
</AlertDialog.Heading>
</AlertDialog.Header>
<AlertDialog.Body>
<p>
This will permanently delete your account
and all of its data. This action cannot be
undone.
</p>
</AlertDialog.Body>
<AlertDialog.Footer>
<Button slot="close" variant="tertiary">
{loading ? (
<Spinner color="current" size="md" />
) : (
"Cancel"
)}
</Button>
<Button
onClick={deleteAccount}
variant="danger"
>
{loading ? (
<Spinner color="current" size="md" />
) : (
"Delete account"
)}
</Button>
</AlertDialog.Footer>
</AlertDialog.Dialog>
</AlertDialog.Container>
</AlertDialog.Backdrop>
</AlertDialog>
</div> </div>
); );
} }

View File

@@ -1,101 +1,182 @@
import { Button, Modal, Surface, Typography } from "@heroui/react"; import { Button, Input, Modal, Surface, Typography } from "@heroui/react";
import type { Dispatch, SetStateAction } from "react"; import {
useEffect,
useState,
type ChangeEvent,
type Dispatch,
type SetStateAction,
} from "react";
import { AgentInfo, WeaponInfo } from "~/types/skin"; import { AgentInfo, WeaponInfo } from "~/types/skin";
import { Float } from "./float"; import { Float } from "./float";
import { SkinSelector } from "./skin_selector";
export function SkinDialog({ export function SkinDialog({
isOpen, isOpen,
setIsOpen, setIsOpen,
skin, skin,
setSkin,
side,
}: { }: {
isOpen: boolean; isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>; setIsOpen: Dispatch<SetStateAction<boolean>>;
skin: WeaponInfo | AgentInfo; skin: WeaponInfo | AgentInfo;
setSkin: Dispatch<SetStateAction<WeaponInfo | AgentInfo>>;
side: "T" | "CT";
}) { }) {
const acolor = `${skin.color}64`; const [selectorOpen, setSelectorOpen] = useState(false);
const [canClose, setCanClose] = useState(true);
const [localSkin, setLocalSkin] = useState(skin);
const acolor = `${localSkin.color}64`;
const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => {
if (localSkin instanceof AgentInfo) return;
const newSkin = new WeaponInfo(localSkin);
newSkin.pattern = event.currentTarget.valueAsNumber;
setLocalSkin(newSkin);
};
const applySkin = (side: "SIDE" | "BOTH") => {
setSkin(localSkin);
};
useEffect(() => {
if (!selectorOpen) {
setTimeout(() => {
setCanClose(true);
}, 100);
}
}, [selectorOpen]);
useEffect(() => {
if (isOpen) {
setLocalSkin(skin);
}
}, [isOpen, skin]);
return ( return (
<Modal isOpen={isOpen}> <>
<Modal.Backdrop <Modal isOpen={isOpen}>
variant="blur" <Modal.Backdrop
shouldCloseOnInteractOutside={(_element) => { variant="blur"
setIsOpen(false); shouldCloseOnInteractOutside={(_element) => {
return true; if (canClose) {
}} setIsOpen(false);
> }
<Modal.Container size="lg"> return canClose;
<Modal.Dialog> }}
<Modal.CloseTrigger >
onPress={() => { <Modal.Container size="lg">
setIsOpen(false); <Modal.Dialog>
}} <Modal.CloseTrigger
/> onPress={() => {
<Modal.Body> if (!selectorOpen) {
<div className="flex flex-col gap-2"> setIsOpen(false);
<Typography.Heading }
level={4} }}
className="py-1 text-wrap" />
> <Modal.Body>
{skin.display} <div className="flex flex-col gap-2">
</Typography.Heading> <Typography.Heading
<Button className="bg-mist-700 flex flex-col h-full p-0 rounded-none gap-0 hover:bg-indigo-800 active:bg-indigo-900"> level={4}
<div className="flex flex-col w-full h-full"> className="py-1 text-wrap"
<div >
className="bg-linear-to-b pointer-events-none w-full h-full relative" {localSkin.display}
style={{ </Typography.Heading>
background: `linear-gradient(to bottom, #1f2937, ${acolor})`, <div className="gap-0">
<div className="bg-mist-700 flex flex-col h-full p-0 rounded-none gap-0 w-full">
<div
className="bg-linear-to-b pointer-events-none w-full h-full relative"
style={{
background: `linear-gradient(to bottom, #1f2937, ${acolor})`,
}}
>
<img
src={`/skins/${localSkin.name}_${localSkin instanceof WeaponInfo ? localSkin.paint_index : 0}.png`}
className="pointer-events-none object-contain object-bottom h-full"
alt="Weapon image"
/>
</div>
<div
className={`h-1 w-full`}
style={{
background: `${localSkin.color}`,
}}
></div>
</div>
<Button
onClick={() => {
setCanClose(false);
setSelectorOpen(true);
}}
className="w-full rounded-b-xl rounded-t-none"
>
Change skin
</Button>
</div>
{localSkin instanceof WeaponInfo && (
<Float
skin={localSkin}
setSkin={setLocalSkin}
acceptInput
hasLabel
/>
)}
{localSkin instanceof WeaponInfo && (
<div className="flex items-center gap-2">
<Typography.Paragraph>
Pattern:{" "}
</Typography.Paragraph>
<Input
className="flex-1"
id="pattern"
type="number"
step={1}
value={localSkin.pattern}
onChange={handleInputChange}
/>
</div>
)}
<Typography.Code>
Transaction id: {localSkin.id}
</Typography.Code>
<div className="flex gap-2 justify-end">
<div className="flex-1">
<Button>Inspect</Button>
</div>
{localSkin instanceof WeaponInfo && (
<Button
onClick={() => {
applySkin("BOTH");
}}
variant="secondary"
>
Apply to Both
</Button>
)}
<Button
onClick={() => {
applySkin("SIDE");
}} }}
> >
<img Apply
src={`/skins/${skin.name}_${skin instanceof WeaponInfo ? skin.paint_index : 0}.png`} {localSkin instanceof WeaponInfo
className="pointer-events-none object-contain object-bottom h-full" ? ` to ${side}`
alt="Weapon image" : ""}
/> </Button>
<Surface className="rounded-full w-fit h-fit p-2 absolute bottom-2 right-2" variant="secondary">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="size-6 m-0"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"
/>
</svg>
</Surface>
</div>
<div
className={`h-1 w-full`}
style={{
background: `${skin.color}`,
}}
></div>
</div> </div>
</Button> </div>
{skin instanceof WeaponInfo && ( </Modal.Body>
<> </Modal.Dialog>
<Typography.Paragraph> </Modal.Container>
Float: {skin.float} </Modal.Backdrop>
</Typography.Paragraph> </Modal>
<Float float={skin.float} /> <SkinSelector
</> isOpen={selectorOpen}
)} setIsOpen={setSelectorOpen}
{skin instanceof WeaponInfo && ( skin={localSkin}
<Typography.Paragraph> setSkin={setLocalSkin}
Pattern: {skin.pattern} side={side}
</Typography.Paragraph> />
)} </>
<Typography.Code>
Transaction id: {skin.id}
</Typography.Code>
</div>
</Modal.Body>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
); );
} }

View File

@@ -0,0 +1,116 @@
import { Button, Modal, Typography } from "@heroui/react";
import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
import { useApi } from "~/hooks/api";
import { useNotification } from "~/hooks/notification";
import { AgentInfo, WeaponInfo } from "~/types/skin";
interface Info {
name: string;
paint_index: number;
display: string;
color: string;
min_float: number;
max_float: number;
}
export function SkinSelector({
isOpen,
setIsOpen,
skin,
setSkin,
side,
}: {
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
skin: WeaponInfo | AgentInfo;
setSkin: Dispatch<SetStateAction<WeaponInfo | AgentInfo>>;
side: "T" | "CT" | "BOTH";
}) {
const [skins, setSkins] = useState<Array<Info>>([]);
const { notify } = useNotification();
const { get } = useApi();
const setPaint = (v: Info) => {
if (skin instanceof WeaponInfo) {
const newSkin = new WeaponInfo(skin);
newSkin.name = v.name;
newSkin.paint_index = v.paint_index;
newSkin.display = v.display === "" ? newSkin.display : newSkin.display.split("|")[0] + " | " + v.display;
newSkin.min_float = v.min_float;
newSkin.max_float = v.max_float;
newSkin.color = v.color;
setSkin(newSkin);
} else {
const newSkin = new AgentInfo(skin);
newSkin.name = v.name;
newSkin.color = v.color;
setSkin(newSkin);
}
};
useEffect(() => {
const fetchSkins = async () => {
let [_, response] = await get(`index?weapon=${skin.weapon_id}&team=${side}${skin instanceof AgentInfo ? "&agent=true" : ""}&gloves=${skin.name.includes("gloves") || skin.name.includes("wraps")}`)
setSkins(response);
};
if (skins.length === 0 && isOpen) {
fetchSkins();
}
}, [isOpen]);
return (
<Modal isOpen={isOpen}>
<Modal.Backdrop
variant="blur"
shouldCloseOnInteractOutside={(_element) => {
setIsOpen(false);
return true;
}}
>
<Modal.Container size="cover">
<Modal.Dialog>
<Modal.CloseTrigger
onPress={() => {
setIsOpen(false);
}}
/>
<Modal.Body className="mt-6">
<div className="grid grid-cols-4 gap-2">
{skins &&
skins.map((v, i) => (
<Button
onClick={() => {
setPaint(v);
setIsOpen(false);
}}
className="flex flex-col w-full h-fit items-center justify-start rounded-none p-0"
style={{
background: `linear-gradient(to bottom, #1f2937, ${v.color}64)`,
}}
>
<img
key={i}
src={`/skins/${v.name}_${skin instanceof WeaponInfo ? v.paint_index : 0}.png`}
className="pointer-events-none object-contain object-bottom"
alt="Weapon image"
/>
<Typography.Heading level={3}>
{v.display}
</Typography.Heading>
<div
className="h-1 w-full"
style={{
backgroundColor: v.color,
}}
></div>
</Button>
))}
</div>
</Modal.Body>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
);
}

View File

@@ -1,10 +1,18 @@
import { Button, Typography } from "@heroui/react"; import { Button, Typography } from "@heroui/react";
import { Float } from "./float"; import { Float } from "./float";
import { useState } from "react"; import { useState, type Dispatch, type SetStateAction } from "react";
import { SkinDialog } from "./skin_dialog"; import { SkinDialog } from "./skin_dialog";
import { WeaponInfo, AgentInfo } from "~/types/skin"; import { WeaponInfo, AgentInfo } from "~/types/skin";
export function SkinTile({ skin }: { skin: WeaponInfo | AgentInfo }) { export function SkinTile({
skin,
setSkin,
side,
}: {
skin: WeaponInfo | AgentInfo;
setSkin: Dispatch<SetStateAction<WeaponInfo | AgentInfo>>
side: "T" | "CT";
}) {
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const acolor = `${skin.color}64`; const acolor = `${skin.color}64`;
return ( return (
@@ -13,6 +21,8 @@ export function SkinTile({ skin }: { skin: WeaponInfo | AgentInfo }) {
isOpen={isDialogOpen} isOpen={isDialogOpen}
setIsOpen={setIsDialogOpen} setIsOpen={setIsDialogOpen}
skin={skin} skin={skin}
setSkin={setSkin}
side={side}
/> />
<Button <Button
onClick={() => setIsDialogOpen(true)} onClick={() => setIsDialogOpen(true)}
@@ -43,7 +53,7 @@ export function SkinTile({ skin }: { skin: WeaponInfo | AgentInfo }) {
{skin.display} {skin.display}
</Typography.Heading> </Typography.Heading>
{skin instanceof WeaponInfo && ( {skin instanceof WeaponInfo && (
<Float float={skin.float} /> <Float skin={skin} setSkin={setSkin} />
)} )}
{skin instanceof WeaponInfo && ( {skin instanceof WeaponInfo && (
<Typography.Paragraph> <Typography.Paragraph>

96
app/hooks/api.tsx Normal file
View File

@@ -0,0 +1,96 @@
import {
useState,
createContext,
useContext,
type PropsWithChildren,
} from "react";
import { useNotification } from "./notification";
import type { NotificationType } from "~/components/notification";
type ApiContextType = {
get: (arg0: string) => Promise<[boolean, any]>;
post: (arg0: string, arg1: any) => Promise<boolean>;
};
const apiContext = createContext<ApiContextType>({
get: async (_: string) => {
return [true, undefined];
},
post: async (_a: string, _b: any) => {
return true;
},
});
export function ApiProvider({ children }: PropsWithChildren) {
const { notify } = useNotification();
const get = async (endpoint: string): Promise<[boolean, any]> => {
let response = await fetch(
`${import.meta.env.VITE_BASE_API_URL}/${endpoint}`,
{ credentials: "include" },
).catch((e) => notifyError(notify, e.code, e.message));
if (!response) {
notifyError(notify, 1, "No data in response from server.");
return [false, undefined];
}
if (!response.ok) {
notifyError(notify, response.status, await response.text());
return [false, undefined];
}
const text = await response.text();
const data = text ? JSON.parse(text) : undefined;
return [true, data];
};
const post = async (endpoint: string, data: any) => {
let response = await fetch(
`${import.meta.env.VITE_BASE_API_URL}/${endpoint}`,
{
credentials: "include",
method: "POST",
body: JSON.stringify(data),
headers: {
"Content-Type": "application/json",
},
},
).catch((e) => notifyError(notify, e.code, e.message));
if (!response) {
notifyError(notify, 1, "No data in response from server.");
return false;
} else if (!response.ok) {
notifyError(notify, response.status, await response.text());
return false;
}
return true;
};
return (
<apiContext.Provider value={{ get, post }}>
{children}
</apiContext.Provider>
);
}
export function useApi() {
return useContext(apiContext);
}
function notifyError(
notify: (arg0: NotificationType, arg1: number) => void,
code: number,
message: string,
) {
notify(
{
type: "danger",
loading: false,
title: "Oops, something went wrong.",
message: `There was an issue on our end: ${message} (Error ${code})`,
},
10000,
);
}

View File

@@ -7,6 +7,7 @@ import {
} from "react"; } from "react";
import { useLocation, useSearchParams } from "react-router"; import { useLocation, useSearchParams } from "react-router";
import { useNotification } from "./notification"; import { useNotification } from "./notification";
import { useApi } from "./api";
type User = { type User = {
id: string; id: string;
@@ -39,6 +40,7 @@ export function AuthProvider({ children }: PropsWithChildren) {
const [searchParams, setSeachParams] = useSearchParams(); const [searchParams, setSeachParams] = useSearchParams();
const { notify } = useNotification(); const { notify } = useNotification();
const location = useLocation(); const location = useLocation();
const { get } = useApi();
useEffect(() => { useEffect(() => {
const load = async () => { const load = async () => {
@@ -56,22 +58,7 @@ export function AuthProvider({ children }: PropsWithChildren) {
); );
let query = searchParams.toString(); let query = searchParams.toString();
setSeachParams({}, { replace: true }); setSeachParams({}, { replace: true });
let response = await fetch( await get(`login?${query}`);
`${import.meta.env.VITE_BASE_API_URL}/login?${query}`,
{ credentials: "include" },
)
.then((r) => r.text())
.catch((e) => {
notify(
{
type: "danger",
loading: false,
title: "An error occured.",
message: `An error occured: ${e}`,
},
10000,
);
});
} }
if (await update()) { if (await update()) {
@@ -98,50 +85,23 @@ export function AuthProvider({ children }: PropsWithChildren) {
}; };
const logout = async () => { const logout = async () => {
await fetch(import.meta.env.VITE_BASE_API_URL + "/logout", { const [ok, _] = await get("logout");
credentials: "include", if (ok) {
}) setUser(null);
.then(() => { notify(
setUser(null); {
notify( type: "success",
{ loading: false,
type: "success", title: "Logout successful!",
loading: false, message: "",
title: "Logout successful!", },
message: "", 2000,
}, );
2000, }
);
})
.catch((e) => {
notify(
{
type: "danger",
loading: false,
title: "An error occured.",
message: `An error occured: ${e}`,
},
10000,
);
});
}; };
const update = async () => { const update = async () => {
let data = await fetch(`${import.meta.env.VITE_BASE_API_URL}/me`, { let [_, data] = await get("me");
credentials: "include",
})
.then((r) => r.json())
.catch((e) => {
notify(
{
type: "danger",
loading: false,
title: "An error occured.",
message: `An error occured: ${e}`,
},
10000,
);
});
if (data !== undefined) { if (data !== undefined) {
setUser(data); setUser(data);

View File

@@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import Base from "./base"; import Base from "./base";
import { Surface, Tabs } from "@heroui/react"; import { Button, Surface, Tabs } from "@heroui/react";
import { Inventory } from "~/components/inventory"; import { Inventory } from "~/components/inventory";
export function InventoryPage() { export function InventoryPage() {
@@ -11,7 +11,7 @@ export function InventoryPage() {
className="flex flex-col flex-1 min-h-0 h-0 shadow-lg shadow-indigo-900 ring-1 ring-indigo-800 p-2 m-2 rounded-3xl gap-2" className="flex flex-col flex-1 min-h-0 h-0 shadow-lg shadow-indigo-900 ring-1 ring-indigo-800 p-2 m-2 rounded-3xl gap-2"
variant="default" variant="default"
> >
<div className="w-full h-min flex"> <div className="w-full h-min flex justify-between items-center">
<Tabs <Tabs
selectedKey={side} selectedKey={side}
onSelectionChange={(key) => { onSelectionChange={(key) => {
@@ -40,6 +40,7 @@ export function InventoryPage() {
</Tabs.List> </Tabs.List>
</Tabs.ListContainer> </Tabs.ListContainer>
</Tabs> </Tabs>
<Button>Need help?</Button>
</div> </div>
<Inventory side={side} /> <Inventory side={side} />
</Surface> </Surface>

View File

@@ -11,6 +11,7 @@ import type { Route } from "./+types/root";
import "./app.css"; import "./app.css";
import { AuthProvider } from "./hooks/auth"; import { AuthProvider } from "./hooks/auth";
import { NotificationProvider } from "./hooks/notification"; import { NotificationProvider } from "./hooks/notification";
import { ApiProvider } from "./hooks/api";
export const links: Route.LinksFunction = () => [ export const links: Route.LinksFunction = () => [
{ rel: "preconnect", href: "https://fonts.googleapis.com" }, { rel: "preconnect", href: "https://fonts.googleapis.com" },
@@ -39,7 +40,9 @@ export function Layout({ children }: { children: React.ReactNode }) {
</head> </head>
<body className="bg-background text-foreground"> <body className="bg-background text-foreground">
<NotificationProvider> <NotificationProvider>
<AuthProvider>{children}</AuthProvider> <ApiProvider>
<AuthProvider>{children}</AuthProvider>
</ApiProvider>
</NotificationProvider> </NotificationProvider>
<ScrollRestoration /> <ScrollRestoration />
<Scripts /> <Scripts />

View File

@@ -1,10 +1,13 @@
export class WeaponInfo { export class WeaponInfo {
id!: string; id!: string;
weapon_id!: string;
name!: string; name!: string;
color!: string; color!: string;
display!: string; display!: string;
paint_index!: number; paint_index!: number;
float!: number; float!: number;
min_float!: number;
max_float!: number;
pattern!: number; pattern!: number;
constructor(data: WeaponInfo) { constructor(data: WeaponInfo) {
@@ -14,6 +17,7 @@ export class WeaponInfo {
export class AgentInfo { export class AgentInfo {
id!: string; id!: string;
weapon_id!: string;
name!: string; name!: string;
color!: string; color!: string;
display!: string; display!: string;