diff --git a/.env b/.env new file mode 100644 index 0000000..948a18b --- /dev/null +++ b/.env @@ -0,0 +1 @@ +SECRET=Aijd108jAW8dj98uj12918AWdijo83289jAdioj diff --git a/.gitignore b/.gitignore index 497553e..6a2b49e 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,5 @@ temp_dbs *.njsproj *.sln *.sw? + +deploy.sh diff --git a/backend/controller/controller.ts b/backend/controller/controller.ts index 07daf42..1548862 100644 --- a/backend/controller/controller.ts +++ b/backend/controller/controller.ts @@ -1,10 +1,12 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import type { ServerWebSocket } from "bun"; import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite"; import type { z } from "zod"; interface RequestContext { user?: string; db: BunSQLiteDatabase; + ws: ServerWebSocket; } export type Endpoint = { diff --git a/backend/controller/userController.ts b/backend/controller/userController.ts new file mode 100644 index 0000000..433df26 --- /dev/null +++ b/backend/controller/userController.ts @@ -0,0 +1,60 @@ +import { z } from "zod"; +import { createController, createEndpoint } from "./controller"; +import { loginUser, registerUser } from "../repositories/userRepository"; +import crypto from "crypto"; +import { resetSessionUser, setSessionUser } from "../router"; + +const secret = process.env.SECRET!; + +const signString = (payload: string) => { + return crypto.createHmac("sha256", secret).update(payload).digest("hex"); +}; + +export const userController = createController({ + getSelf: createEndpoint(z.null(), async (_, { user }) => { + return user; + }), + login: createEndpoint( + z.object({ username: z.string(), password: z.string() }), + async (input, { db, ws }) => { + const { name: user } = await loginUser( + db, + input.username, + input.password, + ); + const session = { user, expires: Date.now() + 1000 * 60 * 60 * 24 * 14 }; + const sig = signString(JSON.stringify(session)); + setSessionUser(ws, user); + return { token: JSON.stringify({ session, sig }) }; + }, + ), + loginWithToken: createEndpoint( + z.object({ token: z.string() }), + async (input, { ws }) => { + const { session, sig } = JSON.parse(input.token); + const { user } = session; + if (sig !== signString(JSON.stringify(session))) { + return { success: false }; + } + if (Date.now() > session.expires) { + return { success: false }; + } + setSessionUser(ws, user); + return { success: true }; + }, + ), + logout: createEndpoint(z.null(), async (_, { ws }) => { + resetSessionUser(ws); + }), + register: createEndpoint( + z.object({ username: z.string().max(15), password: z.string().min(6) }), + async (input, { db, ws }) => { + await registerUser(db, input.username, input.password); + const user = input.username; + const session = { user, expires: Date.now() + 1000 * 60 * 60 * 24 * 14 }; + const sig = signString(JSON.stringify(session)); + setSessionUser(ws, user); + return { token: JSON.stringify({ session, sig }) }; + }, + ), +}); diff --git a/backend/router.ts b/backend/router.ts index b251e89..1d0524a 100644 --- a/backend/router.ts +++ b/backend/router.ts @@ -3,9 +3,11 @@ import type { ServerWebSocket } from "bun"; import type { Controller, Endpoint } from "./controller/controller"; import { gameController } from "./controller/gameController"; import { db } from "./database/db"; +import { userController } from "./controller/userController"; const controllers = { game: gameController, + user: userController, } satisfies Record>; const userName = new WeakMap, string>(); @@ -14,6 +16,10 @@ export const setSessionUser = (ws: ServerWebSocket, user: string) => { userName.set(ws, user); }; +export const resetSessionUser = (ws: ServerWebSocket) => { + userName.delete(ws); +}; + export const handleRequest = async ( message: unknown, ws: ServerWebSocket, diff --git a/bun.lockb b/bun.lockb index f2ecc90..bc27cb3 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/index.html b/index.html index 944032c..f7afd88 100644 --- a/index.html +++ b/index.html @@ -3,8 +3,9 @@ - + + Minesweeper diff --git a/package.json b/package.json index 0f628f6..e459c12 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,12 @@ "drizzle:migrate": "bun run backend/migrate.ts" }, "dependencies": { + "@radix-ui/react-dialog": "^1.1.1", + "@radix-ui/react-dropdown-menu": "^2.1.1", + "@radix-ui/react-popover": "^1.1.1", "@tailwindcss/vite": "^4.0.0-alpha.24", "@tanstack/react-query": "^5.56.2", + "@tanstack/react-query-devtools": "^5.0.0-alpha.91", "@uidotdev/usehooks": "^2.4.1", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", diff --git a/src/Shell.tsx b/src/Shell.tsx index 2d909b2..0fff75e 100644 --- a/src/Shell.tsx +++ b/src/Shell.tsx @@ -3,16 +3,16 @@ 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"; +import { useMediaQuery } from "@uidotdev/usehooks"; +import Header from "./components/Header"; const drawerWidth = 256; const drawerWidthWithPadding = drawerWidth; @@ -22,14 +22,10 @@ const Shell: React.FC = () => { const x = isOpen ? 0 : -drawerWidthWithPadding; const width = isOpen ? drawerWidthWithPadding : 0; + const isMobile = useMediaQuery("(max-width: 768px)"); useEffect(() => { - const onResize = () => { - setIsOpen(window.innerWidth > 768); - }; - window.addEventListener("resize", onResize); - onResize(); - return () => window.removeEventListener("resize", onResize); - }, []); + setIsOpen(!isMobile); + }, [isMobile]); return (
@@ -85,7 +81,8 @@ const Shell: React.FC = () => { layout /> -
+
+
diff --git a/src/atoms.ts b/src/atoms.ts index 7180609..c7b54e3 100644 --- a/src/atoms.ts +++ b/src/atoms.ts @@ -1,3 +1,8 @@ import { atom } from "jotai"; +import { atomWithStorage } from "jotai/utils"; export const gameId = atom(undefined); +export const loginToken = atomWithStorage( + "loginToken", + undefined, +); diff --git a/src/components/Auth/LoginButton.tsx b/src/components/Auth/LoginButton.tsx new file mode 100644 index 0000000..3bc122d --- /dev/null +++ b/src/components/Auth/LoginButton.tsx @@ -0,0 +1,57 @@ +import { useEffect, useState } from "react"; +import { Button } from "../Button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "../Dialog"; + +const LoginButton = () => { + const [isOpen, setIsOpen] = useState(false); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + + useEffect(() => { + setUsername(""); + setPassword(""); + }, [isOpen]); + + return ( + + + + + + + Login + +
+ + setUsername(e.target.value)} + /> + + setPassword(e.target.value)} + /> +
+ + + + +
+
+ ); +}; + +export default LoginButton; diff --git a/src/components/Auth/RegisterButton.tsx b/src/components/Auth/RegisterButton.tsx new file mode 100644 index 0000000..27f7f33 --- /dev/null +++ b/src/components/Auth/RegisterButton.tsx @@ -0,0 +1,57 @@ +import { useEffect, useState } from "react"; +import { Button } from "../Button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "../Dialog"; + +const RegisterButton = () => { + const [isOpen, setIsOpen] = useState(false); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + + useEffect(() => { + setUsername(""); + setPassword(""); + }, [isOpen]); + + return ( + + + + + + + Register + +
+ + setUsername(e.target.value)} + /> + + setPassword(e.target.value)} + /> +
+ + + + +
+
+ ); +}; + +export default RegisterButton; diff --git a/src/components/Button.tsx b/src/components/Button.tsx index 6934afb..b609125 100644 --- a/src/components/Button.tsx +++ b/src/components/Button.tsx @@ -2,11 +2,15 @@ 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", { +const buttonVariants = cva("font-semibold py-2 px-4 rounded-md flex gap-2", { variants: { variant: { - default: "bg-primary text-white/95 hover:bg-primary/90", + default: "bg-gray-900 text-white/95", ghost: "bg-transparent text-white/95 hover:bg-white/05", + outline: + "bg-transparent text-white/95 hover:bg-white/05 border-white/10 border-1", + primary: + "[background:var(--bg-brand)] text-white/95 hover:bg-white/05 hover:animate-gradientmove", }, size: { default: "h-10 py-2 px-4", diff --git a/src/components/Dialog.tsx b/src/components/Dialog.tsx new file mode 100644 index 0000000..65e3747 --- /dev/null +++ b/src/components/Dialog.tsx @@ -0,0 +1,119 @@ +import * as React from "react"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { X } from "lucide-react"; +import { cn } from "../lib/utils"; + +const Dialog = DialogPrimitive.Root; + +const DialogTrigger = DialogPrimitive.Trigger; + +const DialogPortal = DialogPrimitive.Portal; + +const DialogClose = DialogPrimitive.Close; + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; + +const DialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Close + + + +)); +DialogContent.displayName = DialogPrimitive.Content.displayName; + +const DialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogHeader.displayName = "DialogHeader"; + +const DialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+); +DialogFooter.displayName = "DialogFooter"; + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogTitle.displayName = DialogPrimitive.Title.displayName; + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DialogDescription.displayName = DialogPrimitive.Description.displayName; + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogClose, + DialogTrigger, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +}; diff --git a/src/components/DropdownMenu.tsx b/src/components/DropdownMenu.tsx new file mode 100644 index 0000000..3aeb137 --- /dev/null +++ b/src/components/DropdownMenu.tsx @@ -0,0 +1,197 @@ +import * as React from "react"; +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { Check, ChevronRight, Circle } from "lucide-react"; +import { cn } from "../lib/utils"; + +const DropdownMenu = DropdownMenuPrimitive.Root; + +const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; + +const DropdownMenuGroup = DropdownMenuPrimitive.Group; + +const DropdownMenuPortal = DropdownMenuPrimitive.Portal; + +const DropdownMenuSub = DropdownMenuPrimitive.Sub; + +const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)); +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName; + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName; + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)); +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName; + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName; + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName; + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)); +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName; + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean; + } +>(({ className, inset, ...props }, ref) => ( + +)); +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName; + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ); +}; +DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +}; diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000..ed4b056 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,58 @@ +import { UserRound } from "lucide-react"; +import { Button } from "./Button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "./DropdownMenu"; +import { useLocation } from "wouter"; +import LoginButton from "./Auth/LoginButton"; +import { useWSQuery } from "../hooks"; +import RegisterButton from "./Auth/RegisterButton"; +import banner from "../images/banner.png"; +import mine from "../images/mine.png"; + +const Header = () => { + const [, setLocation] = useLocation(); + const { data: username } = useWSQuery("user.getSelf", null); + return ( +
+
+ + +
+ {username ? ( + + + + + + setLocation("/profile")}> + Profile + + setLocation("/settings")}> + Settings + + + Logout + + + ) : ( + <> + + + + )} +
+ ); +}; + +export default Header; diff --git a/src/components/Popover.tsx b/src/components/Popover.tsx new file mode 100644 index 0000000..fe5daa2 --- /dev/null +++ b/src/components/Popover.tsx @@ -0,0 +1,32 @@ +import * as PopoverPrimitive from "@radix-ui/react-popover"; +import { forwardRef, PropsWithChildren } from "react"; +import { cn } from "../lib/utils"; + +const Popover = PopoverPrimitive.Root; + +const PopoverTrigger: React.FC = ({ children }) => { + return ( + {children} + ); +}; + +const PopoverContent = forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( + + + +)); +PopoverContent.displayName = PopoverPrimitive.Content.displayName; + +export { Popover, PopoverTrigger, PopoverContent }; diff --git a/src/images/banner.png b/src/images/banner.png new file mode 100644 index 0000000..baff3b0 Binary files /dev/null and b/src/images/banner.png differ diff --git a/src/images/expert.png b/src/images/expert.png new file mode 100644 index 0000000..8401028 Binary files /dev/null and b/src/images/expert.png differ diff --git a/src/images/mine.png b/src/images/mine.png new file mode 100644 index 0000000..5ecdd47 Binary files /dev/null and b/src/images/mine.png differ diff --git a/src/index.css b/src/index.css index b54dbc4..c6022f6 100644 --- a/src/index.css +++ b/src/index.css @@ -4,6 +4,16 @@ --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%; + --bg-secondary: linear-gradient(90deg, #D9AFD9 0%, #97D9E1 100%) 0% 0% / 100% 300%; + --animate-gradientmove: gradientmove 1s ease 0s 1 normal forwards; + @keyframes gradientmove { + 0%{background-position: 0% 0%} + 100%{background-position: 0% 100%} + } +} + +button { + cursor: pointer; } /* .game-board { */ @@ -99,11 +109,6 @@ /* 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; */ diff --git a/src/main.tsx b/src/main.tsx index 7555605..5b0c196 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,8 +4,13 @@ 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 { + QueryCache, + QueryClient, + QueryClientProvider, +} from "@tanstack/react-query"; import Shell from "./Shell.tsx"; +import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; document.addEventListener("contextmenu", (event) => { event.preventDefault(); @@ -13,7 +18,9 @@ document.addEventListener("contextmenu", (event) => { connectWS(); -const queryClient = new QueryClient(); +const queryClient = new QueryClient({ + queryCache: new QueryCache(), +}); createRoot(document.getElementById("root")!).render( @@ -21,6 +28,7 @@ createRoot(document.getElementById("root")!).render( {/* */} + , );