first layout draft

This commit is contained in:
MasterGordon 2024-09-23 00:58:36 +02:00
parent 62055855e5
commit 8319de0812
22 changed files with 506 additions and 155 deletions

View File

@ -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>,

View File

@ -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({

View File

@ -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");

View File

@ -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;

BIN
bun.lockb

Binary file not shown.

3
dev.ts Normal file
View File

@ -0,0 +1,3 @@
import { $ } from "bun";
await Promise.all([$`bun run dev:backend`, $`bun run dev:client`]);

View File

@ -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: "^_",
},
],
},
},
)
);

View File

@ -1 +0,0 @@
console.log("Hello via Bun!");

View File

@ -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"
},

BIN
sqlite.db

Binary file not shown.

98
src/Shell.tsx Normal file
View File

@ -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;

3
src/atoms.ts Normal file
View File

@ -0,0 +1,3 @@
import { atom } from "jotai";
export const gameId = atom<string | undefined>(undefined);

41
src/components/Button.tsx Normal file
View File

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

5
src/components/Hr.tsx Normal file
View File

@ -0,0 +1,5 @@
const Hr = () => {
return <hr className="border-white/10 w-full" />;
};
export default Hr;

View File

@ -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;

63
src/hooks.ts Normal file
View File

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

View File

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

6
src/lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

View File

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

74
src/wsClient.ts Normal file
View File

@ -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();

0
tailwind.config.js Normal file
View File

View File

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