diff --git a/backend/controller/controller.ts b/backend/controller/controller.ts index d1a70e2..07daf42 100644 --- a/backend/controller/controller.ts +++ b/backend/controller/controller.ts @@ -12,12 +12,6 @@ export type Endpoint = { handler: (input: TInput, context: RequestContext) => Promise; }; -export type Request> = { - method: "POST"; - url: string; - body: z.infer; -}; - export const createEndpoint = ( validate: z.ZodType, handler: (input: TInput, context: RequestContext) => Promise, diff --git a/backend/controller/gameController.ts b/backend/controller/gameController.ts index 484a560..e12b83b 100644 --- a/backend/controller/gameController.ts +++ b/backend/controller/gameController.ts @@ -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({ diff --git a/backend/index.ts b/backend/index.ts index f5872d6..9560b57 100644 --- a/backend/index.ts +++ b/backend/index.ts @@ -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, 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"); diff --git a/backend/router.ts b/backend/router.ts index 8f6dff3..b251e89 100644 --- a/backend/router.ts +++ b/backend/router.ts @@ -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>; -export const handleRequest = (message: unknown, sessionUser?: string) => { +const userName = new WeakMap, string>(); + +export const setSessionUser = (ws: ServerWebSocket, user: string) => { + userName.set(ws, user); +}; + +export const handleRequest = async ( + message: unknown, + ws: ServerWebSocket, +) => { + // 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; - 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; + 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; diff --git a/bun.lockb b/bun.lockb index 7a664db..f2ecc90 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/dev.ts b/dev.ts new file mode 100644 index 0000000..afda337 --- /dev/null +++ b/dev.ts @@ -0,0 +1,3 @@ +import { $ } from "bun"; + +await Promise.all([$`bun run dev:backend`, $`bun run dev:client`]); diff --git a/eslint.config.js b/eslint.config.js index 092408a..a61a109 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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: "^_", + }, + ], }, }, -) +); diff --git a/index.ts b/index.ts deleted file mode 100644 index f67b2c6..0000000 --- a/index.ts +++ /dev/null @@ -1 +0,0 @@ -console.log("Hello via Bun!"); \ No newline at end of file diff --git a/package.json b/package.json index c461e0b..0f628f6 100644 --- a/package.json +++ b/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" }, diff --git a/sqlite.db b/sqlite.db index e548cfa..06eaec2 100644 Binary files a/sqlite.db and b/sqlite.db differ diff --git a/src/Shell.tsx b/src/Shell.tsx new file mode 100644 index 0000000..2d909b2 --- /dev/null +++ b/src/Shell.tsx @@ -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 ( +
+ +
+

+ Minesweeper +
+ Business +

+
+ + + Dashboard + + + + Play + + + + History + + + + Settings + +
+
+ + + Source + +
+
+ +
+ + + + +
+
+
+
+
+
+
+ ); +}; + +export default Shell; diff --git a/src/atoms.ts b/src/atoms.ts new file mode 100644 index 0000000..7180609 --- /dev/null +++ b/src/atoms.ts @@ -0,0 +1,3 @@ +import { atom } from "jotai"; + +export const gameId = atom(undefined); diff --git a/src/components/Button.tsx b/src/components/Button.tsx new file mode 100644 index 0000000..6934afb --- /dev/null +++ b/src/components/Button.tsx @@ -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 & + VariantProps & { + as?: React.FC; + }; + +export const Button = forwardRef( + ( + { className, variant, size, as: Comp = "button" as const, ...props }, + ref, + ) => { + return ( + + ); + }, +); diff --git a/src/components/Hr.tsx b/src/components/Hr.tsx new file mode 100644 index 0000000..126ab4a --- /dev/null +++ b/src/components/Hr.tsx @@ -0,0 +1,5 @@ +const Hr = () => { + return
; +}; + +export default Hr; diff --git a/src/components/NavLink.tsx b/src/components/NavLink.tsx new file mode 100644 index 0000000..94461d5 --- /dev/null +++ b/src/components/NavLink.tsx @@ -0,0 +1,22 @@ +import { Link } from "wouter"; + +interface NavLinkProps { + href: string; + children: React.ReactNode; + external?: boolean; +} + +const NavLink: React.FC = ({ href, children, external }) => { + const Comp = external ? "a" : Link; + return ( + + {children} + + ); +}; + +export default NavLink; diff --git a/src/hooks.ts b/src/hooks.ts new file mode 100644 index 0000000..83df5fe --- /dev/null +++ b/src/hooks.ts @@ -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> +> => { + 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 + >, + ) => 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] }); + }; +}; diff --git a/src/index.css b/src/index.css index 1ad06f6..b54dbc4 100644 --- a/src/index.css +++ b/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; -} diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..a5ef193 --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/src/main.tsx b/src/main.tsx index 99a4fdc..7555605 100644 --- a/src/main.tsx +++ b/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( - - + + + + {/* */} + , ); diff --git a/src/wsClient.ts b/src/wsClient.ts new file mode 100644 index 0000000..0c04087 --- /dev/null +++ b/src/wsClient.ts @@ -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>> => { + if (ws.readyState !== WebSocket.OPEN) { + await new Promise((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> + >((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(); diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..e69de29 diff --git a/vite.config.ts b/vite.config.ts index 861b04b..50bf145 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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()], +});