Compare commits

..

52 Commits

Author SHA1 Message Date
fe3f33e142 Using the new category to filter skins shown in the right panel 2026-10-01 00:08:35 +02:00
b22ee15a24 Inventory and dialog revamp from user feedback 2026-09-30 01:13:09 +02:00
038451e0f3 Fixed issue that prevents proper logout 2026-09-30 01:12:50 +02:00
b1fa006a0d Fixed issue where the float could be incorrect for a new skin 2026-09-30 01:12:36 +02:00
0ebc0f6d1a Fixed misuse of api 2026-09-29 00:14:42 +02:00
81f1bb4bda Updated code to use new Api provider 2026-09-29 00:00:52 +02:00
b1d57c645b Created api hook to reduce boilerplate code 2026-09-28 23:07:24 +02:00
d89b679d08 Fixed bug when changing gloves 2026-09-28 01:20:33 +02:00
31ac45f052 Using state for weapons (necessary for agent) 2026-09-28 01:11:52 +02:00
221019f3ca Fixed skin color not changing 2026-09-27 23:27:52 +02:00
2aa56b59c7 Added ability to delete account 2026-09-27 19:20:01 +02:00
1543af0ccf Progress on implementation of skin selector 2026-09-25 00:36:08 +02:00
1279c3921e Addition of min/max float and remaking of Float to be a slider 2026-09-24 15:40:07 +02:00
adc61be69c Change buttons according to the type of skin (Weapon or Agent) 2026-09-12 01:05:34 +02:00
9fb01f5746 Added buttons to dialog mockup 2026-09-12 01:02:23 +02:00
e2e627eeea Implemented a mockup for the dialog to change skins 2026-09-12 00:56:41 +02:00
8cc913f083 Modified float to not be dependant on the padding of the parent 2026-09-12 00:56:22 +02:00
dc9b69b42d Introduced classes for weapons and agents and restructured code 2026-09-12 00:56:08 +02:00
8400ec7632 Revamped weapon display and agent display 2026-09-11 00:03:38 +02:00
d72d635702 Using new api 2026-09-11 00:03:23 +02:00
b41265842f Added fixedheight for inventory 2026-09-11 00:03:12 +02:00
b8f338dbd5 Added property fixedHeight 2026-09-11 00:02:52 +02:00
e073cfcad1 Modified weapon to all be the same size by resizing text and image 2026-09-10 18:37:58 +02:00
f5ca462ba3 Removed unnecessary surface 2026-09-09 23:59:26 +02:00
77583597b5 Updated to use new api and new weapon display 2026-09-08 18:26:28 +02:00
843bf30cae Reworked base weapon display 2026-09-08 18:26:17 +02:00
9a1bf23fcf Correction of the path used for images 2026-09-03 02:42:54 +02:00
711e1d8f16 Inventory mockup (experimental) 2026-09-03 02:42:17 +02:00
f9fa72e492 Added skin images to .gitignore 2026-09-03 02:38:59 +02:00
0c944a6082 Fixed the connect to server button not connecting 2026-08-29 20:03:21 +02:00
e14bd80d8d Fix success notification showing when failing to update settings 2026-08-29 18:12:21 +02:00
1329512fbb Implemented settings ui 2026-08-29 18:07:16 +02:00
b13b63c3cb Added update function to reload user information without re-logging in 2026-08-29 18:07:04 +02:00
643e865c88 Augmented Notification's z index 2026-08-29 18:06:43 +02:00
f13cfdb032 Changed guard backdrop to blur variant 2026-07-30 00:55:57 +02:00
028ea11c18 Revamped settings ui 2026-07-30 00:55:44 +02:00
e45036dec3 Settings ui trial 2026-07-28 20:22:05 +02:00
c50d203de4 Modfied auth to use notification provider 2026-07-25 22:12:04 -04:00
c81fe28ba5 Updated user button to allow for children 2026-07-25 22:11:21 -04:00
d310837338 Added button to connect to game server 2026-07-25 22:11:01 -04:00
43c200e45e Removed console.log 2026-07-25 22:10:44 -04:00
2438a07eb7 Added NotificationProvider 2026-07-25 01:03:16 -04:00
d0975354af Moved login and primitive error popups to auth hook 2026-07-22 23:22:53 -04:00
acbb878811 Added a login guard 2026-07-22 20:49:33 -04:00
852331f909 Added feedback to the user that their profile is loading 2026-07-22 20:49:18 -04:00
c6b6d316cf Added loading property when user's profile is loading 2026-07-22 20:49:00 -04:00
c7530dcbab Proper authentication (needs more user feedback) 2026-07-18 00:31:47 -04:00
6aab99ab8f Added user authentication 2026-07-14 12:59:47 -04:00
5d6aa0a569 Introduced "Base" page as a component 2026-07-13 11:54:03 -04:00
d5b9848b84 Added simple footer 2026-07-12 23:20:01 -04:00
9359fdb016 Added logo 2026-07-12 22:54:33 -04:00
464c1a7205 Modified layout of home page cards 2026-07-11 23:00:27 -04:00
25 changed files with 1600 additions and 92 deletions

