added login/register switch

This commit is contained in:
MasterGordon 2025-09-11 14:36:02 +02:00
parent 891dde40b6
commit 53a3a5d44f
6 changed files with 223 additions and 68 deletions

View File

@ -17,6 +17,8 @@ import { useMediaQuery } from "@uidotdev/usehooks";
import Header from "./components/Header"; import Header from "./components/Header";
import { Tag } from "./components/Tag"; import { Tag } from "./components/Tag";
import Feed from "./components/Feed/Feed"; import Feed from "./components/Feed/Feed";
import { useAtom } from "jotai";
import { loginTokenAtom } from "./atoms";
const drawerWidth = 256; const drawerWidth = 256;
const drawerWidthWithPadding = drawerWidth; const drawerWidthWithPadding = drawerWidth;
@ -24,6 +26,7 @@ const drawerWidthWithPadding = drawerWidth;
const Shell: React.FC<PropsWithChildren> = ({ children }) => { const Shell: React.FC<PropsWithChildren> = ({ children }) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const drawerRef = useRef<HTMLDivElement>(null); const drawerRef = useRef<HTMLDivElement>(null);
const [loginToken] = useAtom(loginTokenAtom);
const x = isOpen ? 0 : -drawerWidthWithPadding; const x = isOpen ? 0 : -drawerWidthWithPadding;
const width = isOpen ? drawerWidthWithPadding : 0; const width = isOpen ? drawerWidthWithPadding : 0;
@ -79,22 +82,28 @@ const Shell: React.FC<PropsWithChildren> = ({ children }) => {
<Play /> <Play />
Play Play
</NavLink> </NavLink>
<NavLink href="/history"> {loginToken && (
<History /> <NavLink href="/history">
History <History />
</NavLink> History
</NavLink>
)}
<NavLink href="/store"> <NavLink href="/store">
<Store /> <Store />
Store Store
</NavLink> </NavLink>
<NavLink href="/collection"> {loginToken && (
<Library /> <NavLink href="/collection">
Collection <Tag size="sm">NEW</Tag> <Library />
</NavLink> Collection <Tag size="sm">NEW</Tag>
<NavLink href="/settings"> </NavLink>
<Settings /> )}
Settings {loginToken && (
</NavLink> <NavLink href="/settings">
<Settings />
Settings
</NavLink>
)}
<Hr /> <Hr />
<Feed /> <Feed />
{/* <Hr /> */} {/* <Hr /> */}
@ -125,7 +134,7 @@ const Shell: React.FC<PropsWithChildren> = ({ children }) => {
transition={{ type: "tween" }} transition={{ type: "tween" }}
layout layout
/> />
<motion.div className="flex flex-col gap-4 grow max-w-7xl mx-auto w-[calc(100vw-256px)]"> <motion.div className="flex flex-col gap-4 grow max-w-7xl mx-auto w-full md:w-[calc(100vw-256px)]">
<div className="flex flex-col justify-center gap-4 sm:mx-16 mt-16 sm:mt-2 mx-2"> <div className="flex flex-col justify-center gap-4 sm:mx-16 mt-16 sm:mt-2 mx-2">
<Header /> <Header />
{children} {children}

View File

@ -17,26 +17,31 @@ import { wsClient } from "../../wsClient";
const RegisterButton = () => { const RegisterButton = () => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [isLoginMode, setIsLoginMode] = useState(false);
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const register = useWSMutation("user.register"); const register = useWSMutation("user.register");
const login = useWSMutation("user.login");
const [, setToken] = useAtom(loginTokenAtom); const [, setToken] = useAtom(loginTokenAtom);
useEffect(() => { useEffect(() => {
setUsername(""); setUsername("");
setPassword(""); setPassword("");
setError("");
}, [isOpen]); }, [isOpen]);
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="primary">Register</Button> <Button variant="primary" className="self-start">
Register
</Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Register</DialogTitle> <DialogTitle>{isLoginMode ? "Login" : "Register"}</DialogTitle>
</DialogHeader> </DialogHeader>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<label className="text-white/70 font-bold">Username</label> <label className="text-white/70 font-bold">Username</label>
@ -49,6 +54,18 @@ const RegisterButton = () => {
<PasswordInput value={password} onChange={setPassword} /> <PasswordInput value={password} onChange={setPassword} />
</div> </div>
{error && <p className="text-red-500">{error}</p>} {error && <p className="text-red-500">{error}</p>}
<div className="flex justify-center">
<Button
variant="ghost"
onClick={() => {
setIsLoginMode(!isLoginMode);
setError("");
}}
className="text-sm text-white/60 hover:text-white/80"
>
{isLoginMode ? "Need an account? Register" : "Already have an account? Login"}
</Button>
</div>
<DialogFooter> <DialogFooter>
<Button variant="ghost" onClick={() => setIsOpen(false)}> <Button variant="ghost" onClick={() => setIsOpen(false)}>
Cancel Cancel
@ -56,7 +73,8 @@ const RegisterButton = () => {
<Button <Button
variant="primary" variant="primary"
onClick={() => { onClick={() => {
register const mutation = isLoginMode ? login : register;
mutation
.mutateAsync({ username, password }) .mutateAsync({ username, password })
.then(async (res) => { .then(async (res) => {
setToken(res.token); setToken(res.token);
@ -64,13 +82,14 @@ const RegisterButton = () => {
token: res.token, token: res.token,
}); });
await queryClient.resetQueries(); await queryClient.resetQueries();
setIsOpen(false);
}) })
.catch((e) => { .catch((e) => {
setError(e); setError(e);
}); });
}} }}
> >
Register {isLoginMode ? "Login" : "Register"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@ -10,7 +10,7 @@ interface PastMatchProps {
const PastMatch = ({ game }: PastMatchProps) => { const PastMatch = ({ game }: PastMatchProps) => {
return ( return (
<div className="flex flex-col gap-4 items-center w-full @container"> <div className="flex flex-col gap-4 items-center w-full @container">
<div className="w-full bg-white/10 border-white/20 border-1 rounded-md grid justify-center grid-cols-4 @max-xl:grid-cols-3 p-4"> <div className="w-full bg-white/10 border-white/20 border-1 rounded-md grid justify-center grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 p-4">
<div className="flex-col flex"> <div className="flex-col flex">
<div className="text-white/90 text-lg">Endless</div> <div className="text-white/90 text-lg">Endless</div>
<div className="text-white/50 text-lg"> <div className="text-white/50 text-lg">
@ -24,10 +24,10 @@ const PastMatch = ({ game }: PastMatchProps) => {
{game.minesCount - game.isFlagged.flat().filter((f) => f).length} {game.minesCount - game.isFlagged.flat().filter((f) => f).length}
</div> </div>
</div> </div>
<div className="text-white/80 text-lg @max-xl:hidden"> <div className="text-white/80 text-lg hidden lg:block">
<div>Duration: {formatTimeSpan(game.finished - game.started)}</div> <div>Duration: {formatTimeSpan(game.finished - game.started)}</div>
</div> </div>
<div className="flex justify-end"> <div className="flex justify-center sm:justify-end col-span-full sm:col-span-1">
{/* @ts-expect-error as is cheaply typed */} {/* @ts-expect-error as is cheaply typed */}
<Button as={Link} href={`/play/${game.uuid}`} variant="outline"> <Button as={Link} href={`/play/${game.uuid}`} variant="outline">
Show Board Show Board

View File

@ -6,12 +6,14 @@ import { cn } from "../lib/utils";
function TooltipProvider({ function TooltipProvider({
delayDuration = 0, delayDuration = 0,
disableHoverableContent = false,
...props ...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) { }: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return ( return (
<TooltipPrimitive.Provider <TooltipPrimitive.Provider
data-slot="tooltip-provider" data-slot="tooltip-provider"
delayDuration={delayDuration} delayDuration={delayDuration}
disableHoverableContent={disableHoverableContent}
{...props} {...props}
/> />
); );

View File

@ -1,10 +1,11 @@
import { useWSMutation, useWSQuery } from "../../hooks"; import { useWSMutation, useWSQuery } from "../../hooks";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { gameIdAtom } from "../../atoms"; import { gameIdAtom, loginTokenAtom } from "../../atoms";
import { Button } from "../../components/Button"; import { Button } from "../../components/Button";
import LeaderboardButton from "../../components/LeaderboardButton"; import LeaderboardButton from "../../components/LeaderboardButton";
import { Fragment, startTransition, Suspense, useEffect } from "react"; import { Fragment, startTransition, Suspense, useEffect } from "react";
import { Board } from "../../components/LazyBoard"; import { Board } from "../../components/LazyBoard";
import RegisterButton from "../../components/Auth/RegisterButton";
interface EndlessProps { interface EndlessProps {
gameId?: string; gameId?: string;
@ -12,6 +13,7 @@ interface EndlessProps {
const Endless: React.FC<EndlessProps> = (props) => { const Endless: React.FC<EndlessProps> = (props) => {
const [gameId, setGameId] = useAtom(gameIdAtom); const [gameId, setGameId] = useAtom(gameIdAtom);
const [loginToken] = useAtom(loginTokenAtom);
const { data: game } = useWSQuery("game.getGameState", gameId!, !!gameId); const { data: game } = useWSQuery("game.getGameState", gameId!, !!gameId);
const { data: settings } = useWSQuery("user.getSettings", null); const { data: settings } = useWSQuery("user.getSettings", null);
const startGame = useWSMutation("game.createGame"); const startGame = useWSMutation("game.createGame");
@ -73,18 +75,22 @@ const Endless: React.FC<EndlessProps> = (props) => {
<div className="w-full grid md:grid-cols-[350px_1fr]"> <div className="w-full grid md:grid-cols-[350px_1fr]">
<div className="flex flex-col md:border-r-white/10 md:border-r-1 gap-8 pr-12"> <div className="flex flex-col md:border-r-white/10 md:border-r-1 gap-8 pr-12">
<h2 className="text-white/90 text-xl">Minesweeper Endless</h2> <h2 className="text-white/90 text-xl">Minesweeper Endless</h2>
<Button {loginToken ? (
className="w-fit" <Button
variant="primary" className="w-fit"
onClick={async () => { variant="primary"
const gameId = await startGame.mutateAsync(null); onClick={async () => {
startTransition(() => { const gameId = await startGame.mutateAsync(null);
setGameId(gameId.uuid); startTransition(() => {
}); setGameId(gameId.uuid);
}} });
> }}
Start Game >
</Button> Start Game
</Button>
) : (
<RegisterButton />
)}
<h2 className="text-white/80 text-lg mt-8">How to play</h2> <h2 className="text-white/80 text-lg mt-8">How to play</h2>
<p className="text-white/90"> <p className="text-white/90">
Endless minesweeper is just like regular minesweeper but you Endless minesweeper is just like regular minesweeper but you

View File

@ -1,4 +1,4 @@
import { useMemo } from "react"; import { useMemo, useRef, useEffect, useState } from "react";
import { useWSQuery } from "../../hooks"; import { useWSQuery } from "../../hooks";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { import {
@ -9,7 +9,67 @@ import {
import PastMatch from "../../components/PastMatch"; import PastMatch from "../../components/PastMatch";
import GemsIcon from "../../components/GemIcon"; import GemsIcon from "../../components/GemIcon";
const TouchTooltip = ({ children, content, date, games }: {
children: React.ReactNode;
content: React.ReactNode;
date: string;
games: number;
}) => {
const [isOpen, setIsOpen] = useState(false);
const [isTouchDevice, setIsTouchDevice] = useState(false);
const timeoutRef = useRef<NodeJS.Timeout>();
useEffect(() => {
// Detect if device supports touch
setIsTouchDevice('ontouchstart' in window || navigator.maxTouchPoints > 0);
}, []);
const handleTouch = () => {
if (isTouchDevice) {
setIsOpen(true);
if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => setIsOpen(false), 2000);
}
};
useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, []);
// For touch devices, use controlled tooltip
if (isTouchDevice) {
return (
<Tooltip open={isOpen}>
<TooltipTrigger asChild onClick={handleTouch} onTouchStart={handleTouch}>
{children}
</TooltipTrigger>
<TooltipContent>
<p>{date}</p>
<p>{games} Games Played</p>
</TooltipContent>
</Tooltip>
);
}
// For non-touch devices, use default hover behavior
return (
<Tooltip>
<TooltipTrigger asChild>
{children}
</TooltipTrigger>
<TooltipContent>
<p>{date}</p>
<p>{games} Games Played</p>
</TooltipContent>
</Tooltip>
);
};
const Profile: React.FC = () => { const Profile: React.FC = () => {
const [availableWeeks, setAvailableWeeks] = useState(16); // Default to 4 months
const containerRef = useRef<HTMLDivElement>(null);
const { data: username } = useWSQuery("user.getSelf", null); const { data: username } = useWSQuery("user.getSelf", null);
const { data: heatmap } = useWSQuery( const { data: heatmap } = useWSQuery(
"user.getHeatmap", "user.getHeatmap",
@ -30,15 +90,71 @@ const Profile: React.FC = () => {
!!username, !!username,
); );
const now = useMemo(() => dayjs(), []); const now = useMemo(() => dayjs(), []);
const firstOfYear = useMemo(() => now.startOf("year"), [now]); const startDate = useMemo(() => now.subtract(availableWeeks, "weeks").startOf("week"), [now, availableWeeks]);
const weeks = now.diff(firstOfYear, "weeks") + 1;
const maxHeat = heatmap ? Math.max(...heatmap) : 0; const maxHeat = heatmap ? Math.max(...heatmap) : 0;
// Calculate available space and adjust weeks accordingly
useEffect(() => {
const calculateAvailableWeeks = () => {
if (!containerRef.current) return;
const containerWidth = containerRef.current.offsetWidth;
const isMobile = window.innerWidth < 640; // sm breakpoint
// Calculate dot and gap sizes based on breakpoint
const dotWidth = isMobile ? 16 : 24; // w-4 = 16px, w-6 = 24px
const gapSize = isMobile ? 4 : 8; // gap-1 = 4px, gap-2 = 8px
// Each week takes: dotWidth + gapSize
const weekWidth = dotWidth + gapSize;
// Calculate max weeks that fit, with some padding
const maxWeeks = Math.floor((containerWidth - 32) / weekWidth); // 32px for padding
// Limit between 4 weeks (1 month) and 26 weeks (6 months)
const weeks = Math.max(4, Math.min(26, maxWeeks));
setAvailableWeeks(weeks);
};
// Use setTimeout to ensure DOM is fully rendered
const timer = setTimeout(calculateAvailableWeeks, 0);
window.addEventListener('resize', calculateAvailableWeeks);
return () => {
clearTimeout(timer);
window.removeEventListener('resize', calculateAvailableWeeks);
};
}, []);
// Additional effect to recalculate when heatmap data is available
useEffect(() => {
if (heatmap && containerRef.current) {
const timer = setTimeout(() => {
if (!containerRef.current) return;
const containerWidth = containerRef.current.offsetWidth;
const isMobile = window.innerWidth < 640;
const dotWidth = isMobile ? 16 : 24;
const gapSize = isMobile ? 4 : 8;
const weekWidth = dotWidth + gapSize;
const maxWeeks = Math.floor((containerWidth - 32) / weekWidth);
const weeks = Math.max(4, Math.min(26, maxWeeks));
setAvailableWeeks(weeks);
}, 100);
return () => clearTimeout(timer);
}
}, [heatmap]);
return ( return (
<div className="grid md:[grid-template-columns:_2fr_3fr] gap-6"> <div className="grid md:[grid-template-columns:_2fr_3fr] gap-6 px-2 sm:px-0">
<div className="m-8 text-white flex self-center"> <div className="mx-4 my-8 md:m-8 text-white flex flex-col sm:flex-row self-center">
<div className="p-2 flex items-center text-2xl">{username}</div> <div className="p-2 flex items-center text-2xl mb-2 sm:mb-0">{username}</div>
<div className="border-l-white border-l p-2 text-lg"> <div className="border-l-0 sm:border-l-white sm:border-l p-2 text-lg">
<p>Total Games: {profile?.totalGames}</p> <p>Total Games: {profile?.totalGames}</p>
<p>Highest Stage: {profile?.highestStage}</p> <p>Highest Stage: {profile?.highestStage}</p>
<p> <p>
@ -57,40 +173,43 @@ const Profile: React.FC = () => {
.map((game) => <PastMatch key={game.uuid} game={game} />)} .map((game) => <PastMatch key={game.uuid} game={game} />)}
</div> </div>
{heatmap && ( {heatmap && (
<div className="col-span-full"> <div className="col-span-full mb-8">
<h2 className="text-white text-2xl font-semibold mb-4">Activity</h2> <h2 className="text-white text-2xl font-semibold mb-4">Activity</h2>
<div className="flex gap-2 "> <div className="overflow-x-auto" ref={containerRef}>
{Array.from({ length: weeks }).map((_, w) => ( <div className="flex gap-1 sm:gap-2 min-w-fit">
<div key={w} className="w-6 flex gap-2 flex-col"> {Array.from({ length: availableWeeks }).map((_, w) => (
{Array.from({ length: 7 }).map((_, d) => { <div key={w} className="w-4 sm:w-6 flex gap-1 sm:gap-2 flex-col">
const index = w * 7 + d; {Array.from({ length: 7 }).map((_, d) => {
if (index >= heatmap.length) return; const index = (heatmap.length - (availableWeeks * 7)) + (w * 7 + d);
return ( if (index < 0 || index >= heatmap.length) return;
<Tooltip key={d}> const date = now
<TooltipTrigger> .clone()
<div className="w-5 h-5 border border-white"> .subtract((availableWeeks * 7) - 1 - (w * 7 + d), "days")
.format("DD/MM/YYYY");
return (
<TouchTooltip
key={d}
date={date}
games={heatmap[index]}
content={null}
>
<button
className="w-3 h-3 sm:w-5 sm:h-5 border border-white touch-manipulation"
type="button"
>
<div <div
className="w-5 h-5 bg-brand -m-px" className="w-3 h-3 sm:w-5 sm:h-5 bg-brand -m-px pointer-events-none"
style={{ style={{
opacity: heatmap[index] / maxHeat, opacity: heatmap[index] / maxHeat,
}} }}
/> />
</div> </button>
</TooltipTrigger> </TouchTooltip>
<TooltipContent> );
<p> })}
{firstOfYear </div>
.clone() ))}
.add(index, "days") </div>
.format("DD/MM/YYYY")}
</p>
<p>{heatmap[index]} Games Played</p>
</TooltipContent>
</Tooltip>
);
})}
</div>
))}
</div> </div>
</div> </div>
)} )}