Compare commits
43 Commits
acbb878811
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| fe3f33e142 | |||
| b22ee15a24 | |||
| 038451e0f3 | |||
| b1fa006a0d | |||
| 0ebc0f6d1a | |||
| 81f1bb4bda | |||
| b1d57c645b | |||
| d89b679d08 | |||
| 31ac45f052 | |||
| 221019f3ca | |||
| 2aa56b59c7 | |||
| 1543af0ccf | |||
| 1279c3921e | |||
| adc61be69c | |||
| 9fb01f5746 | |||
| e2e627eeea | |||
| 8cc913f083 | |||
| dc9b69b42d | |||
| 8400ec7632 | |||
| d72d635702 | |||
| b41265842f | |||
| b8f338dbd5 | |||
| e073cfcad1 | |||
| f5ca462ba3 | |||
| 77583597b5 | |||
| 843bf30cae | |||
| 9a1bf23fcf | |||
| 711e1d8f16 | |||
| f9fa72e492 | |||
| 0c944a6082 | |||
| e14bd80d8d | |||
| 1329512fbb | |||
| b13b63c3cb | |||
| 643e865c88 | |||
| f13cfdb032 | |||
| 028ea11c18 | |||
| e45036dec3 | |||
| c50d203de4 | |||
| c81fe28ba5 | |||
| d310837338 | |||
| 43c200e45e | |||
| 2438a07eb7 | |||
| d0975354af |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -6,3 +6,5 @@ pnpm-lock.yaml
|
|||||||
# React Router
|
# React Router
|
||||||
/.react-router/
|
/.react-router/
|
||||||
/build/
|
/build/
|
||||||
|
|
||||||
|
public/skins/
|
||||||
|
|||||||
14
app/components/agent.tsx
Normal file
14
app/components/agent.tsx
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Surface } from "@heroui/react";
|
||||||
|
import type { AgentInfo } from "~/types/skin";
|
||||||
|
|
||||||
|
export function Agent({ agent }: { agent: AgentInfo }) {
|
||||||
|
return (
|
||||||
|
<Surface className="bg-[#1f2937] flex justify-center flex-1 min-h-0 p-4">
|
||||||
|
<img
|
||||||
|
src={`/skins/${agent.name}_big.png`}
|
||||||
|
className="pointer-events-none object-contain object-bottom h-full w-full"
|
||||||
|
alt="Agent image"
|
||||||
|
/>
|
||||||
|
</Surface>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
app/components/float.tsx
Normal file
85
app/components/float.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
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 (
|
||||||
|
<>
|
||||||
|
{hasLabel && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Typography.Paragraph>Float</Typography.Paragraph>
|
||||||
|
<Input
|
||||||
|
className="flex-1"
|
||||||
|
id="float"
|
||||||
|
type="number"
|
||||||
|
step={0.0001}
|
||||||
|
value={skin.float}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { PropsWithChildren } from "react";
|
import type { PropsWithChildren } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
import { useAuth } from "~/hooks/auth";
|
import { useAuth } from "~/hooks/auth";
|
||||||
import { Button, Modal, Spinner } from "@heroui/react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { Button, Modal, Spinner, Alert } from "@heroui/react";
|
||||||
|
|
||||||
export default function Guard({ children }: PropsWithChildren) {
|
export default function Guard({ children }: PropsWithChildren) {
|
||||||
const { user, loading, login, logout } = useAuth();
|
const { user, loading, login, logout } = useAuth();
|
||||||
@@ -24,6 +25,7 @@ export default function Guard({ children }: PropsWithChildren) {
|
|||||||
<div className="h-dvh w-dvw blur-2xl">
|
<div className="h-dvh w-dvw blur-2xl">
|
||||||
<Modal>
|
<Modal>
|
||||||
<Modal.Backdrop
|
<Modal.Backdrop
|
||||||
|
variant="blur"
|
||||||
isDismissable={false}
|
isDismissable={false}
|
||||||
isKeyboardDismissDisabled
|
isKeyboardDismissDisabled
|
||||||
isOpen
|
isOpen
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Separator, Surface, Typography } from "@heroui/react";
|
import { ButtonGroup, Surface, Typography } from "@heroui/react";
|
||||||
import { Tabs } from "@heroui/react";
|
import { Tabs } from "@heroui/react";
|
||||||
import { Button } from "@heroui/react";
|
import { Button } from "@heroui/react";
|
||||||
import { useNavigate, useLocation } from "react-router";
|
import { useNavigate, useLocation } from "react-router";
|
||||||
@@ -43,7 +43,12 @@ export function Header() {
|
|||||||
<div className="absolute flex left-1/2 -translate-x-1/2">
|
<div className="absolute flex left-1/2 -translate-x-1/2">
|
||||||
<img className="h-12" src={porning} alt="Porning logo" />
|
<img className="h-12" src={porning} alt="Porning logo" />
|
||||||
</div>
|
</div>
|
||||||
<User />
|
<ButtonGroup >
|
||||||
|
<Button onClick={() => {window.open(`steam://connect/${import.meta.env.VITE_SERVER_IP}`, "_blank")}}><Typography.Paragraph>Connect to server</Typography.Paragraph></Button>
|
||||||
|
<User>
|
||||||
|
<ButtonGroup.Separator />
|
||||||
|
</User>
|
||||||
|
</ButtonGroup>
|
||||||
</Surface>
|
</Surface>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
129
app/components/inventory.tsx
Normal file
129
app/components/inventory.tsx
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Skeleton, Surface } from "@heroui/react";
|
||||||
|
import { Agent } from "./agent";
|
||||||
|
import { SkinTile } from "./skin_tile";
|
||||||
|
import { useNotification } from "~/hooks/notification";
|
||||||
|
import { WeaponInfo, AgentInfo } from "~/types/skin";
|
||||||
|
import { useApi } from "~/hooks/api";
|
||||||
|
|
||||||
|
export function Inventory({ side }: { side: "T" | "CT" }) {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [agent, setAgent] = useState<AgentInfo>();
|
||||||
|
const [gloves, setGloves] = useState<WeaponInfo>();
|
||||||
|
const [knife, setKnife] = useState<WeaponInfo>();
|
||||||
|
const [weapons, setWeapons] = useState<Array<WeaponInfo>>([]);
|
||||||
|
const { notify } = useNotification();
|
||||||
|
const { get } = useApi();
|
||||||
|
|
||||||
|
const setWeapon = (index:number, newWeapon:WeaponInfo) => {
|
||||||
|
setWeapons(weapons.map((v,i) => (i===index ? newWeapon: v)))
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetch_weapons = async () => {
|
||||||
|
let [success, data] = await get(`skin?team=${side}&category=Knives`)
|
||||||
|
|
||||||
|
setKnife(new WeaponInfo(data[0]));
|
||||||
|
|
||||||
|
[success, data] = await get(`skin?team=${side}&category=Gloves`)
|
||||||
|
|
||||||
|
setGloves(new WeaponInfo(data[0]));
|
||||||
|
|
||||||
|
[success, data] = await get(`skin?team=${side}&category=Weapons`);
|
||||||
|
|
||||||
|
setWeapons(data.map((item: any) => (new WeaponInfo(item))));
|
||||||
|
|
||||||
|
[success, data] = await get(`agent?team=${side}`);
|
||||||
|
|
||||||
|
setAgent(new AgentInfo(data));
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) fetch_weapons();
|
||||||
|
}, [loading]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
}, [side]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 gap-2 min-h-0">
|
||||||
|
<Surface
|
||||||
|
className="flex flex-col rounded-xl w-1/3 p-4 gap-2"
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
<Skeleton className="flex-1 h-55 w-full rounded-xl" />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Skeleton className="flex-1 h-55 rounded-xl" />
|
||||||
|
<Skeleton className="flex-1 h-55 rounded-xl" />
|
||||||
|
<Skeleton className="flex-1 h-55 rounded-xl" />
|
||||||
|
</div>
|
||||||
|
</Surface>
|
||||||
|
<Surface
|
||||||
|
className="flex-1 flex flex-col rounded-xl w-2/3 p-4"
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
<div className="absolute inset-4 flex-1 grid grid-cols-5 gap-2 overflow-y-scroll">
|
||||||
|
{Array.from({ length: 50 }, (_, index) => (
|
||||||
|
<Skeleton
|
||||||
|
key={index}
|
||||||
|
className="flex-1 h-55 rounded-xl"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Surface>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-1 gap-2 min-h-0">
|
||||||
|
<Surface
|
||||||
|
className="flex flex-col rounded-xl w-1/3 p-4 gap-2 min-h-0"
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
{agent && <Agent agent={agent}></Agent>}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{knife && (
|
||||||
|
<SkinTile
|
||||||
|
skin={knife}
|
||||||
|
setSkin={(newSkin) => setKnife(newSkin as WeaponInfo)}
|
||||||
|
side={side}
|
||||||
|
></SkinTile>
|
||||||
|
)}
|
||||||
|
{gloves && (
|
||||||
|
<SkinTile
|
||||||
|
skin={gloves}
|
||||||
|
setSkin={(newSkin) => setGloves(newSkin as WeaponInfo)}
|
||||||
|
side={side}
|
||||||
|
></SkinTile>
|
||||||
|
)}
|
||||||
|
{agent && (
|
||||||
|
<SkinTile
|
||||||
|
skin={agent}
|
||||||
|
setSkin={(newSkin) => setAgent(newSkin as AgentInfo)}
|
||||||
|
side={side}
|
||||||
|
></SkinTile>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Surface>
|
||||||
|
<Surface
|
||||||
|
className="flex-1 flex flex-col rounded-xl w-2/3 p-4"
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
<div className="absolute inset-4 flex-1 grid grid-cols-5 gap-2 overflow-y-scroll">
|
||||||
|
{weapons.map((v, i) => (
|
||||||
|
<SkinTile
|
||||||
|
key={i}
|
||||||
|
skin={v}
|
||||||
|
setSkin={(newWeapon) => setWeapon(i, newWeapon as WeaponInfo)}
|
||||||
|
side={side}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Surface>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/components/notification.tsx
Normal file
33
app/components/notification.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Alert, Spinner } from "@heroui/react";
|
||||||
|
|
||||||
|
export type NotificationType = {
|
||||||
|
type: "success" | "default" | "accent" | "danger" | "warning";
|
||||||
|
loading: boolean;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Notification({
|
||||||
|
type,
|
||||||
|
loading,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
}: NotificationType) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center fixed top-12 w-dvw z-1000">
|
||||||
|
<Alert className="w-fit h-fit border border-white" status={type}>
|
||||||
|
{loading ? (
|
||||||
|
<Alert.Indicator>
|
||||||
|
<Spinner size="sm" />
|
||||||
|
</Alert.Indicator>
|
||||||
|
) : (
|
||||||
|
<Alert.Indicator />
|
||||||
|
)}
|
||||||
|
<Alert.Content>
|
||||||
|
<Alert.Title>{title}</Alert.Title>
|
||||||
|
<Alert.Description>{message}</Alert.Description>
|
||||||
|
</Alert.Content>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
268
app/components/settings.tsx
Normal file
268
app/components/settings.tsx
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Button,
|
||||||
|
Input,
|
||||||
|
TextField,
|
||||||
|
Form,
|
||||||
|
Fieldset,
|
||||||
|
Description,
|
||||||
|
Label,
|
||||||
|
Typography,
|
||||||
|
Spinner,
|
||||||
|
AlertDialog,
|
||||||
|
useOverlayState,
|
||||||
|
} from "@heroui/react";
|
||||||
|
import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
|
||||||
|
import { useApi } from "~/hooks/api";
|
||||||
|
import { useAuth } from "~/hooks/auth";
|
||||||
|
import { useNotification } from "~/hooks/notification";
|
||||||
|
|
||||||
|
export function Settings({
|
||||||
|
show,
|
||||||
|
setShow,
|
||||||
|
}: {
|
||||||
|
show: boolean;
|
||||||
|
setShow: Dispatch<SetStateAction<boolean>>;
|
||||||
|
}) {
|
||||||
|
const { user, update, logout } = useAuth();
|
||||||
|
const { notify } = useNotification();
|
||||||
|
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 = () => {
|
||||||
|
notify(
|
||||||
|
{
|
||||||
|
type: "success",
|
||||||
|
loading: false,
|
||||||
|
title: "Settings successfully updated!",
|
||||||
|
message: "",
|
||||||
|
},
|
||||||
|
2000,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const formData = new FormData(e.currentTarget);
|
||||||
|
const data: Record<string, string> = {};
|
||||||
|
|
||||||
|
// Convert FormData to plain object
|
||||||
|
formData.forEach((value, key) => {
|
||||||
|
data[key] = value.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
let ok = await post("update", data);
|
||||||
|
|
||||||
|
if (ok) {
|
||||||
|
await update();
|
||||||
|
notifySucess();
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
save();
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadFromSteam = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
let [ok, _] = await get("reload");
|
||||||
|
|
||||||
|
if (ok) {
|
||||||
|
await update();
|
||||||
|
notifySucess();
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteAccount = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
let [ok, _] = await get("delete");
|
||||||
|
|
||||||
|
if (ok) {
|
||||||
|
await logout();
|
||||||
|
notifySucess();
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
deleteState.close();
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Modal isOpen={show}>
|
||||||
|
<Modal.Backdrop
|
||||||
|
variant="blur"
|
||||||
|
shouldCloseOnInteractOutside={(_element) => {
|
||||||
|
if (!canClose) return false;
|
||||||
|
setShow(false);
|
||||||
|
return true;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Modal.Container size="lg">
|
||||||
|
<Modal.Dialog>
|
||||||
|
<Modal.CloseTrigger
|
||||||
|
onPress={() => {
|
||||||
|
setShow(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Modal.Body>
|
||||||
|
<Form className="w-full" onSubmit={onSubmit}>
|
||||||
|
<Fieldset>
|
||||||
|
<Typography.Heading level={3}>
|
||||||
|
Account settings.
|
||||||
|
</Typography.Heading>
|
||||||
|
<Typography.Paragraph size="sm">
|
||||||
|
Update your profile information
|
||||||
|
either manually or from Steam.
|
||||||
|
<br />
|
||||||
|
Change several settings concerning
|
||||||
|
some functionnalities of the app.
|
||||||
|
</Typography.Paragraph>
|
||||||
|
<Fieldset.Group>
|
||||||
|
<TextField name="username">
|
||||||
|
<Label>Username</Label>
|
||||||
|
<Input
|
||||||
|
placeholder={user?.name}
|
||||||
|
variant="secondary"
|
||||||
|
></Input>
|
||||||
|
</TextField>
|
||||||
|
<TextField name="avatar">
|
||||||
|
<Label>Avatar URL</Label>
|
||||||
|
<Input
|
||||||
|
placeholder={user?.avatar}
|
||||||
|
variant="secondary"
|
||||||
|
></Input>
|
||||||
|
<Description>
|
||||||
|
The URL must be a valid
|
||||||
|
image url.
|
||||||
|
</Description>
|
||||||
|
</TextField>
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
variant="tertiary"
|
||||||
|
isPending={loading}
|
||||||
|
onPress={() => {
|
||||||
|
loadFromSteam();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Spinner
|
||||||
|
color="current"
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
"Load from Steam"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</Fieldset.Group>
|
||||||
|
<Fieldset.Actions className="flex justify-end">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
isPending={loading}
|
||||||
|
onPress={() => {
|
||||||
|
deleteState.open();
|
||||||
|
setCanClose(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Spinner
|
||||||
|
color="current"
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
"Delete account"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onPress={() => {
|
||||||
|
setShow(false);
|
||||||
|
}}
|
||||||
|
isPending={loading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
isPending={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Spinner
|
||||||
|
color="current"
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
"Save"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</Fieldset.Actions>
|
||||||
|
</Fieldset>
|
||||||
|
</Form>
|
||||||
|
</Modal.Body>
|
||||||
|
</Modal.Dialog>
|
||||||
|
</Modal.Container>
|
||||||
|
</Modal.Backdrop>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
182
app/components/skin_dialog.tsx
Normal file
182
app/components/skin_dialog.tsx
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { Button, Input, Modal, Surface, Typography } from "@heroui/react";
|
||||||
|
import {
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
type ChangeEvent,
|
||||||
|
type Dispatch,
|
||||||
|
type SetStateAction,
|
||||||
|
} from "react";
|
||||||
|
import { AgentInfo, WeaponInfo } from "~/types/skin";
|
||||||
|
import { Float } from "./float";
|
||||||
|
import { SkinSelector } from "./skin_selector";
|
||||||
|
|
||||||
|
export function SkinDialog({
|
||||||
|
isOpen,
|
||||||
|
setIsOpen,
|
||||||
|
skin,
|
||||||
|
setSkin,
|
||||||
|
side,
|
||||||
|
}: {
|
||||||
|
isOpen: boolean;
|
||||||
|
setIsOpen: Dispatch<SetStateAction<boolean>>;
|
||||||
|
skin: WeaponInfo | AgentInfo;
|
||||||
|
setSkin: Dispatch<SetStateAction<WeaponInfo | AgentInfo>>;
|
||||||
|
side: "T" | "CT";
|
||||||
|
}) {
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<Modal isOpen={isOpen}>
|
||||||
|
<Modal.Backdrop
|
||||||
|
variant="blur"
|
||||||
|
shouldCloseOnInteractOutside={(_element) => {
|
||||||
|
if (canClose) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
return canClose;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Modal.Container size="lg">
|
||||||
|
<Modal.Dialog>
|
||||||
|
<Modal.CloseTrigger
|
||||||
|
onPress={() => {
|
||||||
|
if (!selectorOpen) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Modal.Body>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Typography.Heading
|
||||||
|
level={4}
|
||||||
|
className="py-1 text-wrap"
|
||||||
|
>
|
||||||
|
{localSkin.display}
|
||||||
|
</Typography.Heading>
|
||||||
|
<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");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Apply
|
||||||
|
{localSkin instanceof WeaponInfo
|
||||||
|
? ` to ${side}`
|
||||||
|
: ""}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal.Body>
|
||||||
|
</Modal.Dialog>
|
||||||
|
</Modal.Container>
|
||||||
|
</Modal.Backdrop>
|
||||||
|
</Modal>
|
||||||
|
<SkinSelector
|
||||||
|
isOpen={selectorOpen}
|
||||||
|
setIsOpen={setSelectorOpen}
|
||||||
|
skin={localSkin}
|
||||||
|
setSkin={setLocalSkin}
|
||||||
|
side={side}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
app/components/skin_selector.tsx
Normal file
116
app/components/skin_selector.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
68
app/components/skin_tile.tsx
Normal file
68
app/components/skin_tile.tsx
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { Button, Typography } from "@heroui/react";
|
||||||
|
import { Float } from "./float";
|
||||||
|
import { useState, type Dispatch, type SetStateAction } from "react";
|
||||||
|
import { SkinDialog } from "./skin_dialog";
|
||||||
|
import { WeaponInfo, AgentInfo } from "~/types/skin";
|
||||||
|
|
||||||
|
export function SkinTile({
|
||||||
|
skin,
|
||||||
|
setSkin,
|
||||||
|
side,
|
||||||
|
}: {
|
||||||
|
skin: WeaponInfo | AgentInfo;
|
||||||
|
setSkin: Dispatch<SetStateAction<WeaponInfo | AgentInfo>>
|
||||||
|
side: "T" | "CT";
|
||||||
|
}) {
|
||||||
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
|
const acolor = `${skin.color}64`;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SkinDialog
|
||||||
|
isOpen={isDialogOpen}
|
||||||
|
setIsOpen={setIsDialogOpen}
|
||||||
|
skin={skin}
|
||||||
|
setSkin={setSkin}
|
||||||
|
side={side}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={() => setIsDialogOpen(true)}
|
||||||
|
className="bg-mist-700 flex flex-col h-full p-0 rounded-none gap-0 hover:bg-indigo-800 active:bg-indigo-900"
|
||||||
|
>
|
||||||
|
<div className="flex flex-col w-full h-full">
|
||||||
|
<div
|
||||||
|
className="bg-linear-to-b pointer-events-none w-full h-full"
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(to bottom, #1f2937, ${acolor})`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={`/skins/${skin.name}_${skin instanceof WeaponInfo ? skin.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: `${skin.color}` }}
|
||||||
|
></div>
|
||||||
|
<div className="px-2">
|
||||||
|
<Typography.Heading
|
||||||
|
level={6}
|
||||||
|
className="py-1 text-wrap"
|
||||||
|
>
|
||||||
|
{skin.display}
|
||||||
|
</Typography.Heading>
|
||||||
|
{skin instanceof WeaponInfo && (
|
||||||
|
<Float skin={skin} setSkin={setSkin} />
|
||||||
|
)}
|
||||||
|
{skin instanceof WeaponInfo && (
|
||||||
|
<Typography.Paragraph>
|
||||||
|
Pattern: {skin.pattern}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,27 +7,38 @@ import {
|
|||||||
Separator,
|
Separator,
|
||||||
Spinner,
|
Spinner,
|
||||||
} from "@heroui/react";
|
} from "@heroui/react";
|
||||||
|
import { useState, type PropsWithChildren } from "react";
|
||||||
import { useAuth } from "~/hooks/auth";
|
import { useAuth } from "~/hooks/auth";
|
||||||
|
import { Settings } from "./settings";
|
||||||
|
|
||||||
export default function User() {
|
export default function User({ children }: PropsWithChildren) {
|
||||||
const { user, loading, login, logout } = useAuth();
|
const { user, loading, login, logout } = useAuth();
|
||||||
|
const [showSettings, setShowSettings] = useState(false);
|
||||||
|
|
||||||
|
const toggleSettings = () => {setShowSettings(true)}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Button isPending={true}>
|
<Button isPending={true}>
|
||||||
<Spinner color="current" size="md" />
|
<Spinner color="current" size="md" />
|
||||||
Loading...
|
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user === null) {
|
if (user === null) {
|
||||||
return <Button onClick={() => login()}>Login</Button>;
|
return (
|
||||||
|
<Button onClick={() => login()}>
|
||||||
|
<Typography.Paragraph>{children}Login</Typography.Paragraph>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<Settings show={showSettings} setShow={setShowSettings}/>
|
||||||
<Dropdown>
|
<Dropdown>
|
||||||
<Button className="pr-0">
|
<Button className="pr-0">
|
||||||
|
{children}
|
||||||
<Typography.Paragraph>{user.name}</Typography.Paragraph>
|
<Typography.Paragraph>{user.name}</Typography.Paragraph>
|
||||||
<Avatar>
|
<Avatar>
|
||||||
<Avatar.Image alt="Avatar" src={user.avatar}></Avatar.Image>
|
<Avatar.Image alt="Avatar" src={user.avatar}></Avatar.Image>
|
||||||
@@ -35,7 +46,7 @@ export default function User() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Dropdown.Popover>
|
<Dropdown.Popover>
|
||||||
<Dropdown.Menu
|
<Dropdown.Menu
|
||||||
onAction={(key) => handleAction(String(key), logout)}
|
onAction={(key) => handleAction(String(key), logout, toggleSettings)}
|
||||||
>
|
>
|
||||||
<Dropdown.Item id="settings" textValue="Settings">
|
<Dropdown.Item id="settings" textValue="Settings">
|
||||||
<Label>Settings</Label>
|
<Label>Settings</Label>
|
||||||
@@ -51,12 +62,13 @@ export default function User() {
|
|||||||
</Dropdown.Menu>
|
</Dropdown.Menu>
|
||||||
</Dropdown.Popover>
|
</Dropdown.Popover>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAction(key: string, logout: () => Promise<void>) {
|
function handleAction(key: string, logout: () => Promise<void>, toggleSettings: () => void) {
|
||||||
if (key === "settings") {
|
if (key === "settings") {
|
||||||
console.log("settings");
|
toggleSettings();
|
||||||
} else {
|
} else {
|
||||||
logout();
|
logout();
|
||||||
}
|
}
|
||||||
|
|||||||
96
app/hooks/api.tsx
Normal file
96
app/hooks/api.tsx
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
type PropsWithChildren,
|
type PropsWithChildren,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useLocation, useSearchParams } from "react-router";
|
import { useLocation, useSearchParams } from "react-router";
|
||||||
|
import { useNotification } from "./notification";
|
||||||
|
import { useApi } from "./api";
|
||||||
|
|
||||||
type User = {
|
type User = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -19,6 +21,7 @@ type AuthContextType = {
|
|||||||
loading: boolean;
|
loading: boolean;
|
||||||
login: () => void;
|
login: () => void;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
|
update: () => Promise<boolean>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextType>({
|
const AuthContext = createContext<AuthContextType>({
|
||||||
@@ -26,35 +29,49 @@ const AuthContext = createContext<AuthContextType>({
|
|||||||
loading: false,
|
loading: false,
|
||||||
login: () => {},
|
login: () => {},
|
||||||
logout: async () => {},
|
logout: async () => {},
|
||||||
|
update: async () => {
|
||||||
|
return false;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export function AuthProvider({ children }: PropsWithChildren) {
|
export function AuthProvider({ children }: PropsWithChildren) {
|
||||||
const [user, setUser] = useState(null);
|
const [user, setUser] = useState(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [searchParams, setSeachParams] = useSearchParams();
|
const [searchParams, setSeachParams] = useSearchParams();
|
||||||
|
const { notify } = useNotification();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const { get } = useApi();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
if (searchParams.get("openid.ns") !== null) {
|
if (searchParams.get("openid.ns") !== null) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
notify(
|
||||||
|
{
|
||||||
|
type: "accent",
|
||||||
|
loading: true,
|
||||||
|
title: "Logging in...",
|
||||||
|
message:
|
||||||
|
"Please wait while we log you in and sync your data. This may take a few moments.",
|
||||||
|
},
|
||||||
|
Infinity,
|
||||||
|
);
|
||||||
let query = searchParams.toString();
|
let query = searchParams.toString();
|
||||||
setSeachParams({}, { replace: true });
|
setSeachParams({}, { replace: true });
|
||||||
let response = await fetch(
|
await get(`login?${query}`);
|
||||||
"http://localhost:8080/login?" + query,
|
|
||||||
{ credentials: "include" },
|
|
||||||
).then((r) => r.text());
|
|
||||||
if (response === "nok") {
|
|
||||||
// TODO: signal error to user
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let data = await fetch("http://localhost:8080/me", {
|
if (await update()) {
|
||||||
credentials: "include",
|
notify(
|
||||||
}).then((r) => r.json());
|
{
|
||||||
|
type: "success",
|
||||||
console.log(data);
|
loading: false,
|
||||||
setUser(data);
|
title: "Login successful!",
|
||||||
|
message: "",
|
||||||
|
},
|
||||||
|
2000,
|
||||||
|
);
|
||||||
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -63,18 +80,38 @@ export function AuthProvider({ children }: PropsWithChildren) {
|
|||||||
|
|
||||||
const login = () => {
|
const login = () => {
|
||||||
window.location.replace(
|
window.location.replace(
|
||||||
"https://steamcommunity.com/openid/login?openid.claimed_id=http://specs.openid.net/auth/2.0/identifier_select&openid.identity=http://specs.openid.net/auth/2.0/identifier_select&openid.mode=checkid_setup&openid.ns=http://specs.openid.net/auth/2.0&openid.realm=http://localhost:5173&openid.return_to=http://localhost:5173" +
|
`https://steamcommunity.com/openid/login?openid.claimed_id=http://specs.openid.net/auth/2.0/identifier_select&openid.identity=http://specs.openid.net/auth/2.0/identifier_select&openid.mode=checkid_setup&openid.ns=http://specs.openid.net/auth/2.0&openid.realm=${import.meta.env.VITE_BASE_URL}&openid.return_to=${import.meta.env.VITE_BASE_URL}${location.pathname}`,
|
||||||
location.pathname,
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const logout = async () => {
|
const logout = async () => {
|
||||||
await fetch("http://localhost:8080/logout", { credentials: "include" });
|
const [ok, _] = await get("logout");
|
||||||
|
if (ok) {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
|
notify(
|
||||||
|
{
|
||||||
|
type: "success",
|
||||||
|
loading: false,
|
||||||
|
title: "Logout successful!",
|
||||||
|
message: "",
|
||||||
|
},
|
||||||
|
2000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const update = async () => {
|
||||||
|
let [_, data] = await get("me");
|
||||||
|
|
||||||
|
if (data !== undefined) {
|
||||||
|
setUser(data);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ user, loading, login, logout }}>
|
<AuthContext.Provider value={{ user, loading, login, logout, update }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
59
app/hooks/notification.tsx
Normal file
59
app/hooks/notification.tsx
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
type PropsWithChildren,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
useContext,
|
||||||
|
} from "react";
|
||||||
|
import { type NotificationType, Notification } from "~/components/notification";
|
||||||
|
|
||||||
|
type NotificationContextType = {
|
||||||
|
notify: (arg0: NotificationType, arg1: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const NotificationContext = createContext<NotificationContextType>({
|
||||||
|
notify: (_arg0: NotificationType, _arg1: number) => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function NotificationProvider({ children }: PropsWithChildren) {
|
||||||
|
const [notification, setNotification] = useState<NotificationType | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [notificationTimeout, setNotificationTimeout] = useState(2000);
|
||||||
|
const [popupShowed, setPopupShowed] = useState(false);
|
||||||
|
|
||||||
|
const notify = (newNotification: NotificationType, timeout: number) => {
|
||||||
|
setNotification(newNotification);
|
||||||
|
setNotificationTimeout(timeout);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (notification === null) {
|
||||||
|
setPopupShowed(false);
|
||||||
|
} else if (!popupShowed && notificationTimeout !== Infinity) {
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
setPopupShowed(true);
|
||||||
|
setNotification(null);
|
||||||
|
}, notificationTimeout);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}
|
||||||
|
}, [notification, notificationTimeout]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NotificationContext.Provider value={{ notify }}>
|
||||||
|
{notification !== null && !popupShowed && (
|
||||||
|
<Notification
|
||||||
|
type={notification.type}
|
||||||
|
loading={notification.loading}
|
||||||
|
title={notification.title}
|
||||||
|
message={notification.message}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</NotificationContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useNotification() {
|
||||||
|
return useContext(NotificationContext);
|
||||||
|
}
|
||||||
@@ -6,11 +6,12 @@ import { Header } from "~/components/header";
|
|||||||
export default function Base({
|
export default function Base({
|
||||||
children,
|
children,
|
||||||
needsLogin = false,
|
needsLogin = false,
|
||||||
}: PropsWithChildren<{ needsLogin?: boolean }>) {
|
fixedHeight = false,
|
||||||
|
}: PropsWithChildren<{ needsLogin?: boolean, fixedHeight?: boolean }>) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col pt-20 min-h-dvh">
|
<div className={`flex flex-col pt-20 min-h-dvh ${fixedHeight && "h-dvh"}`}>
|
||||||
<div className="flex flex-col flex-1">
|
<div className="flex flex-col flex-1 min-h-0">
|
||||||
{needsLogin ? <Guard>{children}</Guard> : children}
|
{needsLogin ? <Guard>{children}</Guard> : children}
|
||||||
</div>
|
</div>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|||||||
@@ -1,5 +1,49 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import Base from "./base";
|
import Base from "./base";
|
||||||
|
import { Button, Surface, Tabs } from "@heroui/react";
|
||||||
|
import { Inventory } from "~/components/inventory";
|
||||||
|
|
||||||
export function InventoryPage() {
|
export function InventoryPage() {
|
||||||
return <Base needsLogin>INVENTORY</Base>;
|
const [side, setSide] = useState<"T" | "CT">("T");
|
||||||
|
return (
|
||||||
|
<Base needsLogin fixedHeight>
|
||||||
|
<Surface
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<div className="w-full h-min flex justify-between items-center">
|
||||||
|
<Tabs
|
||||||
|
selectedKey={side}
|
||||||
|
onSelectionChange={(key) => {
|
||||||
|
switch (key) {
|
||||||
|
case "T": {
|
||||||
|
setSide("T");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case "CT": {
|
||||||
|
setSide("CT");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tabs.ListContainer>
|
||||||
|
<Tabs.List>
|
||||||
|
<Tabs.Tab className="w-fit" id="T">
|
||||||
|
Terrorist
|
||||||
|
<Tabs.Indicator />
|
||||||
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab className="w-fit" id="CT">
|
||||||
|
Counter-Terrorist
|
||||||
|
<Tabs.Indicator />
|
||||||
|
</Tabs.Tab>
|
||||||
|
</Tabs.List>
|
||||||
|
</Tabs.ListContainer>
|
||||||
|
</Tabs>
|
||||||
|
<Button>Need help?</Button>
|
||||||
|
</div>
|
||||||
|
<Inventory side={side} />
|
||||||
|
</Surface>
|
||||||
|
</Base>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
15
app/root.tsx
15
app/root.tsx
@@ -10,6 +10,8 @@ import {
|
|||||||
import type { Route } from "./+types/root";
|
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 { 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" },
|
||||||
@@ -29,14 +31,19 @@ export function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
<html className="dark" data-theme="dark" lang="en">
|
<html className="dark" data-theme="dark" lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charSet="utf-8" />
|
<meta charSet="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1"
|
||||||
|
/>
|
||||||
<Meta />
|
<Meta />
|
||||||
<Links />
|
<Links />
|
||||||
</head>
|
</head>
|
||||||
<body className="bg-background text-foreground">
|
<body className="bg-background text-foreground">
|
||||||
<AuthProvider>
|
<NotificationProvider>
|
||||||
{children}
|
<ApiProvider>
|
||||||
</AuthProvider>
|
<AuthProvider>{children}</AuthProvider>
|
||||||
|
</ApiProvider>
|
||||||
|
</NotificationProvider>
|
||||||
<ScrollRestoration />
|
<ScrollRestoration />
|
||||||
<Scripts />
|
<Scripts />
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
28
app/types/skin.ts
Normal file
28
app/types/skin.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
export class WeaponInfo {
|
||||||
|
id!: string;
|
||||||
|
weapon_id!: string;
|
||||||
|
name!: string;
|
||||||
|
color!: string;
|
||||||
|
display!: string;
|
||||||
|
paint_index!: number;
|
||||||
|
float!: number;
|
||||||
|
min_float!: number;
|
||||||
|
max_float!: number;
|
||||||
|
pattern!: number;
|
||||||
|
|
||||||
|
constructor(data: WeaponInfo) {
|
||||||
|
Object.assign(this, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AgentInfo {
|
||||||
|
id!: string;
|
||||||
|
weapon_id!: string;
|
||||||
|
name!: string;
|
||||||
|
color!: string;
|
||||||
|
display!: string;
|
||||||
|
|
||||||
|
constructor(data: AgentInfo) {
|
||||||
|
Object.assign(this, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user