added login/register switch
This commit is contained in:
parent
891dde40b6
commit
53a3a5d44f
|
|
@ -17,6 +17,8 @@ import { useMediaQuery } from "@uidotdev/usehooks";
|
|||
import Header from "./components/Header";
|
||||
import { Tag } from "./components/Tag";
|
||||
import Feed from "./components/Feed/Feed";
|
||||
import { useAtom } from "jotai";
|
||||
import { loginTokenAtom } from "./atoms";
|
||||
|
||||
const drawerWidth = 256;
|
||||
const drawerWidthWithPadding = drawerWidth;
|
||||
|
|
@ -24,6 +26,7 @@ const drawerWidthWithPadding = drawerWidth;
|
|||
const Shell: React.FC<PropsWithChildren> = ({ children }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const drawerRef = useRef<HTMLDivElement>(null);
|
||||
const [loginToken] = useAtom(loginTokenAtom);
|
||||
|
||||
const x = isOpen ? 0 : -drawerWidthWithPadding;
|
||||
const width = isOpen ? drawerWidthWithPadding : 0;
|
||||
|
|
@ -79,22 +82,28 @@ const Shell: React.FC<PropsWithChildren> = ({ children }) => {
|
|||
<Play />
|
||||
Play
|
||||
</NavLink>
|
||||
{loginToken && (
|
||||
<NavLink href="/history">
|
||||
<History />
|
||||
History
|
||||
</NavLink>
|
||||
)}
|
||||
<NavLink href="/store">
|
||||
<Store />
|
||||
Store
|
||||
</NavLink>
|
||||
{loginToken && (
|
||||
<NavLink href="/collection">
|
||||
<Library />
|
||||
Collection <Tag size="sm">NEW</Tag>
|
||||
</NavLink>
|
||||
)}
|
||||
{loginToken && (
|
||||
<NavLink href="/settings">
|
||||
<Settings />
|
||||
Settings
|
||||
</NavLink>
|
||||
)}
|
||||
<Hr />
|
||||
<Feed />
|
||||
{/* <Hr /> */}
|
||||
|
|
@ -125,7 +134,7 @@ const Shell: React.FC<PropsWithChildren> = ({ children }) => {
|
|||
transition={{ type: "tween" }}
|
||||
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">
|
||||
<Header />
|
||||
{children}
|
||||
|
|
|
|||
|
|
@ -17,26 +17,31 @@ import { wsClient } from "../../wsClient";
|
|||
|
||||
const RegisterButton = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isLoginMode, setIsLoginMode] = useState(false);
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const queryClient = useQueryClient();
|
||||
const register = useWSMutation("user.register");
|
||||
const login = useWSMutation("user.login");
|
||||
const [, setToken] = useAtom(loginTokenAtom);
|
||||
|
||||
useEffect(() => {
|
||||
setUsername("");
|
||||
setPassword("");
|
||||
setError("");
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="primary">Register</Button>
|
||||
<Button variant="primary" className="self-start">
|
||||
Register
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Register</DialogTitle>
|
||||
<DialogTitle>{isLoginMode ? "Login" : "Register"}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<label className="text-white/70 font-bold">Username</label>
|
||||
|
|
@ -49,6 +54,18 @@ const RegisterButton = () => {
|
|||
<PasswordInput value={password} onChange={setPassword} />
|
||||
</div>
|
||||
{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>
|
||||
<Button variant="ghost" onClick={() => setIsOpen(false)}>
|
||||
Cancel
|
||||
|
|
@ -56,7 +73,8 @@ const RegisterButton = () => {
|
|||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
register
|
||||
const mutation = isLoginMode ? login : register;
|
||||
mutation
|
||||
.mutateAsync({ username, password })
|
||||
.then(async (res) => {
|
||||
setToken(res.token);
|
||||
|
|
@ -64,13 +82,14 @@ const RegisterButton = () => {
|
|||
token: res.token,
|
||||
});
|
||||
await queryClient.resetQueries();
|
||||
setIsOpen(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
setError(e);
|
||||
});
|
||||
}}
|
||||
>
|
||||
Register
|
||||
{isLoginMode ? "Login" : "Register"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ interface PastMatchProps {
|
|||
const PastMatch = ({ game }: PastMatchProps) => {
|
||||
return (
|
||||
<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="text-white/90 text-lg">Endless</div>
|
||||
<div className="text-white/50 text-lg">
|
||||
|
|
@ -24,10 +24,10 @@ const PastMatch = ({ game }: PastMatchProps) => {
|
|||
{game.minesCount - game.isFlagged.flat().filter((f) => f).length}
|
||||
</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>
|
||||
<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 */}
|
||||
<Button as={Link} href={`/play/${game.uuid}`} variant="outline">
|
||||
Show Board
|
||||
|
|
|
|||
|
|
@ -6,12 +6,14 @@ import { cn } from "../lib/utils";
|
|||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
disableHoverableContent = false,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
disableHoverableContent={disableHoverableContent}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { useWSMutation, useWSQuery } from "../../hooks";
|
||||
import { useAtom } from "jotai";
|
||||
import { gameIdAtom } from "../../atoms";
|
||||
import { gameIdAtom, loginTokenAtom } from "../../atoms";
|
||||
import { Button } from "../../components/Button";
|
||||
import LeaderboardButton from "../../components/LeaderboardButton";
|
||||
import { Fragment, startTransition, Suspense, useEffect } from "react";
|
||||
import { Board } from "../../components/LazyBoard";
|
||||
import RegisterButton from "../../components/Auth/RegisterButton";
|
||||
|
||||
interface EndlessProps {
|
||||
gameId?: string;
|
||||
|
|
@ -12,6 +13,7 @@ interface EndlessProps {
|
|||
|
||||
const Endless: React.FC<EndlessProps> = (props) => {
|
||||
const [gameId, setGameId] = useAtom(gameIdAtom);
|
||||
const [loginToken] = useAtom(loginTokenAtom);
|
||||
const { data: game } = useWSQuery("game.getGameState", gameId!, !!gameId);
|
||||
const { data: settings } = useWSQuery("user.getSettings", null);
|
||||
const startGame = useWSMutation("game.createGame");
|
||||
|
|
@ -73,6 +75,7 @@ const Endless: React.FC<EndlessProps> = (props) => {
|
|||
<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">
|
||||
<h2 className="text-white/90 text-xl">Minesweeper Endless</h2>
|
||||
{loginToken ? (
|
||||
<Button
|
||||
className="w-fit"
|
||||
variant="primary"
|
||||
|
|
@ -85,6 +88,9 @@ const Endless: React.FC<EndlessProps> = (props) => {
|
|||
>
|
||||
Start Game
|
||||
</Button>
|
||||
) : (
|
||||
<RegisterButton />
|
||||
)}
|
||||
<h2 className="text-white/80 text-lg mt-8">How to play</h2>
|
||||
<p className="text-white/90">
|
||||
Endless minesweeper is just like regular minesweeper but you
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useMemo } from "react";
|
||||
import { useMemo, useRef, useEffect, useState } from "react";
|
||||
import { useWSQuery } from "../../hooks";
|
||||
import dayjs from "dayjs";
|
||||
import {
|
||||
|
|
@ -9,7 +9,67 @@ import {
|
|||
import PastMatch from "../../components/PastMatch";
|
||||
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 [availableWeeks, setAvailableWeeks] = useState(16); // Default to 4 months
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { data: username } = useWSQuery("user.getSelf", null);
|
||||
const { data: heatmap } = useWSQuery(
|
||||
"user.getHeatmap",
|
||||
|
|
@ -30,15 +90,71 @@ const Profile: React.FC = () => {
|
|||
!!username,
|
||||
);
|
||||
const now = useMemo(() => dayjs(), []);
|
||||
const firstOfYear = useMemo(() => now.startOf("year"), [now]);
|
||||
const weeks = now.diff(firstOfYear, "weeks") + 1;
|
||||
const startDate = useMemo(() => now.subtract(availableWeeks, "weeks").startOf("week"), [now, availableWeeks]);
|
||||
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 (
|
||||
<div className="grid md:[grid-template-columns:_2fr_3fr] gap-6">
|
||||
<div className="m-8 text-white flex self-center">
|
||||
<div className="p-2 flex items-center text-2xl">{username}</div>
|
||||
<div className="border-l-white border-l p-2 text-lg">
|
||||
<div className="grid md:[grid-template-columns:_2fr_3fr] gap-6 px-2 sm:px-0">
|
||||
<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 mb-2 sm:mb-0">{username}</div>
|
||||
<div className="border-l-0 sm:border-l-white sm:border-l p-2 text-lg">
|
||||
<p>Total Games: {profile?.totalGames}</p>
|
||||
<p>Highest Stage: {profile?.highestStage}</p>
|
||||
<p>
|
||||
|
|
@ -57,42 +173,45 @@ const Profile: React.FC = () => {
|
|||
.map((game) => <PastMatch key={game.uuid} game={game} />)}
|
||||
</div>
|
||||
{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>
|
||||
<div className="flex gap-2 ">
|
||||
{Array.from({ length: weeks }).map((_, w) => (
|
||||
<div key={w} className="w-6 flex gap-2 flex-col">
|
||||
<div className="overflow-x-auto" ref={containerRef}>
|
||||
<div className="flex gap-1 sm:gap-2 min-w-fit">
|
||||
{Array.from({ length: availableWeeks }).map((_, w) => (
|
||||
<div key={w} className="w-4 sm:w-6 flex gap-1 sm:gap-2 flex-col">
|
||||
{Array.from({ length: 7 }).map((_, d) => {
|
||||
const index = w * 7 + d;
|
||||
if (index >= heatmap.length) return;
|
||||
const index = (heatmap.length - (availableWeeks * 7)) + (w * 7 + d);
|
||||
if (index < 0 || index >= heatmap.length) return;
|
||||
const date = now
|
||||
.clone()
|
||||
.subtract((availableWeeks * 7) - 1 - (w * 7 + d), "days")
|
||||
.format("DD/MM/YYYY");
|
||||
return (
|
||||
<Tooltip key={d}>
|
||||
<TooltipTrigger>
|
||||
<div className="w-5 h-5 border border-white">
|
||||
<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
|
||||
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={{
|
||||
opacity: heatmap[index] / maxHeat,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{firstOfYear
|
||||
.clone()
|
||||
.add(index, "days")
|
||||
.format("DD/MM/YYYY")}
|
||||
</p>
|
||||
<p>{heatmap[index]} Games Played</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</button>
|
||||
</TouchTooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in New Issue