2
.gitignore vendored
View File

@@ -6,3 +6,5 @@ pnpm-lock.yaml
# React Router
/.react-router/
/build/
public/skins/

14
app/components/agent.tsx Normal file
View 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
View 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>
</>
);
}

29
app/components/footer.tsx Normal file
View File

@@ -0,0 +1,29 @@
import { Separator, Surface, Typography } from "@heroui/react";
import { Link } from "@heroui/react";
export function Footer() {
const date = new Date();
return (
<Surface className="flex p-4 justify-between" variant="default">
<Typography.Paragraph>
Copyright © {date.getFullYear()} Porning. All rights reserved.
</Typography.Paragraph>
<div className="flex gap-2">
<Link href="/contact">
Contact Us
<Link.Icon className="ml-1.5 size-3"></Link.Icon>
</Link>
<Separator orientation="vertical" variant="tertiary" />
<Link href="/legal/privacy">
Privacy Policy
<Link.Icon className="ml-1.5 size-3"></Link.Icon>
</Link>
<Separator orientation="vertical" variant="tertiary" />
<Link href="/legal/terms">
Terms of Use
<Link.Icon className="ml-1.5 size-3"></Link.Icon>
</Link>
</div>
</Surface>
);
}

64
app/components/guard.tsx Normal file
View File

@@ -0,0 +1,64 @@
import type { PropsWithChildren } from "react";
import { useNavigate } from "react-router";
import { useAuth } from "~/hooks/auth";
import { useEffect, useState } from "react";
import { Button, Modal, Spinner, Alert } from "@heroui/react";
export default function Guard({ children }: PropsWithChildren) {
const { user, loading, login, logout } = useAuth();
const navigate = useNavigate();
if (loading) {
return (
<div className="flex flex-col flex-1 h-full w-full justify-center items-center">
<Spinner color="current" size="xl" />
</div>
);
}
if (user !== null) {
return children;
}
return (
<>
<div className="h-dvh w-dvw blur-2xl">
<Modal>
<Modal.Backdrop
variant="blur"
isDismissable={false}
isKeyboardDismissDisabled
isOpen
>
<Modal.Container>
<Modal.Dialog>
<Modal.Header>
<Modal.Heading>
Login required.
</Modal.Heading>
</Modal.Header>
<Modal.Body>
<p>
To view this page, you need to be logged
in with your Steam account.
</p>
</Modal.Body>
<Modal.Footer>
<Button
variant="secondary"
onClick={() => {
navigate(-1);
}}
>
Cancel
</Button>
<Button onClick={login}>Login</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal>
</div>
</>
);
}

View File

