Compare commits
58 Commits
ef3c940bab
...
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 | |||
| acbb878811 | |||
| 852331f909 | |||
| c6b6d316cf | |||
| c7530dcbab | |||
| 6aab99ab8f | |||
| 5d6aa0a569 | |||
| d5b9848b84 | |||
| 9359fdb016 | |||
| 464c1a7205 | |||
| 7b8ff5a16f | |||
| 2f73a330ab | |||
| e94e5a949a | |||
| f549a8a91b | |||
| 3c80cf56a0 | |||
| 471a3bb1e8 |
2
.gitignore
vendored
@@ -6,3 +6,5 @@ pnpm-lock.yaml
|
||||
# React Router
|
||||
/.react-router/
|
||||
/build/
|
||||
|
||||
public/skins/
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Varela+Round&display=swap');
|
||||
@import "tailwindcss";
|
||||
@import "@heroui/styles";
|
||||
|
||||
@theme {
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif,
|
||||
--font-sans: "Varela Round", "Inter", ui-sans-serif, system-ui, sans-serif,
|
||||
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
@apply bg-white dark:bg-gray-950;
|
||||
/*@apply bg-white dark:bg-gray-950;*/
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
color-scheme: dark;
|
||||
|
||||
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
@@ -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
@@ -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
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
55
app/components/header.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
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();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="flex w-full fixed top-0">
|
||||
<Surface
|
||||
className="flex shadow-lg shadow-indigo-900 w-full p-2 m-2 rounded-3xl items-center justify-between"
|
||||
variant="default"
|
||||
>
|
||||
<Tabs
|
||||
selectedKey={location.pathname}
|
||||
onSelectionChange={(key) => navigate(String(key))}
|
||||
>
|
||||
<Tabs.ListContainer>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab id="/">
|
||||
Home
|
||||
<Tabs.Indicator />
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab id="/inventory">
|
||||
Inventory
|
||||
<Tabs.Indicator />
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab id="/maps">
|
||||
Maps
|
||||
<Tabs.Indicator />
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab id="/gamemodes">
|
||||
Gamemodes
|
||||
<Tabs.Indicator />
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
</Tabs.ListContainer>
|
||||
</Tabs>
|
||||
<div className="absolute flex left-1/2 -translate-x-1/2">
|
||||
<img className="h-12" src={porning} alt="Porning logo" />
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
BIN
app/images/background.jpg
Normal file
|
After Width: | Height: | Size: 730 KiB |
BIN
app/images/gamemodes.webp
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
app/images/maps.jpg
Normal file
|
After Width: | Height: | Size: 683 KiB |
57
app/images/porning.svg
Normal file
|
After Width: | Height: | Size: 5.3 MiB |
BIN
app/images/skins.jpg
Normal file
|
After Width: | Height: | Size: 316 KiB |
22
app/pages/base.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
5
app/pages/gamemodes.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import Base from "./base";
|
||||
|
||||
export function GamemodesPage() {
|
||||
return <Base needsLogin>GAMEMODES</Base>;
|
||||
}
|
||||
107
app/pages/home.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
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 (
|
||||
<Base>
|
||||
<img
|
||||
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">
|
||||
<Surface
|
||||
variant="tertiary"
|
||||
className="flex shadow-lg shadow-indigo-900 rounded-2xl p-0 w-7/10 my-4 justify-end"
|
||||
>
|
||||
<img
|
||||
className="h-[35dvh] rounded-r-2xl"
|
||||
src={skins}
|
||||
alt="Knife in hand inpect"
|
||||
/>
|
||||
<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}
|
||||
>
|
||||
Inventory management, the way you like it.
|
||||
</Typography.Heading>
|
||||
<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
|
||||
unique style on the server. There are no
|
||||
restrictions, allowing you to fully customize your
|
||||
setup and play exactly how you want.
|
||||
</Typography.Paragraph>
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
</Surface>
|
||||
<Surface
|
||||
variant="tertiary"
|
||||
className="flex shadow-lg shadow-indigo-900 rounded-2xl p-0 w-7/10 my-4 justify-begin"
|
||||
>
|
||||
<img
|
||||
className="h-[35dvh] rounded-l-2xl"
|
||||
src={maps}
|
||||
alt="Knife in hand inpect"
|
||||
/>
|
||||
<div className="absolute rounded-2xl w-2/3 left-1/8 h-full bg-linear-to-r from-transparent via-surface-tertiary to-surface-tertiary"></div>
|
||||
<div className="absolute h-full flex flex-col items-end w-full p-4 text-right">
|
||||
<Typography.Heading
|
||||
className="w-full text-right text-shadow-lg"
|
||||
level={1}
|
||||
>
|
||||
Infinite maps, infinite possibilities.
|
||||
</Typography.Heading>
|
||||
<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
|
||||
to practice solo on a training map or load up unique
|
||||
arenas to battle alongside your friends, the choices
|
||||
are limitless.
|
||||
</Typography.Paragraph>
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
</Surface>
|
||||
<Surface
|
||||
variant="tertiary"
|
||||
className="flex shadow-lg shadow-indigo-900 rounded-2xl p-0 w-7/10 my-4 justify-end"
|
||||
>
|
||||
<img
|
||||
className="h-[35dvh] rounded-r-2xl"
|
||||
src={gamemodes}
|
||||
alt="Knife in hand inpect"
|
||||
/>
|
||||
<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}
|
||||
>
|
||||
Different gamemodes, different ways to have fun.
|
||||
</Typography.Heading>
|
||||
<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
|
||||
matches, casual warm-ups, or entirely unique
|
||||
rulesets, there are plenty of options available to
|
||||
ensure every session remains exciting.
|
||||
</Typography.Paragraph>
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
</Surface>
|
||||
</div>
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
49
app/pages/inventory.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useState } from "react";
|
||||
import Base from "./base";
|
||||
import { Button, Surface, Tabs } from "@heroui/react";
|
||||
import { Inventory } from "~/components/inventory";
|
||||
|
||||
export function InventoryPage() {
|
||||
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>
|
||||
);
|
||||
}
|
||||
5
app/pages/maps.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import Base from "./base"
|
||||
|
||||
export function MapsPage() {
|
||||
return <Base needsLogin>MAPS</Base>
|
||||
}
|
||||
18
app/root.tsx
@@ -9,6 +9,9 @@ import {
|
||||
|
||||
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" },
|
||||
@@ -25,15 +28,22 @@ export const links: Route.LinksFunction = () => [
|
||||
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html className="dark" data-theme="dark" lang="en">
|
||||
<head>
|
||||
<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 />
|
||||
<Links />
|
||||
</head>
|
||||
<body>
|
||||
{children}
|
||||
<body className="bg-background text-foreground">
|
||||
<NotificationProvider>
|
||||
<ApiProvider>
|
||||
<AuthProvider>{children}</AuthProvider>
|
||||
</ApiProvider>
|
||||
</NotificationProvider>
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</body>
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { type RouteConfig, index } from "@react-router/dev/routes";
|
||||
import { type RouteConfig, index, route } from "@react-router/dev/routes";
|
||||
|
||||
export default [index("routes/home.tsx")] satisfies RouteConfig;
|
||||
export default [index("routes/home.tsx"),
|
||||
route("/inventory", "routes/inventory.tsx"),
|
||||
route("/maps", "routes/maps.tsx"),
|
||||
route("/gamemodes", "routes/gamemodes.tsx")
|
||||
] satisfies RouteConfig;
|
||||
|
||||
13
app/routes/gamemodes.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { Route } from "./+types/gamemodes";
|
||||
import { GamemodesPage } from "../pages/gamemodes";
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Porning CS2" },
|
||||
{ name: "description", content: "Welcome to Porning's CS2 server!" },
|
||||
];
|
||||
}
|
||||
|
||||
export default function Gamemodes() {
|
||||
return <GamemodesPage />;
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { Route } from "./+types/home";
|
||||
import { Welcome } from "../welcome/welcome";
|
||||
import { HomePage } from "../pages/home";
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "New React Router App" },
|
||||
{ name: "description", content: "Welcome to React Router!" },
|
||||
{ title: "Porning CS2" },
|
||||
{ name: "description", content: "Welcome to Porning's CS2 server!" },
|
||||
];
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
return <Welcome />;
|
||||
return <HomePage />;
|
||||
}
|
||||
|
||||
13
app/routes/inventory.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { Route } from "./+types/inventory";
|
||||
import { InventoryPage } from "../pages/inventory";
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Porning CS2" },
|
||||
{ name: "description", content: "Welcome to Porning's CS2 server!" },
|
||||
];
|
||||
}
|
||||
|
||||
export default function Inventory() {
|
||||
return <InventoryPage />;
|
||||
}
|
||||
13
app/routes/maps.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { Route } from "./+types/maps";
|
||||
import { MapsPage } from "../pages/maps";
|
||||
|
||||
export function meta({}: Route.MetaArgs) {
|
||||
return [
|
||||
{ title: "Porning CS2" },
|
||||
{ name: "description", content: "Welcome to Porning's CS2 server!" },
|
||||
];
|
||||
}
|
||||
|
||||
export default function Maps() {
|
||||
return <MapsPage />;
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
<svg width="1080" height="174" viewBox="0 0 1080 174" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M231.527 86.9999C231.527 94.9642 228.297 102.173 223.067 107.387C217.837 112.606 210.614 115.835 202.634 115.835C194.654 115.835 187.43 119.059 182.206 124.278C176.977 129.498 173.741 136.707 173.741 144.671C173.741 152.635 170.51 159.844 165.281 165.058C160.051 170.277 152.828 173.507 144.847 173.507C136.867 173.507 129.644 170.277 124.42 165.058C119.19 159.844 115.954 152.635 115.954 144.671C115.954 136.707 119.19 129.498 124.42 124.278C129.644 119.059 136.867 115.835 144.847 115.835C152.828 115.835 160.051 112.606 165.281 107.387C170.51 102.173 173.741 94.9642 173.741 86.9999C173.741 71.0711 160.808 58.1643 144.847 58.1643C136.867 58.1643 129.644 54.9347 124.42 49.7155C119.19 44.502 115.954 37.2931 115.954 29.3287C115.954 21.3643 119.19 14.1555 124.42 8.93622C129.644 3.71698 136.867 0.493164 144.847 0.493164C160.808 0.493164 173.741 13.4 173.741 29.3287C173.741 37.2931 176.977 44.502 182.206 49.7155C187.43 54.9347 194.654 58.1643 202.634 58.1643C218.594 58.1643 231.527 71.0711 231.527 86.9999Z" fill="#F44250"/>
|
||||
<path d="M115.954 86.9996C115.954 71.0742 103.018 58.1641 87.061 58.1641C71.1037 58.1641 58.1677 71.0742 58.1677 86.9996C58.1677 102.925 71.1037 115.835 87.061 115.835C103.018 115.835 115.954 102.925 115.954 86.9996Z" fill="white"/>
|
||||
<path d="M58.1676 144.671C58.1676 128.745 45.2316 115.835 29.2743 115.835C13.317 115.835 0.381104 128.745 0.381104 144.671C0.381104 160.596 13.317 173.506 29.2743 173.506C45.2316 173.506 58.1676 160.596 58.1676 144.671Z" fill="white"/>
|
||||
<path d="M289.314 144.671C289.314 128.745 276.378 115.835 260.42 115.835C244.463 115.835 231.527 128.745 231.527 144.671C231.527 160.596 244.463 173.506 260.42 173.506C276.378 173.506 289.314 160.596 289.314 144.671Z" fill="white"/>
|
||||
<g clip-path="url(#clip0_202_2131)">
|
||||
<path d="M562.482 173.247C524.388 173.247 498.363 147.49 498.363 110.468C498.363 73.4455 524.388 47.6885 562.482 47.6885C600.576 47.6885 626.869 73.7135 626.869 110.468C626.869 147.222 600.576 173.247 562.482 173.247ZM562.482 144.007C579.385 144.007 587.703 130.319 587.703 110.468C587.703 90.6168 579.385 76.9289 562.482 76.9289C545.579 76.9289 537.529 90.6168 537.529 110.468C537.529 130.319 545.311 144.007 562.482 144.007Z" fill="white"/>
|
||||
<path d="M833.64 141.116C824.217 141.116 819.237 136.684 819.237 126.156V74.8983H851.928V47.7792H819.237V1.15527H791.75L786.1 26.1978C783.343 36.4805 780.82 42.822 773.897 46.0821C773.105 46.4506 771.129 46.9976 769.409 47.3884C768.014 47.701 766.596 47.8573 765.167 47.8573H752.338V47.9243H734.832C723.578 47.9243 714.445 57.0459 714.445 68.3111V111.552C714.445 130.599 707.199 142.668 692.719 142.668C678.238 142.668 672.868 133.279 672.868 116.375V47.9243H634.249V125.765C634.249 151.254 644.442 173.248 676.63 173.248C691.915 173.248 703.895 167.231 711.096 157.182C712.145 155.72 714.445 156.49 714.445 158.276V170.022H753.332V83.8412C753.332 78.8953 757.34 74.8871 762.286 74.8871H779.882V136.952C779.882 164.663 797.89 173.248 817.842 173.248C833.908 173.248 844.436 169.374 853.58 162.441V136.126C846.1 139.453 839.725 141.116 833.629 141.116H833.64Z" fill="white"/>
|
||||
<path d="M981.561 130.865C975.387 157.962 954.197 173.258 923.07 173.258C885.243 173.258 858.415 150.18 858.415 112.354C858.415 74.5281 885.779 47.6992 922.266 47.6992C961.699 47.6992 982.365 74.796 982.365 107.263V113.884H896.509C894.555 135.711 909.382 144.017 924.409 144.017C937.829 144.017 946.136 138.915 950.434 127.918L981.561 130.865ZM945.075 94.9372C944.271 83.1361 936.757 75.8567 921.998 75.8567C906.434 75.8567 899.188 82.321 897.045 94.9372H945.064H945.075Z" fill="white"/>
|
||||
<path d="M1076.24 85.7486C1070.06 82.2652 1064.17 80.9142 1055.85 80.9142C1039.75 80.9142 1029.02 90.0358 1029.02 110.691V170.02H990.393V47.9225H1029.02V64.3235C1029.02 65.4623 1030.54 65.8195 1031.05 64.8035C1036.68 53.5718 1047.91 44.707 1062.03 44.707C1069.27 44.707 1075.45 46.8507 1078.66 49.5414L1076.25 85.7597L1076.24 85.7486Z" fill="white"/>
|
||||
<path d="M547.32 31.5345V23.9983H522.457V31.5345H515.378V2.23828H542.14C553.562 2.23828 554.365 2.95282 554.365 13.1239C554.365 17.4111 553.472 18.5611 551.329 19.6553L549.408 20.6378L551.317 21.6426C553.595 22.8372 554.365 23.2391 554.365 30.0273V31.5345H547.332H547.32ZM522.457 18.3601H547.32V7.88763H522.457V18.349V18.3601Z" fill="white"/>
|
||||
<path d="M578.493 2.23828H610.826V7.90996H580.067V14.5083H610.011V19.2868H580.067V25.8963H610.837V31.501L578.504 31.5345C575.344 31.5345 572.787 28.9778 572.787 25.8293V7.95462C572.787 4.80617 575.344 2.24945 578.493 2.24945V2.23828Z" fill="white"/>
|
||||
<path d="M655.562 31.5345L653.151 26.3429H633.746L631.335 31.5345H624.58L637.006 4.75034C637.71 3.22078 639.262 2.23828 640.936 2.23828H645.927C647.613 2.23828 649.154 3.22078 649.857 4.75034L662.283 31.5345H655.529H655.562ZM643.46 8.06627C642.712 8.06627 642.053 8.49053 641.729 9.17158L635.968 21.5756H650.94L645.19 9.17158C644.878 8.49053 644.208 8.06627 643.46 8.06627Z" fill="white"/>
|
||||
<path d="M694.862 32.4153C676.05 32.4153 675.313 32.4153 675.313 16.8852C675.313 1.35505 676.05 1.36621 694.862 1.36621C711.721 1.36621 713.764 2.06959 714.244 10.5325H707.333V7.01556H682.168V26.766H707.333V23.2714H714.244C713.775 31.7119 711.721 32.4153 694.862 32.4153Z" fill="white"/>
|
||||
<path d="M745.282 31.5345V7.02795H729.16V2.23828H768.147V7.02795H752.025V31.5345H745.282Z" fill="white"/>
|
||||
<path d="M454.419 169.819C450.935 165.264 448.792 154.814 447.452 137.397C446.112 118.104 437.806 113.817 422.532 113.817H392.254V169.83H347.494V0.986328H432.715C476.391 0.986328 498.106 21.6187 498.106 54.5882C498.106 79.2399 482.833 95.3171 462.201 98.0078C479.618 101.491 489.8 111.405 491.675 130.966C494.087 156.154 494.891 163.656 500.518 169.819H454.419ZM424.676 78.704C443.969 78.704 453.615 73.8808 453.615 58.3395C453.615 44.6739 443.969 37.4392 424.676 37.4392H392.254V78.7152H424.676V78.704Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_202_2131">
|
||||
<rect width="731.156" height="172.261" fill="white" transform="translate(347.494 0.986328)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 6.0 KiB |
@@ -1,23 +0,0 @@
|
||||
<svg width="1080" height="174" viewBox="0 0 1080 174" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M231.527 86.9999C231.527 94.9642 228.297 102.173 223.067 107.387C217.837 112.606 210.614 115.835 202.634 115.835C194.654 115.835 187.43 119.059 182.206 124.278C176.977 129.498 173.741 136.707 173.741 144.671C173.741 152.635 170.51 159.844 165.281 165.058C160.051 170.277 152.828 173.507 144.847 173.507C136.867 173.507 129.644 170.277 124.42 165.058C119.19 159.844 115.954 152.635 115.954 144.671C115.954 136.707 119.19 129.498 124.42 124.278C129.644 119.059 136.867 115.835 144.847 115.835C152.828 115.835 160.051 112.606 165.281 107.387C170.51 102.173 173.741 94.9642 173.741 86.9999C173.741 71.0711 160.808 58.1643 144.847 58.1643C136.867 58.1643 129.644 54.9347 124.42 49.7155C119.19 44.502 115.954 37.2931 115.954 29.3287C115.954 21.3643 119.19 14.1555 124.42 8.93622C129.644 3.71698 136.867 0.493164 144.847 0.493164C160.808 0.493164 173.741 13.4 173.741 29.3287C173.741 37.2931 176.977 44.502 182.206 49.7155C187.43 54.9347 194.654 58.1643 202.634 58.1643C218.594 58.1643 231.527 71.0711 231.527 86.9999Z" fill="#F44250"/>
|
||||
<path d="M115.954 86.9996C115.954 71.0742 103.018 58.1641 87.0608 58.1641C71.1035 58.1641 58.1676 71.0742 58.1676 86.9996C58.1676 102.925 71.1035 115.835 87.0608 115.835C103.018 115.835 115.954 102.925 115.954 86.9996Z" fill="#121212"/>
|
||||
<path d="M58.1676 144.671C58.1676 128.745 45.2316 115.835 29.2743 115.835C13.317 115.835 0.381104 128.745 0.381104 144.671C0.381104 160.596 13.317 173.506 29.2743 173.506C45.2316 173.506 58.1676 160.596 58.1676 144.671Z" fill="#121212"/>
|
||||
<path d="M289.313 144.671C289.313 128.745 276.378 115.835 260.42 115.835C244.463 115.835 231.527 128.745 231.527 144.671C231.527 160.596 244.463 173.506 260.42 173.506C276.378 173.506 289.313 160.596 289.313 144.671Z" fill="#121212"/>
|
||||
<g clip-path="url(#clip0_171_1761)">
|
||||
<path d="M562.482 173.247C524.388 173.247 498.363 147.49 498.363 110.468C498.363 73.4455 524.388 47.6885 562.482 47.6885C600.576 47.6885 626.869 73.7135 626.869 110.468C626.869 147.222 600.576 173.247 562.482 173.247ZM562.482 144.007C579.386 144.007 587.703 130.319 587.703 110.468C587.703 90.6168 579.386 76.9289 562.482 76.9289C545.579 76.9289 537.529 90.6168 537.529 110.468C537.529 130.319 545.311 144.007 562.482 144.007Z" fill="#121212"/>
|
||||
<path d="M833.64 141.116C824.217 141.116 819.237 136.684 819.237 126.156V74.8983H851.928V47.7792H819.237V1.15527H791.75L786.1 26.1978C783.343 36.4805 780.82 42.822 773.897 46.0821C773.105 46.4506 771.129 46.9976 769.409 47.3884C768.014 47.701 766.596 47.8573 765.167 47.8573H752.338V47.9243H734.832C723.578 47.9243 714.445 57.0459 714.445 68.3111V111.552C714.445 130.599 707.199 142.668 692.719 142.668C678.238 142.668 672.868 133.279 672.868 116.375V47.9243H634.249V125.765C634.249 151.254 644.442 173.248 676.63 173.248C691.915 173.248 703.895 167.231 711.096 157.182C712.145 155.72 714.445 156.49 714.445 158.276V170.022H753.332V83.8412C753.332 78.8953 757.34 74.8871 762.286 74.8871H779.882V136.952C779.882 164.663 797.89 173.248 817.842 173.248C833.908 173.248 844.436 169.374 853.58 162.441V136.126C846.1 139.453 839.725 141.116 833.629 141.116H833.64Z" fill="#121212"/>
|
||||
<path d="M981.561 130.865C975.387 157.962 954.197 173.258 923.07 173.258C885.243 173.258 858.415 150.18 858.415 112.354C858.415 74.5281 885.779 47.6992 922.266 47.6992C961.699 47.6992 982.365 74.796 982.365 107.263V113.884H896.509C894.555 135.711 909.382 144.017 924.409 144.017C937.829 144.017 946.136 138.915 950.434 127.918L981.561 130.865ZM945.075 94.9372C944.271 83.1361 936.757 75.8567 921.998 75.8567C906.434 75.8567 899.188 82.321 897.045 94.9372H945.064H945.075Z" fill="#121212"/>
|
||||
<path d="M1076.24 85.7486C1070.06 82.2652 1064.17 80.9142 1055.85 80.9142C1039.75 80.9142 1029.02 90.0358 1029.02 110.691V170.02H990.393V47.9225H1029.02V64.3235C1029.02 65.4623 1030.54 65.8195 1031.05 64.8035C1036.68 53.5718 1047.91 44.707 1062.03 44.707C1069.27 44.707 1075.45 46.8507 1078.66 49.5414L1076.25 85.7597L1076.24 85.7486Z" fill="#121212"/>
|
||||
<path d="M547.321 31.5345V23.9983H522.457V31.5345H515.378V2.23828H542.14C553.562 2.23828 554.366 2.95282 554.366 13.1239C554.366 17.4111 553.472 18.5611 551.329 19.6553L549.408 20.6378L551.318 21.6426C553.595 22.8372 554.366 23.2391 554.366 30.0273V31.5345H547.332H547.321ZM522.457 18.3601H547.321V7.88763H522.457V18.349V18.3601Z" fill="#121212"/>
|
||||
<path d="M578.493 2.23828H610.826V7.90996H580.067V14.5083H610.011V19.2868H580.067V25.8963H610.837V31.501L578.504 31.5345C575.344 31.5345 572.787 28.9778 572.787 25.8293V7.95462C572.787 4.80617 575.344 2.24945 578.493 2.24945V2.23828Z" fill="#121212"/>
|
||||
<path d="M655.562 31.5345L653.151 26.3429H633.747L631.335 31.5345H624.58L637.007 4.75034C637.71 3.22078 639.262 2.23828 640.937 2.23828H645.927C647.613 2.23828 649.154 3.22078 649.857 4.75034L662.284 31.5345H655.529H655.562ZM643.46 8.06627C642.712 8.06627 642.053 8.49053 641.729 9.17158L635.968 21.5756H650.94L645.19 9.17158C644.878 8.49053 644.208 8.06627 643.46 8.06627Z" fill="#121212"/>
|
||||
<path d="M694.862 32.4153C676.05 32.4153 675.313 32.4153 675.313 16.8852C675.313 1.35505 676.05 1.36621 694.862 1.36621C711.721 1.36621 713.764 2.06959 714.244 10.5325H707.333V7.01556H682.168V26.766H707.333V23.2714H714.244C713.775 31.7119 711.721 32.4153 694.862 32.4153Z" fill="#121212"/>
|
||||
<path d="M745.282 31.5345V7.02795H729.16V2.23828H768.148V7.02795H752.026V31.5345H745.282Z" fill="#121212"/>
|
||||
<path d="M454.419 169.819C450.935 165.264 448.792 154.814 447.452 137.397C446.112 118.104 437.806 113.817 422.532 113.817H392.254V169.83H347.494V0.986328H432.715C476.391 0.986328 498.106 21.6187 498.106 54.5882C498.106 79.2399 482.833 95.3171 462.201 98.0078C479.618 101.491 489.8 111.405 491.676 130.966C494.087 156.154 494.891 163.656 500.518 169.819H454.419ZM424.676 78.704C443.969 78.704 453.615 73.8808 453.615 58.3395C453.615 44.6739 443.969 37.4392 424.676 37.4392H392.254V78.7152H424.676V78.704Z" fill="#121212"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_171_1761">
|
||||
<rect width="731.156" height="172.261" fill="white" transform="translate(347.494 0.986328)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 6.0 KiB |
@@ -1,89 +0,0 @@
|
||||
import logoDark from "./logo-dark.svg";
|
||||
import logoLight from "./logo-light.svg";
|
||||
|
||||
export function Welcome() {
|
||||
return (
|
||||
<main className="flex items-center justify-center pt-16 pb-4">
|
||||
<div className="flex-1 flex flex-col items-center gap-16 min-h-0">
|
||||
<header className="flex flex-col items-center gap-9">
|
||||
<div className="w-[500px] max-w-[100vw] p-4">
|
||||
<img
|
||||
src={logoLight}
|
||||
alt="React Router"
|
||||
className="block w-full dark:hidden"
|
||||
/>
|
||||
<img
|
||||
src={logoDark}
|
||||
alt="React Router"
|
||||
className="hidden w-full dark:block"
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
<div className="max-w-[300px] w-full space-y-6 px-4">
|
||||
<nav className="rounded-3xl border border-gray-200 p-6 dark:border-gray-700 space-y-4">
|
||||
<p className="leading-6 text-gray-700 dark:text-gray-200 text-center">
|
||||
What's next?
|
||||
</p>
|
||||
<ul>
|
||||
{resources.map(({ href, text, icon }) => (
|
||||
<li key={href}>
|
||||
<a
|
||||
className="group flex items-center gap-3 self-stretch p-3 leading-normal text-blue-700 hover:underline dark:text-blue-500"
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{icon}
|
||||
{text}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const resources = [
|
||||
{
|
||||
href: "https://reactrouter.com/docs",
|
||||
text: "React Router Docs",
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
className="stroke-gray-600 group-hover:stroke-current dark:stroke-gray-300"
|
||||
>
|
||||
<path
|
||||
d="M9.99981 10.0751V9.99992M17.4688 17.4688C15.889 19.0485 11.2645 16.9853 7.13958 12.8604C3.01467 8.73546 0.951405 4.11091 2.53116 2.53116C4.11091 0.951405 8.73546 3.01467 12.8604 7.13958C16.9853 11.2645 19.0485 15.889 17.4688 17.4688ZM2.53132 17.4688C0.951566 15.8891 3.01483 11.2645 7.13974 7.13963C11.2647 3.01471 15.8892 0.951453 17.469 2.53121C19.0487 4.11096 16.9854 8.73551 12.8605 12.8604C8.73562 16.9853 4.11107 19.0486 2.53132 17.4688Z"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "https://rmx.as/discord",
|
||||
text: "Join Discord",
|
||||
icon: (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="20"
|
||||
viewBox="0 0 24 20"
|
||||
fill="none"
|
||||
className="stroke-gray-600 group-hover:stroke-current dark:stroke-gray-300"
|
||||
>
|
||||
<path
|
||||
d="M15.0686 1.25995L14.5477 1.17423L14.2913 1.63578C14.1754 1.84439 14.0545 2.08275 13.9422 2.31963C12.6461 2.16488 11.3406 2.16505 10.0445 2.32014C9.92822 2.08178 9.80478 1.84975 9.67412 1.62413L9.41449 1.17584L8.90333 1.25995C7.33547 1.51794 5.80717 1.99419 4.37748 2.66939L4.19 2.75793L4.07461 2.93019C1.23864 7.16437 0.46302 11.3053 0.838165 15.3924L0.868838 15.7266L1.13844 15.9264C2.81818 17.1714 4.68053 18.1233 6.68582 18.719L7.18892 18.8684L7.50166 18.4469C7.96179 17.8268 8.36504 17.1824 8.709 16.4944L8.71099 16.4904C10.8645 17.0471 13.128 17.0485 15.2821 16.4947C15.6261 17.1826 16.0293 17.8269 16.4892 18.4469L16.805 18.8725L17.3116 18.717C19.3056 18.105 21.1876 17.1751 22.8559 15.9238L23.1224 15.724L23.1528 15.3923C23.5873 10.6524 22.3579 6.53306 19.8947 2.90714L19.7759 2.73227L19.5833 2.64518C18.1437 1.99439 16.6386 1.51826 15.0686 1.25995ZM16.6074 10.7755L16.6074 10.7756C16.5934 11.6409 16.0212 12.1444 15.4783 12.1444C14.9297 12.1444 14.3493 11.6173 14.3493 10.7877C14.3493 9.94885 14.9378 9.41192 15.4783 9.41192C16.0471 9.41192 16.6209 9.93851 16.6074 10.7755ZM8.49373 12.1444C7.94513 12.1444 7.36471 11.6173 7.36471 10.7877C7.36471 9.94885 7.95323 9.41192 8.49373 9.41192C9.06038 9.41192 9.63892 9.93712 9.6417 10.7815C9.62517 11.6239 9.05462 12.1444 8.49373 12.1444Z"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -9,6 +9,8 @@
|
||||
"typecheck": "react-router typegen && tsc"
|
||||
},
|
||||
"dependencies": {
|
||||
"@heroui/react": "^3.2.2",
|
||||
"@heroui/styles": "^3.2.2",
|
||||
"@react-router/node": "8.0.0",
|
||||
"@react-router/serve": "8.0.0",
|
||||
"isbot": "^5.1.36",
|
||||
|
||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 44 KiB |