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
/.react-router/ /.react-router/
/build/ /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 { 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";
import porning from "../images/porning.svg";
import User from "./user";
export function Header() { export function Header() {
const location = useLocation(); const location = useLocation();
@@ -39,21 +41,14 @@ export function Header() {
</Tabs.ListContainer> </Tabs.ListContainer>
</Tabs> </Tabs>
<div className="absolute flex left-1/2 -translate-x-1/2"> <div className="absolute flex left-1/2 -translate-x-1/2">
<Typography.Heading <img className="h-12" src={porning} alt="Porning logo" />
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>
</div> </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> </Surface>
</div> </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() { 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, Surface } from "@heroui/react";
import { Typography } from "@heroui/react";
import { Surface } from "@heroui/react";
import background from "../images/background.jpg"; import background from "../images/background.jpg";
import skins from "../images/skins.jpg"; import skins from "../images/skins.jpg";
import maps from "../images/maps.jpg"; import maps from "../images/maps.jpg";
import gamemodes from "../images/gamemodes.webp"; import gamemodes from "../images/gamemodes.webp";
import Base from "./base";
export function HomePage() { export function HomePage() {
return ( return (
<main> <Base>
<img <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} src={background}
alt="Background" alt="Background"
/> />
<div className="flex flex-col items-center pt-20"> <div className="flex flex-col items-center">
<Surface <Surface
variant="tertiary" variant="tertiary"
className="flex shadow-lg shadow-indigo-900 rounded-2xl p-0 w-7/10 my-4 justify-end" 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 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"> <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. Inventory management, the way you like it.
</Typography.Heading> </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 Take complete control of your loadout with our
advanced inventory system. Equip your favorite advanced inventory system. Equip your favorite
weapon skins, gloves, and knives to match your weapon skins, gloves, and knives to match your
@@ -37,6 +40,7 @@ export function HomePage() {
restrictions, allowing you to fully customize your restrictions, allowing you to fully customize your
setup and play exactly how you want. setup and play exactly how you want.
</Typography.Paragraph> </Typography.Paragraph>
<div className="flex-1" />
</div> </div>
</Surface> </Surface>
<Surface <Surface
@@ -56,7 +60,8 @@ export function HomePage() {
> >
Infinite maps, infinite possibilities. Infinite maps, infinite possibilities.
</Typography.Heading> </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 Experience endless replayability through full Steam
Workshop integration. You have the freedom to add Workshop integration. You have the freedom to add
custom maps directly to the server. Whether you want custom maps directly to the server. Whether you want
@@ -64,6 +69,7 @@ export function HomePage() {
arenas to battle alongside your friends, the choices arenas to battle alongside your friends, the choices
are limitless. are limitless.
</Typography.Paragraph> </Typography.Paragraph>
<div className="flex-1" />
</div> </div>
</Surface> </Surface>
<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 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"> <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. Different gamemodes, different ways to have fun.
</Typography.Heading> </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 Keep the gameplay fresh with a wide variety of
custom gamemodes designed for every playstyle. custom gamemodes designed for every playstyle.
Whether you are looking for intense competitive Whether you are looking for intense competitive
@@ -88,10 +98,10 @@ export function HomePage() {
rulesets, there are plenty of options available to rulesets, there are plenty of options available to
ensure every session remains exciting. ensure every session remains exciting.
</Typography.Paragraph> </Typography.Paragraph>
<div className="flex-1" />
</div> </div>
</Surface> </Surface>
</div> </div>
<Header /> </Base>
</main>
); );
} }

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() { 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() { export function MapsPage() {
return (<main><Header /></main>) return <Base needsLogin>MAPS</Base>
} }

View File

@@ -9,6 +9,9 @@ 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 { 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" },
@@ -28,12 +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">
{children} <NotificationProvider>
<ApiProvider>
<AuthProvider>{children}</AuthProvider>
</ApiProvider>
</NotificationProvider>
<ScrollRestoration /> <ScrollRestoration />
<Scripts /> <Scripts />
</body> </body>

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