@@ -1,7 +1,9 @@
import { Separator, Surface, Typography } from "@heroui/react";
import { ButtonGroup, Surface, Typography } from "@heroui/react";
import { Tabs } from "@heroui/react";
import { Button } from "@heroui/react";
import { useNavigate, useLocation } from "react-router";
import porning from "../images/porning.svg";
import User from "./user";
export function Header() {
const location = useLocation();
@@ -39,21 +41,14 @@ export function Header() {
</Tabs.ListContainer>
</Tabs>
<div className="absolute flex left-1/2 -translate-x-1/2">
<Typography.Heading
level={2}
align="center"
>
Porning
</Typography.Heading>
<Separator orientation="vertical" variant="tertiary" className="bg-white mx-4"/>
<Typography.Heading
level={2}
align="center"
>
CS2
</Typography.Heading>
<img className="h-12" src={porning} alt="Porning logo" />
</div>
<Button>Login</Button>
<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>
</div>
);

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

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

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

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

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

75
app/components/user.tsx Normal file
View File

@@ -0,0 +1,75 @@
import {
Button,
Avatar,
Typography,
Dropdown,
Label,
Separator,
Spinner,
} from "@heroui/react";
import { useState, type PropsWithChildren } from "react";
import { useAuth } from "~/hooks/auth";
import { Settings } from "./settings";
export default function User({ children }: PropsWithChildren) {
const { user, loading, login, logout } = useAuth();
const [showSettings, setShowSettings] = useState(false);
const toggleSettings = () => {setShowSettings(true)}
if (loading) {
return (
<Button isPending={true}>
<Spinner color="current" size="md" />
</Button>
);
}
if (user === null) {
return (
<Button onClick={() => login()}>
<Typography.Paragraph>{children}Login</Typography.Paragraph>
</Button>
);
}
return (
<>
<Settings show={showSettings} setShow={setShowSettings}/>
<Dropdown>
<Button className="pr-0">
{children}
<Typography.Paragraph>{user.name}</Typography.Paragraph>
<Avatar>
<Avatar.Image alt="Avatar" src={user.avatar}></Avatar.Image>
</Avatar>
</Button>
<Dropdown.Popover>
<Dropdown.Menu
onAction={(key) => handleAction(String(key), logout, toggleSettings)}
>
<Dropdown.Item id="settings" textValue="Settings">
<Label>Settings</Label>
</Dropdown.Item>
<Separator variant="tertiary" />
<Dropdown.Item
id="logout"
textValue="Logout"
variant="danger"
>
<Label>Logout</Label>
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown.Popover>
</Dropdown>
</>
);
}
function handleAction(key: string, logout: () => Promise<void>, toggleSettings: () => void) {
if (key === "settings") {
toggleSettings();
} else {
logout();
}
}

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

122
app/hooks/auth.tsx Normal file
View File

@@ -0,0 +1,122 @@
import {
useEffect,
useState,
createContext,
useContext,
type PropsWithChildren,
} from "react";
import { useLocation, useSearchParams } from "react-router";
import { useNotification } from "./notification";
import { useApi } from "./api";
type User = {
id: string;
steamid: string;
name: string;
avatar: string;
};
type AuthContextType = {
user: User | null;
loading: boolean;
login: () => void;
logout: () => Promise<void>;
update: () => Promise<boolean>;
};
const AuthContext = createContext<AuthContextType>({
user: null,
loading: false,
login: () => {},
logout: async () => {},
update: async () => {
return false;
},
});
export function AuthProvider({ children }: PropsWithChildren) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(false);
const [searchParams, setSeachParams] = useSearchParams();
const { notify } = useNotification();
const location = useLocation();
const { get } = useApi();
useEffect(() => {
const load = async () => {
if (searchParams.get("openid.ns") !== null) {
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();
setSeachParams({}, { replace: true });
await get(`login?${query}`);
}
if (await update()) {
notify(
{
type: "success",
loading: false,
title: "Login successful!",
message: "",
},
2000,
);
}
setLoading(false);
};
load();
}, []);
const login = () => {
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=${import.meta.env.VITE_BASE_URL}&openid.return_to=${import.meta.env.VITE_BASE_URL}${location.pathname}`,
);
};
const logout = async () => {
const [ok, _] = await get("logout");
if (ok) {
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 (
<AuthContext.Provider value={{ user, loading, login, logout, update }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
return useContext(AuthContext);
}

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

57
app/images/porning.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.3 MiB

22
app/pages/base.tsx Normal file
View File

@@ -0,0 +1,22 @@
import type { PropsWithChildren } from "react";
import { Footer } from "~/components/footer";
import Guard from "~/components/guard";
import { Header } from "~/components/header";
export default function Base({
children,
needsLogin = false,
fixedHeight = false,
}: PropsWithChildren<{ needsLogin?: boolean, fixedHeight?: boolean }>) {
return (
<>
<div className={`flex flex-col pt-20 min-h-dvh ${fixedHeight && "h-dvh"}`}>
<div className="flex flex-col flex-1 min-h-0">
{needsLogin ? <Guard>{children}</Guard> : children}
</div>
<Footer />
</div>
<Header />
</>
);
}

View File

@@ -1,5 +1,5 @@
import { Header } from "~/components/header"
import Base from "./base";
export function GamemodesPage() {
return (<main><Header /></main>)
}
return <Base needsLogin>GAMEMODES</Base>;
}

View File

@@ -1,20 +1,19 @@
import { Header } from "~/components/header";
import { Typography } from "@heroui/react";
import { Surface } from "@heroui/react";
import { Typography, Surface } from "@heroui/react";
import background from "../images/background.jpg";
import skins from "../images/skins.jpg";
import maps from "../images/maps.jpg";
import gamemodes from "../images/gamemodes.webp";
import Base from "./base";
export function HomePage() {
return (
<main>
<Base>
<img
className="fixed object-cover h-full w-full -z-10 blur-xs"
className="fixed top-0 object-cover h-full w-full -z-10 blur-xs"
src={background}
alt="Background"
/>
<div className="flex flex-col items-center pt-20">
<div className="flex flex-col items-center">
<Surface
variant="tertiary"
className="flex shadow-lg shadow-indigo-900 rounded-2xl p-0 w-7/10 my-4 justify-end"
@@ -26,10 +25,14 @@ export function HomePage() {
/>
<div className="absolute rounded-2xl w-2/3 right-1/8 h-full bg-linear-to-r from-surface-tertiary via-surface-tertiary to-transparent"></div>
<div className="absolute w-full h-full flex flex-col p-4">
<Typography.Heading className="w-full text-shadow-lg" level={1}>
<Typography.Heading
className="w-full text-shadow-lg"
level={1}
>
Inventory management, the way you like it.
</Typography.Heading>
<Typography.Paragraph className="absolute w-1/2 top-1/2 -translate-y-1/2 flex-1 align-middle">
<div className="flex-1" />
<Typography.Paragraph className="w-1/2 align-middle">
Take complete control of your loadout with our
advanced inventory system. Equip your favorite
weapon skins, gloves, and knives to match your
@@ -37,6 +40,7 @@ export function HomePage() {
restrictions, allowing you to fully customize your
setup and play exactly how you want.
</Typography.Paragraph>
<div className="flex-1" />
</div>
</Surface>
<Surface
@@ -56,7 +60,8 @@ export function HomePage() {
>
Infinite maps, infinite possibilities.
</Typography.Heading>
<Typography.Paragraph className="absolute w-1/2 top-1/2 -translate-y-1/2 flex-1 text-right align-middle">
<div className="flex-1" />
<Typography.Paragraph className="w-1/2 text-right align-middle">
Experience endless replayability through full Steam
Workshop integration. You have the freedom to add
custom maps directly to the server. Whether you want
@@ -64,6 +69,7 @@ export function HomePage() {
arenas to battle alongside your friends, the choices
are limitless.
</Typography.Paragraph>
<div className="flex-1" />
</div>
</Surface>
<Surface
@@ -77,10 +83,14 @@ export function HomePage() {
/>
<div className="absolute rounded-2xl w-2/3 right-1/8 h-full bg-linear-to-r from-surface-tertiary via-surface-tertiary to-transparent"></div>
<div className="absolute w-full h-full flex flex-col p-4">
<Typography.Heading className="w-full text-shadow-lg" level={1}>
<Typography.Heading
className="w-full text-shadow-lg"
level={1}
>
Different gamemodes, different ways to have fun.
</Typography.Heading>
<Typography.Paragraph className="absolute w-1/2 flex-1 align-middle top-1/2 -translate-y-1/2">
<div className="flex-1" />
<Typography.Paragraph className="w-1/2 align-middle">
Keep the gameplay fresh with a wide variety of
custom gamemodes designed for every playstyle.
Whether you are looking for intense competitive
@@ -88,10 +98,10 @@ export function HomePage() {
rulesets, there are plenty of options available to
ensure every session remains exciting.
</Typography.Paragraph>
<div className="flex-1" />
</div>
</Surface>
</div>
<Header />
</main>
</Base>
);
}

View File

@@ -1,5 +1,49 @@
import { Header } from "~/components/header"
import { useState } from "react";
import Base from "./base";
import { Button, Surface, Tabs } from "@heroui/react";
import { Inventory } from "~/components/inventory";
export function InventoryPage() {
return (<main><Header /></main>)
}
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>
);
}

View File

@@ -1,5 +1,5 @@
import { Header } from "~/components/header"
import Base from "./base"
export function MapsPage() {
return (<main><Header /></main>)
return <Base needsLogin>MAPS</Base>
}

View File

@@ -1,75 +1,85 @@
import {
isRouteErrorResponse,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
isRouteErrorResponse,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "react-router";
import type { Route } from "./+types/root";
import "./app.css";
import { AuthProvider } from "./hooks/auth";
import { NotificationProvider } from "./hooks/notification";
import { ApiProvider } from "./hooks/api";
export const links: Route.LinksFunction = () => [
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{
rel: "preconnect",
href: "https://fonts.gstatic.com",
crossOrigin: "anonymous",
},
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
},
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{
rel: "preconnect",
href: "https://fonts.gstatic.com",
crossOrigin: "anonymous",
},
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
},
];
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html className="dark" data-theme="dark" lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body className="bg-background text-foreground">
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
return (
<html className="dark" data-theme="dark" lang="en">
<head>
<meta charSet="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1"
/>
<Meta />
<Links />
</head>
<body className="bg-background text-foreground">
<NotificationProvider>
<ApiProvider>
<AuthProvider>{children}</AuthProvider>
</ApiProvider>
</NotificationProvider>
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
return <Outlet />;
return <Outlet />;
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = "Oops!";
let details = "An unexpected error occurred.";
let stack: string | undefined;
let message = "Oops!";
let details = "An unexpected error occurred.";
let stack: string | undefined;
if (isRouteErrorResponse(error)) {
message = error.status === 404 ? "404" : "Error";
details =
error.status === 404
? "The requested page could not be found."
: error.statusText || details;
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message;
stack = error.stack;
}
if (isRouteErrorResponse(error)) {
message = error.status === 404 ? "404" : "Error";
details =
error.status === 404
? "The requested page could not be found."
: error.statusText || details;
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message;
stack = error.stack;
}
return (
<main className="pt-16 p-4 container mx-auto">
<h1>{message}</h1>
<p>{details}</p>
{stack && (
<pre className="w-full p-4 overflow-x-auto">
<code>{stack}</code>
</pre>
)}
</main>
);
return (
<main className="pt-16 p-4 container mx-auto">
<h1>{message}</h1>
<p>{details}</p>
{stack && (
<pre className="w-full p-4 overflow-x-auto">
<code>{stack}</code>
</pre>
)}
</main>
);
}

28
app/types/skin.ts Normal file
View 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);
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 44 KiB