first layout draft
This commit is contained in:
parent
62055855e5
commit
8319de0812
|
|
@ -12,12 +12,6 @@ export type Endpoint<TInput, TResponse> = {
|
|||
handler: (input: TInput, context: RequestContext) => Promise<TResponse>;
|
||||
};
|
||||
|
||||
export type Request<TEndpoint extends Endpoint<any, any>> = {
|
||||
method: "POST";
|
||||
url: string;
|
||||
body: z.infer<TEndpoint["validate"]>;
|
||||
};
|
||||
|
||||
export const createEndpoint = <TInput, TResponse>(
|
||||
validate: z.ZodType<TInput>,
|
||||
handler: (input: TInput, context: RequestContext) => Promise<TResponse>,
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ export const gameController = createController({
|
|||
if (game.finished) return gameState;
|
||||
return serverToClientGame(gameState);
|
||||
}),
|
||||
createGame: createEndpoint(z.undefined(), async (_, { user, db }) => {
|
||||
createGame: createEndpoint(z.null(), async (_, { user, db }) => {
|
||||
if (!user) throw new UnauthorizedError("Unauthorized");
|
||||
const uuid = crypto.randomUUID() as string;
|
||||
const newGame: ServerGame = game.createGame({
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { ServerWebSocket } from "bun";
|
||||
import { handleRequest } from "./router";
|
||||
|
||||
const allowCors = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
|
|
@ -6,7 +6,6 @@ const allowCors = {
|
|||
"Access-Control-Allow-Headers": "Content-Type",
|
||||
};
|
||||
|
||||
const userName = new WeakMap<ServerWebSocket<unknown>, string>();
|
||||
const server = Bun.serve({
|
||||
async fetch(request: Request) {
|
||||
if (request.method === "OPTIONS") {
|
||||
|
|
@ -22,10 +21,10 @@ const server = Bun.serve({
|
|||
if (typeof message !== "string") {
|
||||
return;
|
||||
}
|
||||
const user = userName.get(ws);
|
||||
try {
|
||||
const msg = JSON.parse(message);
|
||||
console.log(msg);
|
||||
console.log("Received message", msg);
|
||||
handleRequest(msg, ws);
|
||||
} catch (e) {
|
||||
console.error("Faulty request", message, e);
|
||||
return;
|
||||
|
|
@ -37,3 +36,5 @@ const server = Bun.serve({
|
|||
},
|
||||
port: 8076,
|
||||
});
|
||||
|
||||
console.log("Listening on port 8076");
|
||||
|
|
|
|||
|
|
@ -1,37 +1,52 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type { ServerWebSocket } from "bun";
|
||||
import type { Controller, Endpoint } from "./controller/controller";
|
||||
import { gameController } from "./controller/gameController";
|
||||
import { db } from "./database/db";
|
||||
import { BadRequestError } from "./errors/BadRequestError";
|
||||
|
||||
const controllers = {
|
||||
game: gameController,
|
||||
} satisfies Record<string, Controller<any>>;
|
||||
|
||||
export const handleRequest = (message: unknown, sessionUser?: string) => {
|
||||
const userName = new WeakMap<ServerWebSocket<unknown>, string>();
|
||||
|
||||
export const setSessionUser = (ws: ServerWebSocket<unknown>, user: string) => {
|
||||
userName.set(ws, user);
|
||||
};
|
||||
|
||||
export const handleRequest = async (
|
||||
message: unknown,
|
||||
ws: ServerWebSocket<unknown>,
|
||||
) => {
|
||||
// TODO: Remove this
|
||||
const sessionUser = userName.get(ws) || "Gordon";
|
||||
const ctx = {
|
||||
user: sessionUser,
|
||||
db,
|
||||
ws,
|
||||
};
|
||||
if (
|
||||
!message ||
|
||||
!(typeof message === "object") ||
|
||||
!("type" in message) ||
|
||||
!("payload" in message)
|
||||
!("payload" in message) ||
|
||||
!("id" in message)
|
||||
)
|
||||
return;
|
||||
const { type, payload } = message;
|
||||
const { type, payload, id } = message;
|
||||
if (!(typeof type === "string")) return;
|
||||
const [controllerName, action] = type.split(".");
|
||||
if (!(controllerName in controllers)) return;
|
||||
// @ts-expect-error controllers[controllerName] is a Controller
|
||||
const endpoint = controllers[controllerName][action] as Endpoint<any, any>;
|
||||
const input = endpoint.validate.safeParse(payload);
|
||||
if (input.success) {
|
||||
const result = endpoint.handler(input.data, ctx);
|
||||
return result;
|
||||
try {
|
||||
// @ts-expect-error controllers[controllerName] is a Controller
|
||||
const endpoint = controllers[controllerName][action] as Endpoint<any, any>;
|
||||
const input = endpoint.validate.parse(payload);
|
||||
const result = await endpoint.handler(input, ctx);
|
||||
ws.send(JSON.stringify({ id, payload: result }));
|
||||
return;
|
||||
} catch (_) {
|
||||
ws.send(JSON.stringify({ id, error: "Bad Request" }));
|
||||
}
|
||||
throw new BadRequestError(input.error.message);
|
||||
};
|
||||
|
||||
export type Routes = typeof controllers;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
import { $ } from "bun";
|
||||
|
||||
await Promise.all([$`bun run dev:backend`, $`bun run dev:client`]);
|
||||
|
|
@ -1,28 +1,36 @@
|
|||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import reactHooks from "eslint-plugin-react-hooks";
|
||||
import reactRefresh from "eslint-plugin-react-refresh";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{ ignores: ["dist"] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
"react-refresh/only-export-components": [
|
||||
"warn",
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
argsIgnorePattern: "^_",
|
||||
varsIgnorePattern: "^_",
|
||||
caughtErrorsIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
);
|
||||
|
|
|
|||
13
package.json
13
package.json
|
|
@ -4,7 +4,9 @@
|
|||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev": "bun run dev.ts",
|
||||
"dev:backend": "bun run backend/index.ts --watch",
|
||||
"dev:client": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
|
|
@ -12,14 +14,23 @@
|
|||
"drizzle:migrate": "bun run backend/migrate.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0-alpha.24",
|
||||
"@tanstack/react-query": "^5.56.2",
|
||||
"@uidotdev/usehooks": "^2.4.1",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-orm": "^0.33.0",
|
||||
"framer-motion": "^11.5.6",
|
||||
"jotai": "^2.10.0",
|
||||
"lucide-react": "^0.441.0",
|
||||
"react": "^18.3.1",
|
||||
"react-confetti-boom": "^1.0.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tailwindcss": "^4.0.0-alpha.24",
|
||||
"use-sound": "^4.0.3",
|
||||
"wouter": "^3.3.5",
|
||||
"zod": "^3.23.8",
|
||||
"zustand": "^4.5.5"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { Button } from "./components/Button";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
GitBranch,
|
||||
Github,
|
||||
History,
|
||||
LayoutDashboard,
|
||||
Menu,
|
||||
Play,
|
||||
Settings,
|
||||
Settings2,
|
||||
} from "lucide-react";
|
||||
import Hr from "./components/Hr";
|
||||
import NavLink from "./components/NavLink";
|
||||
|
||||
const drawerWidth = 256;
|
||||
const drawerWidthWithPadding = drawerWidth;
|
||||
|
||||
const Shell: React.FC = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const x = isOpen ? 0 : -drawerWidthWithPadding;
|
||||
const width = isOpen ? drawerWidthWithPadding : 0;
|
||||
useEffect(() => {
|
||||
const onResize = () => {
|
||||
setIsOpen(window.innerWidth > 768);
|
||||
};
|
||||
window.addEventListener("resize", onResize);
|
||||
onResize();
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative bg-black min-h-screen">
|
||||
<motion.div
|
||||
className="bg-black p-4 absolute h-screen w-64 flex border-white/10 border-1"
|
||||
animate={{ x }}
|
||||
transition={{ type: "tween" }}
|
||||
>
|
||||
<div className="w-full p-2 flex flex-col gap-6">
|
||||
<h1 className="[background:var(--bg-brand)] [-webkit-text-fill-color:transparent] font-black [-webkit-background-clip:text!important] font-mono text-3xl">
|
||||
Minesweeper
|
||||
<br />
|
||||
Business
|
||||
</h1>
|
||||
<Hr />
|
||||
<NavLink href="/">
|
||||
<LayoutDashboard />
|
||||
Dashboard
|
||||
</NavLink>
|
||||
<NavLink href="/play">
|
||||
<Play />
|
||||
Play
|
||||
</NavLink>
|
||||
<NavLink href="/history">
|
||||
<History />
|
||||
History
|
||||
</NavLink>
|
||||
<NavLink href="/settings">
|
||||
<Settings />
|
||||
Settings
|
||||
</NavLink>
|
||||
<Hr />
|
||||
<div className="grow" />
|
||||
<NavLink href="https://github.com/MasterGordon/minesweeper" external>
|
||||
<GitBranch />
|
||||
Source
|
||||
</NavLink>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Button
|
||||
className="absolute left-4 bg-black border-white/10 border-y-1 border-r-1 rounded-l-none"
|
||||
variant="ghost"
|
||||
onClick={() => setIsOpen((isOpen) => !isOpen)}
|
||||
>
|
||||
<Menu />
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
<motion.div className="flex">
|
||||
<motion.div
|
||||
animate={{ width: width }}
|
||||
transition={{ type: "tween" }}
|
||||
layout
|
||||
/>
|
||||
<motion.div className="flex flex-col gap-4 grow max-w-6xl mx-auto">
|
||||
<div className="flex flex-col justify-center gap-4 sm:mx-16 mt-12 sm:mt-0">
|
||||
<div className="bg-gray-950 p-4 rounded-lg w-full"></div>
|
||||
<div className="bg-gray-950 p-4 rounded-lg w-full"></div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Shell;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
import { atom } from "jotai";
|
||||
|
||||
export const gameId = atom<string | undefined>(undefined);
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { forwardRef } from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
const buttonVariants = cva("font-semibold py-2 px-4 rounded-md", {
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-white/95 hover:bg-primary/90",
|
||||
ghost: "bg-transparent text-white/95 hover:bg-white/05",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 py-2 px-4",
|
||||
sm: "h-9 px-3 rounded-md",
|
||||
lg: "h-11 px-8 rounded-md",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
});
|
||||
|
||||
export type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
as?: React.FC;
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
(
|
||||
{ className, variant, size, as: Comp = "button" as const, ...props },
|
||||
ref,
|
||||
) => {
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
const Hr = () => {
|
||||
return <hr className="border-white/10 w-full" />;
|
||||
};
|
||||
|
||||
export default Hr;
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import { Link } from "wouter";
|
||||
|
||||
interface NavLinkProps {
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
external?: boolean;
|
||||
}
|
||||
|
||||
const NavLink: React.FC<NavLinkProps> = ({ href, children, external }) => {
|
||||
const Comp = external ? "a" : Link;
|
||||
return (
|
||||
<Comp
|
||||
href={href}
|
||||
target={external ? "_blank" : undefined}
|
||||
className="text-white/70 hover:text-white/90 font-bold inline-flex gap-2"
|
||||
>
|
||||
{children}
|
||||
</Comp>
|
||||
);
|
||||
};
|
||||
|
||||
export default NavLink;
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
UseQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
import { Routes } from "../backend/router";
|
||||
import { wsClient } from "./wsClient";
|
||||
|
||||
export const useWSQuery = <
|
||||
TController extends keyof Routes,
|
||||
TAction extends keyof Routes[TController] & string,
|
||||
>(
|
||||
action: `${TController}.${TAction}`,
|
||||
// @ts-expect-error We dont care since this is internal api
|
||||
payload: Routes[TController][TAction]["validate"]["_input"],
|
||||
): UseQueryResult<
|
||||
// @ts-expect-error We dont care since this is internal api
|
||||
Awaited<ReturnType<Routes[TController][TAction]["handler"]>>
|
||||
> => {
|
||||
return useQuery({
|
||||
queryKey: [action, payload],
|
||||
queryFn: async () => {
|
||||
const result = await wsClient.dispatch(action, payload);
|
||||
return result;
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useWSMutation = <
|
||||
TController extends keyof Routes,
|
||||
TAction extends keyof Routes[TController] & string,
|
||||
>(
|
||||
action: `${TController}.${TAction}`,
|
||||
onSuccess?: (
|
||||
data: Awaited<
|
||||
// @ts-expect-error We dont care since this is internal api
|
||||
ReturnType<Routes[TController][TAction]["handler"]>
|
||||
>,
|
||||
) => void,
|
||||
) => {
|
||||
return useMutation({
|
||||
// @ts-expect-error We dont care since this is internal api
|
||||
mutationFn: async (
|
||||
// @ts-expect-error We dont care since this is internal api
|
||||
payload: Routes[TController][TAction]["validate"]["_input"],
|
||||
) => {
|
||||
const result = await wsClient.dispatch(action, payload);
|
||||
return result;
|
||||
},
|
||||
onSuccess,
|
||||
});
|
||||
};
|
||||
|
||||
export const useWSInvalidation = <
|
||||
TController extends keyof Routes,
|
||||
TAction extends keyof Routes[TController] & string,
|
||||
>() => {
|
||||
const queryClient = useQueryClient();
|
||||
return (action: `${TController}.${TAction}`) => {
|
||||
queryClient.invalidateQueries({ queryKey: [action] });
|
||||
};
|
||||
};
|
||||
226
src/index.css
226
src/index.css
|
|
@ -1,116 +1,116 @@
|
|||
.game-board {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
max-width: fit-content;
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-primary: hotpink;
|
||||
--bg-brand: -webkit-linear-gradient(225deg, rgb(251, 175, 21), rgb(251, 21, 242),
|
||||
rgb(21, 198, 251)) 0% 0% / 100% 300%;
|
||||
}
|
||||
|
||||
.game-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mine-button {
|
||||
background-color: #666;
|
||||
border: 1px solid black;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
font-size: 1.25rem;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-weight: bold;
|
||||
font-family: monospace;
|
||||
box-sizing: border-box;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
html {
|
||||
background: #111;
|
||||
color: #eee;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: auto;
|
||||
max-width: 1400px;
|
||||
padding: 1rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.timer {
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 2rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* justify-content: space-between; */
|
||||
align-items: center;
|
||||
font-size: 1rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stage {
|
||||
font-size: 1rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
input {
|
||||
font-size: 14px;
|
||||
margin: 12px;
|
||||
padding: 6px 12px 6px 12px;
|
||||
border-radius: 0.7em;
|
||||
background: #333;
|
||||
color: #eee;
|
||||
border: 1px solid rgb(251, 21, 242);
|
||||
|
||||
}
|
||||
|
||||
button {
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
margin: 12px;
|
||||
padding: 6px 12px 6px 12px;
|
||||
border-radius: 0.7em;
|
||||
background: -webkit-linear-gradient(225deg, rgb(251, 175, 21), rgb(251, 21, 242),
|
||||
rgb(21, 198, 251)) 0% 0% / 300% 300%;
|
||||
background-size: 200% auto;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
animation: gradient_move 1s ease infinite;
|
||||
}
|
||||
|
||||
@keyframes gradient_move {
|
||||
0%{background-position: 0% 92%}
|
||||
50%{background-position: 100% 9%}
|
||||
100%{background-position: 0% 92%}
|
||||
}
|
||||
|
||||
/* .scores { */
|
||||
/* position: fixed; */
|
||||
/* top: 0; */
|
||||
/* left: 0; */
|
||||
/* padding: 1rem; */
|
||||
/* .game-board { */
|
||||
/* display: grid; */
|
||||
/* gap: 2px; */
|
||||
/* max-width: fit-content; */
|
||||
/* } */
|
||||
/**/
|
||||
/* .game-wrapper { */
|
||||
/* display: flex; */
|
||||
/* flex-direction: column; */
|
||||
/* align-items: center; */
|
||||
/* } */
|
||||
/**/
|
||||
/* .mine-button { */
|
||||
/* background-color: #666; */
|
||||
/* border: 1px solid black; */
|
||||
/* width: 2rem; */
|
||||
/* height: 2rem; */
|
||||
/* font-size: 1.25rem; */
|
||||
/* user-select: none; */
|
||||
/* display: flex; */
|
||||
/* justify-content: center; */
|
||||
/* align-items: center; */
|
||||
/* font-weight: bold; */
|
||||
/* font-family: monospace; */
|
||||
/* box-sizing: border-box; */
|
||||
/* transition: all 0.2s ease-in-out; */
|
||||
/* } */
|
||||
/**/
|
||||
/* html { */
|
||||
/* background: #111; */
|
||||
/* color: #eee; */
|
||||
/* } */
|
||||
/**/
|
||||
/* body { */
|
||||
/* margin: auto; */
|
||||
/* max-width: 1400px; */
|
||||
/* padding: 1rem; */
|
||||
/* font-family: monospace; */
|
||||
/* } */
|
||||
/**/
|
||||
/* .timer { */
|
||||
/* flex-grow: 1; */
|
||||
/* display: flex; */
|
||||
/* justify-content: space-between; */
|
||||
/* align-items: center; */
|
||||
/* font-size: 2rem; */
|
||||
/* font-family: monospace; */
|
||||
/* } */
|
||||
/**/
|
||||
/* .footer { */
|
||||
/* display: flex; */
|
||||
/* flex-direction: column; */
|
||||
/* align-items: center; */
|
||||
/* font-size: 1rem; */
|
||||
/* font-family: monospace; */
|
||||
/* } */
|
||||
/**/
|
||||
/* pre { */
|
||||
/* margin: 0; */
|
||||
/* } */
|
||||
/**/
|
||||
/* .stage { */
|
||||
/* font-size: 1rem; */
|
||||
/* font-family: monospace; */
|
||||
/* } */
|
||||
/**/
|
||||
/* input { */
|
||||
/* font-size: 14px; */
|
||||
/* margin: 12px; */
|
||||
/* padding: 6px 12px 6px 12px; */
|
||||
/* border-radius: 0.7em; */
|
||||
/* background: #333; */
|
||||
/* color: #eee; */
|
||||
/* border: 1px solid rgb(251, 21, 242); */
|
||||
/**/
|
||||
/* } */
|
||||
/**/
|
||||
/* button { */
|
||||
/* color: white; */
|
||||
/* font-weight: 600; */
|
||||
/* font-size: 14px; */
|
||||
/* margin: 12px; */
|
||||
/* padding: 6px 12px 6px 12px; */
|
||||
/* border-radius: 0.7em; */
|
||||
/* background: -webkit-linear-gradient(225deg, rgb(251, 175, 21), rgb(251, 21, 242), */
|
||||
/* rgb(21, 198, 251)) 0% 0% / 300% 300%; */
|
||||
/* background-size: 200% auto; */
|
||||
/* } */
|
||||
/**/
|
||||
/* button:hover { */
|
||||
/* animation: gradient_move 1s ease infinite; */
|
||||
/* } */
|
||||
/**/
|
||||
/* @keyframes gradient_move { */
|
||||
/* 0%{background-position: 0% 92%} */
|
||||
/* 50%{background-position: 100% 9%} */
|
||||
/* 100%{background-position: 0% 92%} */
|
||||
/* } */
|
||||
/**/
|
||||
/* .header { */
|
||||
/* display: grid; */
|
||||
/* grid-template-columns: 1fr 1fr; */
|
||||
/* margin-bottom: 1rem; */
|
||||
/* } */
|
||||
/**/
|
||||
/* .scores { */
|
||||
/* text-align: right; */
|
||||
/* } */
|
||||
|
||||
.header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.scores {
|
||||
text-align: right;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
11
src/main.tsx
11
src/main.tsx
|
|
@ -4,6 +4,8 @@ import App from "./App.tsx";
|
|||
import "./index.css";
|
||||
import { connectWS } from "./ws.ts";
|
||||
import { Toaster } from "react-hot-toast";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import Shell from "./Shell.tsx";
|
||||
|
||||
document.addEventListener("contextmenu", (event) => {
|
||||
event.preventDefault();
|
||||
|
|
@ -11,9 +13,14 @@ document.addEventListener("contextmenu", (event) => {
|
|||
|
||||
connectWS();
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<Toaster position="top-right" reverseOrder={false} />
|
||||
<App />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Toaster position="top-right" reverseOrder={false} />
|
||||
<Shell />
|
||||
{/* <App /> */}
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,74 @@
|
|||
import type { Routes } from "../backend/router";
|
||||
|
||||
const connectionString = import.meta.env.DEV
|
||||
? "ws://localhost:8076/ws"
|
||||
: "wss://mb.gordon.business/ws";
|
||||
|
||||
const messageListeners = new Set<(event: MessageEvent) => void>();
|
||||
const addMessageListener = (listener: (event: MessageEvent) => void) => {
|
||||
messageListeners.add(listener);
|
||||
};
|
||||
const removeMessageListener = (listener: (event: MessageEvent) => void) => {
|
||||
messageListeners.delete(listener);
|
||||
};
|
||||
|
||||
const emitMessage = (event: MessageEvent) => {
|
||||
messageListeners.forEach((listener) => listener(event));
|
||||
};
|
||||
|
||||
const createWSClient = () => {
|
||||
const ws = new WebSocket(connectionString);
|
||||
ws.onmessage = emitMessage;
|
||||
addMessageListener((event: MessageEvent) => {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log(data);
|
||||
});
|
||||
|
||||
const dispatch = async <
|
||||
TController extends keyof Routes,
|
||||
TAction extends keyof Routes[TController] & string,
|
||||
>(
|
||||
action: `${TController}.${TAction}`,
|
||||
// @ts-expect-error We dont care since this is internal api
|
||||
payload: Routes[TController][TAction]["validate"]["_input"],
|
||||
// @ts-expect-error We dont care since this is internal api
|
||||
): Promise<Awaited<ReturnType<Routes[TController][TAction]["handler"]>>> => {
|
||||
if (ws.readyState !== WebSocket.OPEN) {
|
||||
await new Promise<void>((res) => {
|
||||
ws.onopen = () => {
|
||||
res();
|
||||
};
|
||||
});
|
||||
}
|
||||
const requestId = crypto.randomUUID();
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: action,
|
||||
payload,
|
||||
id: requestId,
|
||||
}),
|
||||
);
|
||||
return new Promise<
|
||||
// @ts-expect-error We dont care since this is internal api
|
||||
Awaited<ReturnType<Routes[TController][TAction]["handler"]>>
|
||||
>((res, rej) => {
|
||||
const listener = (event: MessageEvent) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.id === requestId) {
|
||||
removeMessageListener(listener);
|
||||
if (data.error) {
|
||||
rej(data.error);
|
||||
} else {
|
||||
res(data.payload);
|
||||
}
|
||||
}
|
||||
};
|
||||
addMessageListener(listener);
|
||||
});
|
||||
};
|
||||
return {
|
||||
dispatch,
|
||||
};
|
||||
};
|
||||
|
||||
export const wsClient = createWSClient();
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react-swc'
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
})
|
||||
plugins: [react(), tailwindcss()],
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue