Merge branch 'dev'
Some checks failed
Build and Deploy / quality (push) Failing after 1m47s
Build and Deploy / build-and-deploy (push) Successful in 2m8s

This commit is contained in:
lila 2026-05-02 13:09:19 +02:00
commit 90b0890263
21 changed files with 636 additions and 93 deletions

View file

@ -17,6 +17,7 @@
"better-auth": "^1.6.2",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"sonner": "^2.0.7",
"tailwindcss": "^4.2.2"
},
"devDependencies": {

View file

@ -0,0 +1,249 @@
import { useState, useEffect } from "react";
import { toast } from "sonner";
import { authClient } from "../../lib/auth-client";
type Tab = "login" | "register";
type AuthModalProps = { onClose: () => void; onSuccess: () => void };
type LoginFormProps = { onSuccess: () => void };
const LoginForm = ({ onSuccess }: LoginFormProps) => {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isPending, setIsPending] = useState(false);
const handleSubmit = async () => {
setIsPending(true);
await authClient.signIn.email(
{ email, password },
{
onSuccess: () => {
toast.success("Welcome back!");
onSuccess();
},
onError: (ctx) => {
toast.error(ctx.error.message ?? "Something went wrong.");
setIsPending(false);
},
},
);
};
return (
<form
onSubmit={(e) => {
e.preventDefault();
void handleSubmit();
}}
className="flex flex-col gap-3"
>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full rounded-2xl border border-(--color-primary-light) bg-white px-4 py-3 text-sm text-(--color-text) placeholder:text-(--color-text-muted) focus:outline-none focus:ring-2 focus:ring-(--color-primary)"
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full rounded-2xl border border-(--color-primary-light) bg-white px-4 py-3 text-sm text-(--color-text) placeholder:text-(--color-text-muted) focus:outline-none focus:ring-2 focus:ring-(--color-primary)"
/>
<div className="text-right">
<a
href="/forgot-password"
className="text-xs text-(--color-text-muted) hover:text-(--color-primary) transition-colors"
>
Forgot password?
</a>
</div>
<button
type="submit"
disabled={isPending}
className="w-full rounded-2xl bg-(--color-primary) px-4 py-3 text-white font-bold hover:bg-(--color-primary-dark) transition-colors disabled:opacity-50"
>
{isPending ? "Logging in..." : "Login"}
</button>
</form>
);
};
type RegisterFormProps = { onSuccess: () => void };
const RegisterForm = ({ onSuccess }: RegisterFormProps) => {
const [name, setName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isPending, setIsPending] = useState(false);
const handleSubmit = async () => {
setIsPending(true);
await authClient.signUp.email(
{ name, email, password },
{
onSuccess: () => {
toast.success("Check your email to verify your account.");
onSuccess();
},
onError: (ctx) => {
toast.error(ctx.error.message ?? "Something went wrong.");
setIsPending(false);
},
},
);
};
return (
<form
onSubmit={(e) => {
e.preventDefault();
void handleSubmit();
}}
className="flex flex-col gap-3"
>
<input
type="text"
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
required
className="w-full rounded-2xl border border-(--color-primary-light) bg-white px-4 py-3 text-sm text-(--color-text) placeholder:text-(--color-text-muted) focus:outline-none focus:ring-2 focus:ring-(--color-primary)"
/>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full rounded-2xl border border-(--color-primary-light) bg-white px-4 py-3 text-sm text-(--color-text) placeholder:text-(--color-text-muted) focus:outline-none focus:ring-2 focus:ring-(--color-primary)"
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="w-full rounded-2xl border border-(--color-primary-light) bg-white px-4 py-3 text-sm text-(--color-text) placeholder:text-(--color-text-muted) focus:outline-none focus:ring-2 focus:ring-(--color-primary)"
/>
<button
type="submit"
disabled={isPending}
className="w-full rounded-2xl bg-(--color-primary) px-4 py-3 text-white font-bold hover:bg-(--color-primary-dark) transition-colors disabled:opacity-50"
>
{isPending ? "Creating account..." : "Register"}
</button>
</form>
);
};
type SocialButtonsProps = { onSuccess: () => void };
const SocialButtons = ({ onSuccess }: SocialButtonsProps) => {
const handleSocial = (provider: "google" | "github") => {
void authClient.signIn.social(
{ provider, callbackURL: window.location.origin },
{
onSuccess,
onError: (ctx) => {
toast.error(ctx.error.message ?? "Something went wrong.");
},
},
);
};
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<div className="flex-1 h-px bg-(--color-primary-light)" />
<span className="text-xs text-(--color-text-muted) font-medium">
or continue with
</span>
<div className="flex-1 h-px bg-(--color-primary-light)" />
</div>
<button
onClick={() => handleSocial("github")}
className="w-full rounded-2xl bg-(--color-text) px-4 py-3 text-white font-bold hover:opacity-90 shadow-sm hover:shadow-md transition-all"
>
Continue with GitHub
</button>
<button
onClick={() => handleSocial("google")}
className="w-full rounded-2xl bg-(--color-primary) px-4 py-3 text-white font-bold hover:bg-(--color-primary-dark) shadow-sm hover:shadow-md transition-all"
>
Continue with Google
</button>
</div>
);
};
export const AuthModal = ({ onClose, onSuccess }: AuthModalProps) => {
const [tab, setTab] = useState<Tab>("login");
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40 backdrop-blur-sm"
onClick={onClose}
>
<div
className="relative w-full max-w-sm rounded-3xl border border-(--color-primary-light) bg-white shadow-lg p-8 flex flex-col gap-6"
onClick={(e) => e.stopPropagation()}
>
{/* Close button */}
<button
onClick={onClose}
className="absolute top-4 right-4 text-(--color-text-muted) hover:text-(--color-primary) transition-colors"
aria-label="Close"
>
</button>
{/* Header */}
<div className="text-center">
<h2 className="text-2xl font-black tracking-tight text-(--color-text)">
lila
</h2>
</div>
{/* Tabs */}
<div className="flex rounded-2xl border border-(--color-primary-light) overflow-hidden">
{(["login", "register"] as Tab[]).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`flex-1 py-2 text-sm font-bold transition-colors capitalize ${
tab === t
? "bg-(--color-primary) text-white"
: "text-(--color-text-muted) hover:text-(--color-primary)"
}`}
>
{t}
</button>
))}
</div>
{tab === "login" ? (
<LoginForm onSuccess={onSuccess} />
) : (
<RegisterForm onSuccess={onClose} />
)}
{/* Social */}
<SocialButtons onSuccess={onSuccess} />
</div>
</div>
);
};

View file

@ -66,13 +66,15 @@ const Hero = () => {
) : (
<>
<Link
to="/login"
to="/"
search={{ modal: "auth" }}
className="px-7 py-3 rounded-full text-white font-bold text-sm bg-(--color-primary) hover:bg-(--color-primary-dark)"
>
Get started
</Link>
<Link
to="/login"
to="/"
search={{ modal: "auth" }}
className="px-7 py-3 rounded-full font-bold text-sm text-(--color-primary) border-2 border-(--color-primary) hover:bg-(--color-surface)"
>
Log in

View file

@ -24,13 +24,14 @@ const NavAuth = () => {
</button>
) : (
<Link
to="/login"
to="/"
search={{ modal: "auth" }}
className="text-sm font-medium px-4 py-1.5 rounded-full
text-white bg-(--color-primary)
hover:bg-(--color-primary-dark)
transition-colors duration-200"
>
Sign in
Login
</Link>
)}
</div>

View file

@ -1,17 +0,0 @@
import { Link } from "@tanstack/react-router";
const NavLogin = () => {
return (
<Link
to="/login"
className="text-sm font-medium px-4 py-1.5 rounded-full
text-white bg-(--color-primary)
hover:bg-(--color-primary-dark)
transition-colors duration-200"
>
Login
</Link>
);
};
export default NavLogin;

View file

@ -9,15 +9,21 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as ResetPasswordRouteImport } from './routes/reset-password'
import { Route as PlayRouteImport } from './routes/play'
import { Route as MultiplayerRouteImport } from './routes/multiplayer'
import { Route as LoginRouteImport } from './routes/login'
import { Route as ForgotPasswordRouteImport } from './routes/forgot-password'
import { Route as AboutRouteImport } from './routes/about'
import { Route as IndexRouteImport } from './routes/index'
import { Route as MultiplayerIndexRouteImport } from './routes/multiplayer/index'
import { Route as MultiplayerLobbyCodeRouteImport } from './routes/multiplayer/lobby.$code'
import { Route as MultiplayerGameCodeRouteImport } from './routes/multiplayer/game.$code'
const ResetPasswordRoute = ResetPasswordRouteImport.update({
id: '/reset-password',
path: '/reset-password',
getParentRoute: () => rootRouteImport,
} as any)
const PlayRoute = PlayRouteImport.update({
id: '/play',
path: '/play',
@ -28,9 +34,9 @@ const MultiplayerRoute = MultiplayerRouteImport.update({
path: '/multiplayer',
getParentRoute: () => rootRouteImport,
} as any)
const LoginRoute = LoginRouteImport.update({
id: '/login',
path: '/login',
const ForgotPasswordRoute = ForgotPasswordRouteImport.update({
id: '/forgot-password',
path: '/forgot-password',
getParentRoute: () => rootRouteImport,
} as any)
const AboutRoute = AboutRouteImport.update({
@ -62,9 +68,10 @@ const MultiplayerGameCodeRoute = MultiplayerGameCodeRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/about': typeof AboutRoute
'/login': typeof LoginRoute
'/forgot-password': typeof ForgotPasswordRoute
'/multiplayer': typeof MultiplayerRouteWithChildren
'/play': typeof PlayRoute
'/reset-password': typeof ResetPasswordRoute
'/multiplayer/': typeof MultiplayerIndexRoute
'/multiplayer/game/$code': typeof MultiplayerGameCodeRoute
'/multiplayer/lobby/$code': typeof MultiplayerLobbyCodeRoute
@ -72,8 +79,9 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/about': typeof AboutRoute
'/login': typeof LoginRoute
'/forgot-password': typeof ForgotPasswordRoute
'/play': typeof PlayRoute
'/reset-password': typeof ResetPasswordRoute
'/multiplayer': typeof MultiplayerIndexRoute
'/multiplayer/game/$code': typeof MultiplayerGameCodeRoute
'/multiplayer/lobby/$code': typeof MultiplayerLobbyCodeRoute
@ -82,9 +90,10 @@ export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/about': typeof AboutRoute
'/login': typeof LoginRoute
'/forgot-password': typeof ForgotPasswordRoute
'/multiplayer': typeof MultiplayerRouteWithChildren
'/play': typeof PlayRoute
'/reset-password': typeof ResetPasswordRoute
'/multiplayer/': typeof MultiplayerIndexRoute
'/multiplayer/game/$code': typeof MultiplayerGameCodeRoute
'/multiplayer/lobby/$code': typeof MultiplayerLobbyCodeRoute
@ -94,9 +103,10 @@ export interface FileRouteTypes {
fullPaths:
| '/'
| '/about'
| '/login'
| '/forgot-password'
| '/multiplayer'
| '/play'
| '/reset-password'
| '/multiplayer/'
| '/multiplayer/game/$code'
| '/multiplayer/lobby/$code'
@ -104,8 +114,9 @@ export interface FileRouteTypes {
to:
| '/'
| '/about'
| '/login'
| '/forgot-password'
| '/play'
| '/reset-password'
| '/multiplayer'
| '/multiplayer/game/$code'
| '/multiplayer/lobby/$code'
@ -113,9 +124,10 @@ export interface FileRouteTypes {
| '__root__'
| '/'
| '/about'
| '/login'
| '/forgot-password'
| '/multiplayer'
| '/play'
| '/reset-password'
| '/multiplayer/'
| '/multiplayer/game/$code'
| '/multiplayer/lobby/$code'
@ -124,13 +136,21 @@ export interface FileRouteTypes {
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AboutRoute: typeof AboutRoute
LoginRoute: typeof LoginRoute
ForgotPasswordRoute: typeof ForgotPasswordRoute
MultiplayerRoute: typeof MultiplayerRouteWithChildren
PlayRoute: typeof PlayRoute
ResetPasswordRoute: typeof ResetPasswordRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/reset-password': {
id: '/reset-password'
path: '/reset-password'
fullPath: '/reset-password'
preLoaderRoute: typeof ResetPasswordRouteImport
parentRoute: typeof rootRouteImport
}
'/play': {
id: '/play'
path: '/play'
@ -145,11 +165,11 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof MultiplayerRouteImport
parentRoute: typeof rootRouteImport
}
'/login': {
id: '/login'
path: '/login'
fullPath: '/login'
preLoaderRoute: typeof LoginRouteImport
'/forgot-password': {
id: '/forgot-password'
path: '/forgot-password'
fullPath: '/forgot-password'
preLoaderRoute: typeof ForgotPasswordRouteImport
parentRoute: typeof rootRouteImport
}
'/about': {
@ -209,9 +229,10 @@ const MultiplayerRouteWithChildren = MultiplayerRoute._addFileChildren(
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AboutRoute: AboutRoute,
LoginRoute: LoginRoute,
ForgotPasswordRoute: ForgotPasswordRoute,
MultiplayerRoute: MultiplayerRouteWithChildren,
PlayRoute: PlayRoute,
ResetPasswordRoute: ResetPasswordRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)

View file

@ -1,16 +1,39 @@
import { createRootRoute, Outlet } from "@tanstack/react-router";
import {
createRootRoute,
Outlet,
useNavigate,
useSearch,
} from "@tanstack/react-router";
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
import { Toaster } from "sonner";
import Navbar from "../components/navbar/NavBar";
import NotFound from "../components/NotFound";
import RootError from "../components/RootError";
import { AuthModal } from "../components/auth/AuthModal";
import { AuthModalSearchSchema } from "@lila/shared";
const RootLayout = () => {
const navigate = useNavigate();
const { modal, redirect } = useSearch({ from: "__root__" });
const handleClose = () => {
void navigate({ to: "/", search: {} });
};
const handleSuccess = () => {
void navigate({ to: (redirect as string) ?? "/", search: {} });
};
return (
<>
<Navbar />
<main className="max-w-5xl mx-auto px-6 py-8">
<Outlet />
</main>
{modal === "auth" && (
<AuthModal onClose={handleClose} onSuccess={handleSuccess} />
)}
<Toaster richColors position="top-center" />
<TanStackRouterDevtools />
</>
);
@ -20,4 +43,5 @@ export const Route = createRootRoute({
component: RootLayout,
notFoundComponent: NotFound,
errorComponent: RootError,
validateSearch: AuthModalSearchSchema,
});

View file

@ -0,0 +1,74 @@
import { useState } from "react";
import { createFileRoute, Link } from "@tanstack/react-router";
import { authClient } from "../lib/auth-client";
import { toast } from "sonner";
function ForgotPasswordPage() {
const [email, setEmail] = useState("");
const [isPending, setIsPending] = useState(false);
const handleSubmit = async () => {
setIsPending(true);
await authClient.requestPasswordReset(
{ email, redirectTo: `${window.location.origin}/reset-password` },
{
onSuccess: () => {
toast.success("Check your email for a reset link.");
setIsPending(false);
},
onError: (ctx) => {
toast.error(ctx.error.message ?? "Something went wrong.");
setIsPending(false);
},
},
);
};
return (
<div className="flex flex-col items-center justify-center gap-6 p-8 max-w-sm mx-auto">
<div className="w-full text-center">
<h1 className="text-2xl font-black tracking-tight text-(--color-text)">
Forgot password
</h1>
<p className="mt-1 text-sm text-(--color-text-muted)">
Enter your email and we'll send you a reset link.
</p>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
void handleSubmit();
}}
className="w-full flex flex-col gap-3"
>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full rounded-2xl border border-(--color-primary-light) bg-white px-4 py-3 text-sm text-(--color-text) placeholder:text-(--color-text-muted) focus:outline-none focus:ring-2 focus:ring-(--color-primary)"
/>
<button
type="submit"
disabled={isPending}
className="w-full rounded-2xl bg-(--color-primary) px-4 py-3 text-white font-bold hover:bg-(--color-primary-dark) transition-colors disabled:opacity-50"
>
{isPending ? "Sending..." : "Send reset link"}
</button>
</form>
<Link
to="/"
className="text-sm text-(--color-text-muted) hover:text-(--color-primary) transition-colors"
>
Back to home
</Link>
</div>
);
}
export const Route = createFileRoute("/forgot-password")({
component: ForgotPasswordPage,
});

View file

@ -1,46 +0,0 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { signIn, useSession } from "../lib/auth-client";
const LoginPage = () => {
const { data: session, isPending } = useSession();
const navigate = useNavigate();
if (isPending) return <div className="p-4">Loading...</div>;
if (session) {
void navigate({ to: "/" });
return null;
}
return (
<div className="flex flex-col items-center justify-center gap-4 p-8">
<h1 className="text-2xl font-bold">sign in to lila</h1>
<button
className="w-64 rounded-2xl bg-(--color-text) px-4 py-3 text-white font-bold hover:opacity-90 shadow-sm hover:shadow-md transition-all"
onClick={() => {
void signIn
.social({ provider: "github", callbackURL: window.location.origin })
.catch((err) => {
console.error("GitHub sign in error:", err);
});
}}
>
Continue with GitHub
</button>
<button
className="w-64 rounded-2xl bg-(--color-primary) px-4 py-3 text-white font-bold hover:bg-(--color-primary-dark) shadow-sm hover:shadow-md transition-all"
onClick={() => {
void signIn
.social({ provider: "google", callbackURL: window.location.origin })
.catch((err) => {
console.error("Google sign in error:", err);
});
}}
>
Continue with Google
</button>
</div>
);
};
export const Route = createFileRoute("/login")({ component: LoginPage });

View file

@ -14,7 +14,10 @@ export const Route = createFileRoute("/multiplayer")({
beforeLoad: async () => {
const { data: session } = await authClient.getSession();
if (!session) {
throw redirect({ to: "/login" });
throw redirect({
to: "/",
search: { modal: "auth", redirect: "/multiplayer" },
});
}
return { session };
},

View file

@ -132,7 +132,7 @@ export const Route = createFileRoute("/play")({
beforeLoad: async () => {
const { data: session } = await authClient.getSession();
if (!session) {
throw redirect({ to: "/login" });
throw redirect({ to: "/", search: { modal: "auth", redirect: "/play" } });
}
},
});

View file

@ -0,0 +1,91 @@
import { useState } from "react";
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
import { authClient } from "../lib/auth-client";
import { toast } from "sonner";
import { ResetPasswordSearchSchema } from "@lila/shared";
function ResetPasswordPage() {
const { token } = Route.useSearch();
const navigate = useNavigate();
const [password, setPassword] = useState("");
const [isPending, setIsPending] = useState(false);
if (!token) {
return (
<div className="flex flex-col items-center justify-center gap-4 p-8 max-w-sm mx-auto text-center">
<h1 className="text-2xl font-black tracking-tight text-(--color-text)">
Invalid link
</h1>
<p className="text-sm text-(--color-text-muted)">
This reset link is invalid or has expired.
</p>
<Link
to="/forgot-password"
className="text-sm text-(--color-primary) hover:underline"
>
Request a new one
</Link>
</div>
);
}
const handleSubmit = async () => {
setIsPending(true);
await authClient.resetPassword(
{ newPassword: password, token },
{
onSuccess: () => {
toast.success("Password updated. You can now sign in.");
void navigate({ to: "/" });
},
onError: (ctx) => {
toast.error(ctx.error.message ?? "Something went wrong.");
setIsPending(false);
},
},
);
};
return (
<div className="flex flex-col items-center justify-center gap-6 p-8 max-w-sm mx-auto">
<div className="w-full text-center">
<h1 className="text-2xl font-black tracking-tight text-(--color-text)">
Reset password
</h1>
<p className="mt-1 text-sm text-(--color-text-muted)">
Enter your new password below.
</p>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
void handleSubmit();
}}
className="w-full flex flex-col gap-3"
>
<input
type="password"
placeholder="New password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
className="w-full rounded-2xl border border-(--color-primary-light) bg-white px-4 py-3 text-sm text-(--color-text) placeholder:text-(--color-text-muted) focus:outline-none focus:ring-2 focus:ring-(--color-primary)"
/>
<button
type="submit"
disabled={isPending}
className="w-full rounded-2xl bg-(--color-primary) px-4 py-3 text-white font-bold hover:bg-(--color-primary-dark) transition-colors disabled:opacity-50"
>
{isPending ? "Updating..." : "Update password"}
</button>
</form>
</div>
);
}
export const Route = createFileRoute("/reset-password")({
component: ResetPasswordPage,
validateSearch: ResetPasswordSearchSchema,
});