redone player/user structure. player can only be in one lobby; realtime with trpc subscriptions and redis
This commit is contained in:
parent
a00116a61d
commit
2db3af2dfc
@ -41,6 +41,7 @@
|
||||
"dotenv": "^16.4.7",
|
||||
"drizzle-orm": "^0.41.0",
|
||||
"express": "^4.21.2",
|
||||
"ioredis": "^5.6.0",
|
||||
"lucide-react": "^0.483.0",
|
||||
"mysql2": "^3.11.0",
|
||||
"next": "^15.2.3",
|
||||
|
||||
61
pnpm-lock.yaml
generated
61
pnpm-lock.yaml
generated
@ -71,6 +71,9 @@ importers:
|
||||
express:
|
||||
specifier: ^4.21.2
|
||||
version: 4.21.2
|
||||
ioredis:
|
||||
specifier: ^5.6.0
|
||||
version: 5.6.0
|
||||
lucide-react:
|
||||
specifier: ^0.483.0
|
||||
version: 0.483.0(react@19.0.0)
|
||||
@ -826,6 +829,9 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@ioredis/commands@1.2.0':
|
||||
resolution: {integrity: sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==}
|
||||
|
||||
'@napi-rs/wasm-runtime@0.2.7':
|
||||
resolution: {integrity: sha512-5yximcFK5FNompXfJFoWanu5l8v1hNGqNHh9du1xETp9HWk/B/PzvchX55WYOPaIeNglG8++68AAiauBAtbnzw==}
|
||||
|
||||
@ -1691,6 +1697,10 @@ packages:
|
||||
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
cluster-key-slot@1.1.2:
|
||||
resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
color-convert@2.0.1:
|
||||
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
|
||||
engines: {node: '>=7.0.0'}
|
||||
@ -2308,6 +2318,10 @@ packages:
|
||||
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
ioredis@5.6.0:
|
||||
resolution: {integrity: sha512-tBZlIIWbndeWBWCXWZiqtOF/yxf6yZX3tAlTJ7nfo5jhd6dctNxF7QnYlZLZ1a0o0pDoen7CgZqO+zjNaFbJAg==}
|
||||
engines: {node: '>=12.22.0'}
|
||||
|
||||
ipaddr.js@1.9.1:
|
||||
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
||||
engines: {node: '>= 0.10'}
|
||||
@ -2551,6 +2565,12 @@ packages:
|
||||
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
lodash.defaults@4.2.0:
|
||||
resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==}
|
||||
|
||||
lodash.isarguments@3.1.0:
|
||||
resolution: {integrity: sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==}
|
||||
|
||||
lodash.merge@4.6.2:
|
||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||
|
||||
@ -2950,6 +2970,14 @@ packages:
|
||||
resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
redis-errors@1.2.0:
|
||||
resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
redis-parser@3.0.0:
|
||||
resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
reflect.getprototypeof@1.0.10:
|
||||
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@ -3102,6 +3130,9 @@ packages:
|
||||
stable-hash@0.0.5:
|
||||
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
|
||||
|
||||
standard-as-callback@2.1.0:
|
||||
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
|
||||
|
||||
statuses@2.0.1:
|
||||
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@ -3745,6 +3776,8 @@ snapshots:
|
||||
'@img/sharp-win32-x64@0.33.5':
|
||||
optional: true
|
||||
|
||||
'@ioredis/commands@1.2.0': {}
|
||||
|
||||
'@napi-rs/wasm-runtime@0.2.7':
|
||||
dependencies:
|
||||
'@emnapi/core': 1.3.1
|
||||
@ -4592,6 +4625,8 @@ snapshots:
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
cluster-key-slot@1.1.2: {}
|
||||
|
||||
color-convert@2.0.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
@ -5367,6 +5402,20 @@ snapshots:
|
||||
hasown: 2.0.2
|
||||
side-channel: 1.1.0
|
||||
|
||||
ioredis@5.6.0:
|
||||
dependencies:
|
||||
'@ioredis/commands': 1.2.0
|
||||
cluster-key-slot: 1.1.2
|
||||
debug: 4.4.0
|
||||
denque: 2.1.0
|
||||
lodash.defaults: 4.2.0
|
||||
lodash.isarguments: 3.1.0
|
||||
redis-errors: 1.2.0
|
||||
redis-parser: 3.0.0
|
||||
standard-as-callback: 2.1.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
ipaddr.js@1.9.1: {}
|
||||
|
||||
is-array-buffer@3.0.5:
|
||||
@ -5593,6 +5642,10 @@ snapshots:
|
||||
dependencies:
|
||||
p-locate: 5.0.0
|
||||
|
||||
lodash.defaults@4.2.0: {}
|
||||
|
||||
lodash.isarguments@3.1.0: {}
|
||||
|
||||
lodash.merge@4.6.2: {}
|
||||
|
||||
long@5.3.1: {}
|
||||
@ -5902,6 +5955,12 @@ snapshots:
|
||||
|
||||
react@19.0.0: {}
|
||||
|
||||
redis-errors@1.2.0: {}
|
||||
|
||||
redis-parser@3.0.0:
|
||||
dependencies:
|
||||
redis-errors: 1.2.0
|
||||
|
||||
reflect.getprototypeof@1.0.10:
|
||||
dependencies:
|
||||
call-bind: 1.0.8
|
||||
@ -6128,6 +6187,8 @@ snapshots:
|
||||
|
||||
stable-hash@0.0.5: {}
|
||||
|
||||
standard-as-callback@2.1.0: {}
|
||||
|
||||
statuses@2.0.1: {}
|
||||
|
||||
streamsearch@1.1.0: {}
|
||||
|
||||
@ -1,10 +1,7 @@
|
||||
import { api } from "@/trpc/server";
|
||||
import type { User } from "next-auth";
|
||||
import { notFound } from "next/navigation";
|
||||
import React from "react";
|
||||
import type { PublicUser } from "@/server/auth/config";
|
||||
import { auth } from "@/server/auth";
|
||||
import LobbyPage from "@/app/_components/lobby-page";
|
||||
import LobbyPage from "@/app/_components/lobby/lobby-page";
|
||||
|
||||
async function Page({
|
||||
params,
|
||||
@ -13,17 +10,12 @@ async function Page({
|
||||
id: string;
|
||||
}>;
|
||||
}) {
|
||||
const session = await auth();
|
||||
const sessionPlayer = await api.player.getBySession();
|
||||
const { id } = await params;
|
||||
const lobby = await api.lobby.get({ id });
|
||||
if (!lobby) return notFound();
|
||||
|
||||
const members: Array<{ leader: boolean } & PublicUser> = [
|
||||
{ ...lobby.leader, leader: true },
|
||||
...(lobby?.members?.map(({ user }) => ({ ...user, leader: false })) ?? []),
|
||||
];
|
||||
|
||||
return <LobbyPage lobby={lobby} initialMembers={members} session={session} />;
|
||||
return <LobbyPage lobby={lobby} sessionPlayer={sessionPlayer} />;
|
||||
}
|
||||
|
||||
export default Page;
|
||||
|
||||
@ -1,36 +1,29 @@
|
||||
import React from "react";
|
||||
import { api } from "@/trpc/server";
|
||||
import CreateLobbyDialog from "@/app/_components/create-lobby-dialog";
|
||||
import LobbyCard from "@/app/_components/lobby-card";
|
||||
import CreateLobbyDialog from "@/app/_components/lobby/create-lobby-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import LobbyPage from "@/app/_components/lobby/lobby-page";
|
||||
import { redirect } from "next/navigation";
|
||||
import { appRoutes } from "@/config/app.routes";
|
||||
|
||||
async function Page() {
|
||||
const lobbies = await api.lobby.getAll();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full max-w-md space-y-4">
|
||||
<div className="flex w-full gap-4">
|
||||
<CreateLobbyDialog className="grow" />
|
||||
<Button
|
||||
variant={"party"}
|
||||
size={"xxl"}
|
||||
className="container-bg bg-border/10"
|
||||
>
|
||||
Placeholder
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<menu>
|
||||
{lobbies.map((lobby) => (
|
||||
<li key={lobby.id}>
|
||||
<LobbyCard lobby={lobby} />
|
||||
</li>
|
||||
))}
|
||||
</menu>
|
||||
const sessionPlayer = await api.player.getBySession();
|
||||
if (!sessionPlayer) return redirect(appRoutes.signIn);
|
||||
const lobby = sessionPlayer ? await api.lobby.getCurrentLobby() : null;
|
||||
if (!lobby)
|
||||
return (
|
||||
<div className="flex w-full gap-4">
|
||||
<CreateLobbyDialog className="grow" />
|
||||
<Button
|
||||
variant={"party"}
|
||||
size={"xxl"}
|
||||
className="container-bg bg-border/10"
|
||||
>
|
||||
Placeholder
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
);
|
||||
return <LobbyPage lobby={lobby} sessionPlayer={sessionPlayer} />;
|
||||
}
|
||||
|
||||
export default Page;
|
||||
|
||||
7
src/app/(routes)/me/edit/page.tsx
Normal file
7
src/app/(routes)/me/edit/page.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import React from "react";
|
||||
|
||||
function page() {
|
||||
return <div>Session player profile edit page</div>;
|
||||
}
|
||||
|
||||
export default page;
|
||||
7
src/app/(routes)/me/page.tsx
Normal file
7
src/app/(routes)/me/page.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import React from "react";
|
||||
|
||||
function Page() {
|
||||
return <div>Session player profile </div>;
|
||||
}
|
||||
|
||||
export default Page;
|
||||
@ -1,13 +1,17 @@
|
||||
import Link from "next/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Sparkles, Users, Plus } from "lucide-react";
|
||||
import CreateLobbyDialog from "../_components/create-lobby-dialog";
|
||||
import CreateLobbyDialog from "../_components/lobby/create-lobby-dialog";
|
||||
import { auth } from "@/server/auth";
|
||||
import { Icons } from "@/components/icons";
|
||||
import { appConfig } from "@/app.config";
|
||||
import { appConfig } from "@/config/app.config";
|
||||
import { api } from "@/trpc/server";
|
||||
import { appRoutes } from "@/config/app.routes";
|
||||
|
||||
export default async function QuizGameStartPage() {
|
||||
const session = await auth();
|
||||
const currentLobby = session ? await api.lobby.getCurrentLobby() : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-8 flex flex-col items-center justify-center gap-2 pt-12 text-center">
|
||||
@ -21,43 +25,47 @@ export default async function QuizGameStartPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Game card */}
|
||||
<div className="container-bg max-w-md p-8">
|
||||
{/* Create Lobby Button */}
|
||||
<div className="space-y-6">
|
||||
{session ? (
|
||||
<CreateLobbyDialog className="text-shadow-primary w-full" />
|
||||
) : (
|
||||
<div className="container-bg w-full max-w-md p-8">
|
||||
{currentLobby ? (
|
||||
<Button variant={"party"} size={"xxl"} className="w-full" asChild>
|
||||
<Link href={appRoutes.currentlobby}>Jump to Lobby</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
{session ? (
|
||||
<CreateLobbyDialog className="text-shadow-primary w-full" />
|
||||
) : (
|
||||
<Button
|
||||
size={"xxl"}
|
||||
variant={"party"}
|
||||
asChild
|
||||
className="text-shadow-primary w-full font-black uppercase"
|
||||
>
|
||||
<Link href={"/api/auth/signin"}>Sign-in to create a Lobby</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Join Lobby Button */}
|
||||
<Button
|
||||
size={"xxl"}
|
||||
variant={"party"}
|
||||
asChild
|
||||
className="text-shadow-primary w-full font-black uppercase"
|
||||
className="w-full rounded-xl bg-gradient-to-r from-blue-500 via-blue-600 to-indigo-600 text-white hover:from-blue-600 hover:to-indigo-700"
|
||||
>
|
||||
<Link href={"/api/auth/signin"}>Sign-in to create a Lobby</Link>
|
||||
<Users className="mr-2 h-6 w-6" />
|
||||
Join Lobby
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Join Lobby Button */}
|
||||
<Button
|
||||
size={"xxl"}
|
||||
variant={"party"}
|
||||
className="w-full rounded-xl bg-gradient-to-r from-blue-500 via-blue-600 to-indigo-600 text-white hover:from-blue-600 hover:to-indigo-700"
|
||||
>
|
||||
<Users className="mr-2 h-6 w-6" />
|
||||
Join Lobby
|
||||
</Button>
|
||||
|
||||
{/* Quick Play Option */}
|
||||
<div className="border-t border-white/10 pt-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full text-indigo-200 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
Quick Play
|
||||
</Button>
|
||||
{/* Quick Play Option */}
|
||||
<div className="border-t border-white/10 pt-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full text-indigo-200 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
Quick Play
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -1,9 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
function page() {
|
||||
return (
|
||||
<div>page</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default page
|
||||
@ -1,57 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import type { PublicUser } from "@/server/auth/config";
|
||||
import type { Lobby } from "@/server/db/schema";
|
||||
import UserCard from "@/components/user-card";
|
||||
import DeleteLobbyDialog from "@/app/_components/delete-lobby-dialog";
|
||||
import LobbyMembershipDialog from "@/app/_components/lobby-membership-dialog";
|
||||
import { type Session } from "next-auth";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
function LobbyPage({
|
||||
session,
|
||||
initialMembers,
|
||||
lobby,
|
||||
}: {
|
||||
session: Session | null;
|
||||
lobby: Pick<Lobby, "id" | "name" | "createdAt" | "createdById">;
|
||||
initialMembers: Array<{ leader: boolean } & PublicUser>;
|
||||
}) {
|
||||
const [members, setMembers] = React.useState(initialMembers);
|
||||
const [memberPresence, setMemberPresence] = React.useState<Array<string>>();
|
||||
const isJoined = members.find((member) => member.id === session?.user.id);
|
||||
const isOwner = lobby.createdById === session?.user.id;
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="container-bg w-full max-w-md space-y-4 p-6">
|
||||
<h1 className="text-2xl font-bold capitalize">{lobby.name}</h1>
|
||||
<ul className="space-y-2">
|
||||
{members?.map((member, idx) => (
|
||||
<li key={idx}>
|
||||
<UserCard
|
||||
name={member.name!}
|
||||
image={member.image!}
|
||||
className="relative"
|
||||
>
|
||||
{member?.leader && (
|
||||
<Badge className="absolute -top-2 right-2 p-px px-2 text-white">
|
||||
Leader
|
||||
</Badge>
|
||||
)}
|
||||
</UserCard>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{JSON.stringify(memberPresence)}
|
||||
{isOwner && <DeleteLobbyDialog lobbyId={lobby.id} />}
|
||||
{!isOwner && (
|
||||
<LobbyMembershipDialog lobbyId={lobby.id} join={!isJoined} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LobbyPage;
|
||||
@ -12,7 +12,7 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import LobbyForm from "./lobby/lobby-form";
|
||||
import LobbyForm from "./lobby-form";
|
||||
|
||||
function CreateLobbyDialog({ className }: { className?: string }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
@ -16,6 +16,7 @@ import {
|
||||
import { api } from "@/trpc/react";
|
||||
import { toast } from "sonner";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { appRoutes } from "@/config/app.routes";
|
||||
|
||||
function DeleteLobbyDialog({ lobbyId }: { lobbyId: string }) {
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
@ -25,7 +26,8 @@ function DeleteLobbyDialog({ lobbyId }: { lobbyId: string }) {
|
||||
setLoading(true);
|
||||
const result = await mutateAsync({ lobbyId });
|
||||
if (result) {
|
||||
router.push("/");
|
||||
router.push(appRoutes.home);
|
||||
router.refresh();
|
||||
} else toast.error("Something went wrong");
|
||||
setLoading(false);
|
||||
};
|
||||
@ -33,7 +35,7 @@ function DeleteLobbyDialog({ lobbyId }: { lobbyId: string }) {
|
||||
return (
|
||||
<AlertDialog>
|
||||
<Button asChild variant={"destructive"}>
|
||||
<AlertDialogTrigger>Remove Lobby</AlertDialogTrigger>
|
||||
<AlertDialogTrigger>Delete Lobby</AlertDialogTrigger>
|
||||
</Button>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
@ -9,10 +9,11 @@ import {
|
||||
import Link from "next/link";
|
||||
import { formatDate } from "@/lib/utils";
|
||||
import { ArrowRight, ChevronRight } from "lucide-react";
|
||||
import { appRoutes } from "@/config/app.routes";
|
||||
|
||||
function LobbyCard({ lobby }: { lobby: Lobby }) {
|
||||
return (
|
||||
<Link href={`/lobby/${lobby.id}`}>
|
||||
<Link href={appRoutes.lobby(lobby.id)}>
|
||||
<Card className="group border-none bg-transparent p-0 shadow-none">
|
||||
<div className="container-bg py-4">
|
||||
<CardHeader className="flex w-full items-center justify-between">
|
||||
@ -19,6 +19,7 @@ import { toast } from "sonner";
|
||||
import { api } from "@/trpc/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { appRoutes } from "@/config/app.routes";
|
||||
|
||||
function LobbyForm({
|
||||
server_lobby,
|
||||
@ -34,7 +35,8 @@ function LobbyForm({
|
||||
const form = useForm<z.infer<typeof lobbyPatchSchema>>({
|
||||
resolver: zodResolver(lobbyPatchSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
name: server_lobby?.name ?? "",
|
||||
maxPlayers: server_lobby?.maxPlayers ?? 0,
|
||||
},
|
||||
});
|
||||
async function onSubmit(lobby: z.infer<typeof lobbyPatchSchema>) {
|
||||
@ -45,7 +47,10 @@ function LobbyForm({
|
||||
: await createLobby({ lobby });
|
||||
cb?.();
|
||||
if (result) {
|
||||
if (!existingLobby) router.push(`/lobby/${result.id}`);
|
||||
if (!existingLobby) {
|
||||
router.push(appRoutes.currentlobby);
|
||||
router.refresh();
|
||||
}
|
||||
} else toast.error("Something went wrong.");
|
||||
setLoading(false);
|
||||
}
|
||||
@ -70,9 +75,15 @@ function LobbyForm({
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button type="submit" disabled={loading || !form.formState.isDirty}>
|
||||
Create Lobby
|
||||
</Button>
|
||||
<div className="flex items-center justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading || !form.formState.isDirty}
|
||||
className="ml-auto"
|
||||
>
|
||||
{server_lobby?.id?.length ? "Update" : "Create"} Lobby
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
|
||||
@ -15,7 +15,6 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { api } from "@/trpc/react";
|
||||
import { toast } from "sonner";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
function LobbyMembershipDialog({
|
||||
lobbyId,
|
||||
@ -25,23 +24,21 @@ function LobbyMembershipDialog({
|
||||
join: boolean;
|
||||
}) {
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const { mutateAsync } = api.lobby.membership.useMutation();
|
||||
const router = useRouter();
|
||||
const membership = api.lobby.membership.useMutation();
|
||||
const labelText = join ? "join" : "leave";
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setLoading(true);
|
||||
const result = await mutateAsync({ lobbyId, join });
|
||||
const result = await membership.mutateAsync({ lobbyId, join });
|
||||
if (result) {
|
||||
if (!join) router.push("/");
|
||||
else toast.success("Successfully joined the lobby.");
|
||||
toast.success(`Successfully ${labelText} the lobby.`);
|
||||
} else toast.error("Something went wrong");
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const labelText = join ? "join" : "leave";
|
||||
|
||||
return (
|
||||
<AlertDialog>
|
||||
<Button asChild>
|
||||
<Button asChild variant={join ? "default" : "destructive"}>
|
||||
<AlertDialogTrigger className="capitalize">
|
||||
{labelText} Lobby
|
||||
</AlertDialogTrigger>
|
||||
@ -62,7 +59,7 @@ function LobbyMembershipDialog({
|
||||
disabled={loading}
|
||||
className="capitalize"
|
||||
>
|
||||
{labelText}
|
||||
{labelText} Lobby
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
82
src/app/_components/lobby/lobby-page.tsx
Normal file
82
src/app/_components/lobby/lobby-page.tsx
Normal file
@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import type { Lobby, LobbyMember, Player } from "@/server/db/schema";
|
||||
import LobbyPlayerCard from "@/app/_components/lobby/lobby_player-card";
|
||||
import DeleteLobbyDialog from "@/app/_components/lobby/delete-lobby-dialog";
|
||||
import LobbyMembershipDialog from "@/app/_components/lobby/lobby-membership-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { api } from "@/trpc/react";
|
||||
import CopyToClip from "@/components/copy-to-clip";
|
||||
import { appRoutes } from "@/config/app.routes";
|
||||
import LobbySettingsDialog from "./lobby-settings-dialog";
|
||||
|
||||
function LobbyPage({
|
||||
sessionPlayer,
|
||||
|
||||
lobby,
|
||||
}: {
|
||||
sessionPlayer?: Player | null;
|
||||
lobby: Lobby;
|
||||
}) {
|
||||
const [members, setMembers] = React.useState<Array<LobbyMember>>(
|
||||
lobby?.members ?? [],
|
||||
);
|
||||
api.lobby.onMemberUpdate.useSubscription(undefined, {
|
||||
onData({ data }) {
|
||||
if (data.joined)
|
||||
setMembers((prev) => [...prev, data.membership as LobbyMember]);
|
||||
else
|
||||
setMembers((prev) =>
|
||||
prev.filter((m) => m.playerId !== data.membership),
|
||||
);
|
||||
console.log("Data", data);
|
||||
},
|
||||
});
|
||||
|
||||
const isJoined = members.find((m) => m.playerId === sessionPlayer?.id);
|
||||
const isAdmin = isJoined?.role === "admin";
|
||||
|
||||
return (
|
||||
<div className="container-bg w-full max-w-md space-y-4 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="w-full overflow-hidden text-2xl font-bold text-ellipsis capitalize">
|
||||
{lobby.name}
|
||||
</h1>
|
||||
{isAdmin && <LobbySettingsDialog lobby={lobby} />}
|
||||
</div>
|
||||
|
||||
<ul className="space-y-2">
|
||||
{members?.map((member, idx) => (
|
||||
<li key={idx}>
|
||||
<LobbyPlayerCard player={member?.player!} className="relative">
|
||||
{member?.role === "admin" && (
|
||||
<Badge className="absolute -top-2 right-2 p-px px-2 text-white">
|
||||
Admin
|
||||
</Badge>
|
||||
)}
|
||||
</LobbyPlayerCard>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="flex items-center gap-2">
|
||||
{isJoined && (
|
||||
<CopyToClip
|
||||
target="invite link"
|
||||
text={appRoutes.lobby(lobby.id)}
|
||||
buttonProps={{ variant: "outline" }}
|
||||
>
|
||||
Invite Players
|
||||
</CopyToClip>
|
||||
)}
|
||||
<div className="ml-auto">
|
||||
{sessionPlayer && (
|
||||
<LobbyMembershipDialog lobbyId={lobby.id} join={!isJoined} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LobbyPage;
|
||||
41
src/app/_components/lobby/lobby-settings-dialog.tsx
Normal file
41
src/app/_components/lobby/lobby-settings-dialog.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
"use client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { Lobby } from "@/server/db/schema";
|
||||
import { DialogDescription } from "@radix-ui/react-dialog";
|
||||
import React from "react";
|
||||
import LobbyForm from "./lobby-form";
|
||||
import DeleteLobbyDialog from "./delete-lobby-dialog";
|
||||
import { Settings } from "lucide-react";
|
||||
|
||||
function LobbySettingsDialog({ lobby }: { lobby: Lobby }) {
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant={"ghost"} size={"icon"}>
|
||||
<Settings className="size-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogTitle>Lobby Settings</DialogTitle>
|
||||
<DialogDescription></DialogDescription>
|
||||
|
||||
<LobbyForm server_lobby={lobby} />
|
||||
<div className="flex items-center">
|
||||
<div className="bg-muted-foreground h-px w-12" />
|
||||
<p className="relative z-10 px-2 text-xs">Danger Zone</p>
|
||||
<div className="bg-muted-foreground h-px grow" />
|
||||
</div>
|
||||
|
||||
<DeleteLobbyDialog lobbyId={lobby.id} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default LobbySettingsDialog;
|
||||
@ -1,17 +1,16 @@
|
||||
import React from "react";
|
||||
import Avatar from "./avatar";
|
||||
import Avatar from "../../../components/avatar";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MoreHorizontal } from "lucide-react";
|
||||
import { Button } from "./ui/button";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import type { Player } from "@/server/db/schema";
|
||||
|
||||
function UserCard({
|
||||
name,
|
||||
image,
|
||||
function LobbyPlayerCard({
|
||||
player,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
name: string;
|
||||
image: string;
|
||||
player: Pick<Player, "displayName" | "avatar">;
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
@ -23,8 +22,8 @@ function UserCard({
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar src={image} fb={name} />
|
||||
<h4 className="font-meidum text-xl">{name}</h4>
|
||||
<Avatar src={player.avatar!} fb={player.displayName!} />
|
||||
<h4 className="font-meidum text-xl">{player.displayName}</h4>
|
||||
</div>
|
||||
{children}
|
||||
<Button
|
||||
@ -38,4 +37,4 @@ function UserCard({
|
||||
);
|
||||
}
|
||||
|
||||
export default UserCard;
|
||||
export default LobbyPlayerCard;
|
||||
@ -8,36 +8,37 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import type { User } from "next-auth";
|
||||
import Avatar from "@/components/avatar";
|
||||
import { Edit, Eye, LogOut } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Player } from "@/server/db/schema";
|
||||
import { appRoutes } from "@/config/app.routes";
|
||||
|
||||
function UserPopover({ user }: { user: User }) {
|
||||
function UserPopover({ player }: { player: Player }) {
|
||||
const dropdownItemClassName =
|
||||
"focus:bg-border/30 focus:text-white flex items-center gap-1 ";
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<Avatar
|
||||
src={user.image!}
|
||||
fb={user.name!}
|
||||
src={player.avatar!}
|
||||
fb={player.displayName!}
|
||||
className="border-border/20 size-10 cursor-pointer border-2"
|
||||
/>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="bg-border/30 container-bg text-white">
|
||||
<DropdownMenuLabel className="w-32 truncate overflow-hidden font-bold">
|
||||
{user.name}
|
||||
{player.displayName}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className={dropdownItemClassName} asChild>
|
||||
<Link href={`/profile/${user.id}`}>
|
||||
<Link href={appRoutes.me}>
|
||||
<Eye className="size-4 text-white" />
|
||||
View Profile
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className={dropdownItemClassName} asChild>
|
||||
<Link href={`/profile/${user.id}/edit`}>
|
||||
<Link href={appRoutes.editProfile}>
|
||||
<Edit className="size-4 text-white" />
|
||||
Edit Profile
|
||||
</Link>
|
||||
@ -50,7 +51,7 @@ function UserPopover({ user }: { user: User }) {
|
||||
)}
|
||||
asChild
|
||||
>
|
||||
<Link href="/api/auth/signout">
|
||||
<Link href={appRoutes.signOut}>
|
||||
<LogOut className="group-focus:text-destructive size-4 text-white" />
|
||||
Log out
|
||||
</Link>
|
||||
28
src/components/copy-to-clip.tsx
Normal file
28
src/components/copy-to-clip.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
import React from "react";
|
||||
import { Button, type ButtonProps } from "./ui/button";
|
||||
import { toast } from "sonner";
|
||||
|
||||
function CopyToClip({
|
||||
text,
|
||||
target,
|
||||
buttonProps,
|
||||
children,
|
||||
}: {
|
||||
text: string;
|
||||
target?: string;
|
||||
buttonProps?: ButtonProps;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const handleclick = () => {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success(`Copied ${target ?? ""} to clipboard`);
|
||||
};
|
||||
return (
|
||||
<Button {...buttonProps} onClick={handleclick}>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default CopyToClip;
|
||||
@ -1,7 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import React from "react";
|
||||
import { Icons } from "./icons";
|
||||
import { appConfig } from "@/app.config";
|
||||
import { appConfig } from "@/config/app.config";
|
||||
|
||||
function Footer() {
|
||||
return (
|
||||
|
||||
@ -1,13 +1,16 @@
|
||||
import React from "react";
|
||||
import { auth } from "@/server/auth";
|
||||
import { Button } from "./ui/button";
|
||||
import Link from "next/link";
|
||||
import UserPopover from "@/app/_components/user-popover";
|
||||
import { appConfig } from "@/app.config";
|
||||
import UserPopover from "@/app/_components/profile-popover";
|
||||
import { appConfig } from "@/config/app.config";
|
||||
import NavLink from "./nav-link";
|
||||
import { api } from "@/trpc/server";
|
||||
import { Icons } from "./icons";
|
||||
import { appRoutes } from "@/config/app.routes";
|
||||
|
||||
async function Navbar() {
|
||||
const session = await auth();
|
||||
const sessionPlayer = await api.player.getBySession();
|
||||
const currentLobby = sessionPlayer ? await api.lobby.getCurrentLobby() : null;
|
||||
|
||||
return (
|
||||
<nav className="flex w-full items-center justify-center p-4">
|
||||
@ -19,14 +22,24 @@ async function Navbar() {
|
||||
</li>
|
||||
))}
|
||||
</menu>
|
||||
{/* Login Button or Profile Avatar */}
|
||||
{session?.user ? (
|
||||
<UserPopover user={session.user} />
|
||||
) : (
|
||||
<Button asChild className="bg-primary font-black">
|
||||
<Link href="/api/auth/signin">Sign In</Link>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="bg-border/10 border-border/20 flex items-center rounded-full border-l-2">
|
||||
{currentLobby ? (
|
||||
<Link href={appRoutes.currentlobby}>
|
||||
<div className="border-border/20 flex h-10 items-center justify-center gap-2 pr-2 pl-4 font-semibold">
|
||||
<Icons.logo className="size-4" />
|
||||
<span>Lobby</span>
|
||||
</div>
|
||||
</Link>
|
||||
) : null}
|
||||
{sessionPlayer ? (
|
||||
<UserPopover player={sessionPlayer} />
|
||||
) : (
|
||||
<Button asChild className="bg-primary font-black">
|
||||
<Link href="/api/auth/signin">Sign In</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
|
||||
@ -1,15 +1,15 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
|
||||
import * as React from "react";
|
||||
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
|
||||
function AlertDialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({
|
||||
@ -17,7 +17,7 @@ function AlertDialogTrigger({
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogPortal({
|
||||
@ -25,7 +25,7 @@ function AlertDialogPortal({
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
@ -37,11 +37,11 @@ function AlertDialogOverlay({
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
@ -54,13 +54,13 @@ function AlertDialogContent({
|
||||
<AlertDialogPrimitive.Content
|
||||
data-slot="alert-dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
"bg-background/20 container-bg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
@ -73,7 +73,7 @@ function AlertDialogHeader({
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
@ -85,11 +85,11 @@ function AlertDialogFooter({
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
@ -102,7 +102,7 @@ function AlertDialogTitle({
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
@ -115,7 +115,7 @@ function AlertDialogDescription({
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
@ -127,7 +127,7 @@ function AlertDialogAction({
|
||||
className={cn(buttonVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
@ -139,7 +139,7 @@ function AlertDialogCancel({
|
||||
className={cn(buttonVariants({ variant: "outline" }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@ -154,4 +154,4 @@ export {
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
};
|
||||
|
||||
@ -14,14 +14,13 @@ const buttonVariants = cva(
|
||||
destructive:
|
||||
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
"border bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||
ghost:
|
||||
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 rounded-full",
|
||||
ghost: "hover:bg-background/10 dark:hover:bg-accent/50 rounded-full",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
party:
|
||||
"bg-gradient-to-r from-secondary via-primary to-secondary text-shadow-primary text-primary-foreground rounded-xl border-b-4 shadow-lg font-bold hover:translate-y-[0.1rem] hover:border-b-2 ",
|
||||
"bg-gradient-to-r from-secondary via-primary to-secondary text-shadow-primary text-primary-foreground rounded-xl border-b-4 shadow-lg font-bold hover:translate-y-[0.1rem] hover:border-b-2 ",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
@ -38,16 +37,18 @@ const buttonVariants = cva(
|
||||
},
|
||||
);
|
||||
|
||||
export type ButtonProps = React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
};
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
}: ButtonProps) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
|
||||
@ -1,33 +1,33 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { XIcon } from "lucide-react"
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
@ -39,11 +39,11 @@ function DialogOverlay({
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
@ -57,8 +57,8 @@ function DialogContent({
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className
|
||||
"bg-background/20 container-bg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
@ -69,7 +69,7 @@ function DialogContent({
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@ -79,7 +79,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
@ -88,11 +88,11 @@ function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
@ -105,7 +105,7 @@ function DialogTitle({
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
@ -118,7 +118,7 @@ function DialogDescription({
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
@ -132,4 +132,4 @@ export {
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
};
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { appRoutes } from "./app.routes";
|
||||
|
||||
type AppConfig = {
|
||||
name: string;
|
||||
description: string;
|
||||
@ -10,15 +12,11 @@ export const appConfig: AppConfig = {
|
||||
navigation: [
|
||||
{
|
||||
label: "Home",
|
||||
path: "/",
|
||||
},
|
||||
{
|
||||
label: "Lobbies",
|
||||
path: "/lobby",
|
||||
path: appRoutes.home,
|
||||
},
|
||||
{
|
||||
label: "Games",
|
||||
path: "/game",
|
||||
path: appRoutes.game,
|
||||
},
|
||||
],
|
||||
};
|
||||
13
src/config/app.routes.ts
Normal file
13
src/config/app.routes.ts
Normal file
@ -0,0 +1,13 @@
|
||||
export const appRoutes = {
|
||||
home: "/",
|
||||
game: "/game",
|
||||
currentlobby: "/lobby",
|
||||
lobby: (id: string) => `/lobby/${id}`,
|
||||
|
||||
me: "/me",
|
||||
editProfile: "/me/edit",
|
||||
playerProfile: (id: string) => `/player/${id}`,
|
||||
|
||||
signIn: "/api/auth/signin",
|
||||
signOut: "/api/auth/signout",
|
||||
};
|
||||
2
src/index.d.ts
vendored
2
src/index.d.ts
vendored
@ -2,3 +2,5 @@ type NavLink = {
|
||||
label: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
type LobbyMemberRole = "player" | "admin";
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { lobbyRouter } from "@/server/api/routers/lobby";
|
||||
import { createCallerFactory, createTRPCRouter } from "@/server/api/trpc";
|
||||
import { playerRouter } from "./routers/player";
|
||||
|
||||
/**
|
||||
* This is the primary router for your server.
|
||||
@ -8,6 +9,7 @@ import { createCallerFactory, createTRPCRouter } from "@/server/api/trpc";
|
||||
*/
|
||||
export const appRouter = createTRPCRouter({
|
||||
lobby: lobbyRouter,
|
||||
player: playerRouter,
|
||||
});
|
||||
|
||||
// export type definition of API
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import { lobbies, lobbyMembers } from "@/server/db/schema";
|
||||
import { lobbies, lobbyMembers, players } from "@/server/db/schema";
|
||||
import {
|
||||
createTRPCRouter,
|
||||
protectedProcedure,
|
||||
@ -8,25 +8,31 @@ import {
|
||||
} from "@/server/api/trpc";
|
||||
import { lobbyPatchSchema } from "@/lib/validations/lobby";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { time } from "console";
|
||||
import {
|
||||
combineRedisIterators,
|
||||
redisAsyncIterator,
|
||||
redisPublish,
|
||||
} from "@/server/redis/sse-redis";
|
||||
import { tracked } from "@trpc/server";
|
||||
|
||||
export const lobbyRouter = createTRPCRouter({
|
||||
// queries
|
||||
getAll: protectedProcedure.query(async ({ ctx }) => {
|
||||
const ownedLobbies = await ctx.db.query.lobbies.findMany({
|
||||
where: eq(lobbies.createdById, ctx.session.user.id),
|
||||
});
|
||||
const joinedLobbies = await ctx.db.query.lobbyMembers.findMany({
|
||||
where: eq(lobbyMembers.userId, ctx.session.user.id),
|
||||
getCurrentLobby: protectedProcedure.query(async ({ ctx }) => {
|
||||
const reuslt = await ctx.db.query.lobbyMembers.findFirst({
|
||||
where: eq(lobbyMembers.playerId, ctx.session.user.id),
|
||||
with: {
|
||||
lobby: true,
|
||||
lobby: {
|
||||
with: {
|
||||
members: {
|
||||
with: {
|
||||
player: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return [
|
||||
...ownedLobbies,
|
||||
...(joinedLobbies?.map(({ lobby }) => lobby) ?? []),
|
||||
];
|
||||
return reuslt?.lobby!;
|
||||
}),
|
||||
|
||||
get: publicProcedure
|
||||
@ -40,24 +46,9 @@ export const lobbyRouter = createTRPCRouter({
|
||||
await ctx.db.query.lobbies.findFirst({
|
||||
where: eq(lobbies.id, input.id),
|
||||
with: {
|
||||
leader: {
|
||||
columns: {
|
||||
image: true,
|
||||
name: true,
|
||||
id: true,
|
||||
joinedAt: true,
|
||||
},
|
||||
},
|
||||
members: {
|
||||
with: {
|
||||
user: {
|
||||
columns: {
|
||||
image: true,
|
||||
name: true,
|
||||
id: true,
|
||||
joinedAt: true,
|
||||
},
|
||||
},
|
||||
player: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -79,6 +70,15 @@ export const lobbyRouter = createTRPCRouter({
|
||||
createdById: ctx.session.user.id,
|
||||
})
|
||||
.returning({ id: lobbies.id });
|
||||
if (!lobby) throw new Error("Error creating lobby");
|
||||
await ctx.db.insert(lobbyMembers).values({
|
||||
lobbyId: lobby.id,
|
||||
playerId: ctx.session.user.id,
|
||||
isReady: false,
|
||||
|
||||
role: "admin",
|
||||
});
|
||||
|
||||
return lobby;
|
||||
}),
|
||||
update: protectedProcedure
|
||||
@ -89,6 +89,8 @@ export const lobbyRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
console.log("Check if user is admin");
|
||||
|
||||
const [lobby] = await ctx.db
|
||||
.update(lobbies)
|
||||
.set(input.lobby)
|
||||
@ -108,6 +110,8 @@ export const lobbyRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
console.log("Check if user is admin");
|
||||
|
||||
const [lobby] = await ctx.db
|
||||
.delete(lobbies)
|
||||
.where(
|
||||
@ -117,6 +121,7 @@ export const lobbyRouter = createTRPCRouter({
|
||||
),
|
||||
)
|
||||
.returning({ id: lobbies.id });
|
||||
|
||||
return lobby;
|
||||
}),
|
||||
|
||||
@ -129,30 +134,65 @@ export const lobbyRouter = createTRPCRouter({
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
if (input.join) {
|
||||
return (
|
||||
await ctx.db
|
||||
.insert(lobbyMembers)
|
||||
.values({
|
||||
lobbyId: input.lobbyId,
|
||||
userId: ctx.session.user.id,
|
||||
isReady: false,
|
||||
joinedAt: new Date(),
|
||||
role: "member",
|
||||
const [member] = await ctx.db
|
||||
.insert(lobbyMembers)
|
||||
.values({
|
||||
lobbyId: input.lobbyId,
|
||||
playerId: ctx.session.user.id,
|
||||
isReady: false,
|
||||
role: "player",
|
||||
})
|
||||
.returning();
|
||||
const player = member
|
||||
? await ctx.db.query.players.findFirst({
|
||||
where: eq(players.id, member.playerId),
|
||||
})
|
||||
.returning()
|
||||
)[0];
|
||||
: undefined;
|
||||
if (member) redisPublish("lobby:member:join", { ...member, player });
|
||||
return member;
|
||||
} else {
|
||||
return (
|
||||
await ctx.db
|
||||
.delete(lobbyMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(lobbyMembers.lobbyId, input.lobbyId),
|
||||
eq(lobbyMembers.userId, ctx.session.user.id),
|
||||
),
|
||||
)
|
||||
.returning()
|
||||
)[0];
|
||||
const [member] = await ctx.db
|
||||
.delete(lobbyMembers)
|
||||
.where(
|
||||
and(
|
||||
eq(lobbyMembers.lobbyId, input.lobbyId),
|
||||
eq(lobbyMembers.playerId, ctx.session.user.id),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
if (member) redisPublish("lobby:member:leave", member.playerId);
|
||||
return member;
|
||||
}
|
||||
}),
|
||||
|
||||
// subscriptions
|
||||
onMemberUpdate: protectedProcedure
|
||||
.input(
|
||||
z
|
||||
.object({
|
||||
lastEventId: z.string().nullish(),
|
||||
})
|
||||
.optional(),
|
||||
)
|
||||
.subscription(async function* (opts) {
|
||||
// Create an async iterator for our Redis channel.
|
||||
|
||||
if (opts.input?.lastEventId) {
|
||||
// fetch posts from a database that were missed.
|
||||
}
|
||||
|
||||
for await (const { event, data: membership } of combineRedisIterators([
|
||||
"lobby:member:join",
|
||||
"lobby:member:leave",
|
||||
])) {
|
||||
const joined = event === "lobby:member:join";
|
||||
const id =
|
||||
typeof membership === "string" ? membership : membership.playerId;
|
||||
|
||||
yield tracked(String(id), {
|
||||
joined,
|
||||
membership,
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
13
src/server/api/routers/player.ts
Normal file
13
src/server/api/routers/player.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { players } from "@/server/db/schema";
|
||||
|
||||
export const playerRouter = createTRPCRouter({
|
||||
getBySession: publicProcedure.query(async ({ ctx }) => {
|
||||
return ctx?.session?.user
|
||||
? await ctx.db.query.players.findFirst({
|
||||
where: eq(players.id, ctx.session.user.id),
|
||||
})
|
||||
: null;
|
||||
}),
|
||||
});
|
||||
@ -1,15 +1,6 @@
|
||||
import { DrizzleAdapter } from "@auth/drizzle-adapter";
|
||||
import { type DefaultSession, type NextAuthConfig, type User } from "next-auth";
|
||||
import DiscordProvider from "next-auth/providers/discord";
|
||||
|
||||
import { db } from "@/server/db";
|
||||
import {
|
||||
accounts,
|
||||
sessions,
|
||||
users,
|
||||
verificationTokens,
|
||||
} from "@/server/db/schema";
|
||||
import type { Adapter } from "next-auth/adapters";
|
||||
import { CustomDrizzleAdapter } from "./custom-drizzle-adapter";
|
||||
|
||||
/**
|
||||
* Module augmentation for `next-auth` types. Allows us to add custom properties to the `session`
|
||||
@ -17,23 +8,16 @@ import type { Adapter } from "next-auth/adapters";
|
||||
*
|
||||
* @see https://next-auth.js.org/getting-started/typescript#module-augmentation
|
||||
*/
|
||||
|
||||
declare module "next-auth" {
|
||||
interface Session extends DefaultSession {
|
||||
user: {
|
||||
id: string;
|
||||
joinedAt: Date;
|
||||
// ...other properties
|
||||
// role: UserRole;
|
||||
} & DefaultSession["user"];
|
||||
}
|
||||
|
||||
interface User {
|
||||
joinedAt: Date;
|
||||
// ...other properties
|
||||
// role: UserRole;
|
||||
email: string;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
export type PublicUser = Pick<User, "name" | "image" | "id" | "joinedAt">;
|
||||
|
||||
/**
|
||||
* Options for NextAuth.js used to configure adapters, providers, callbacks, etc.
|
||||
@ -41,33 +25,18 @@ export type PublicUser = Pick<User, "name" | "image" | "id" | "joinedAt">;
|
||||
* @see https://next-auth.js.org/configuration/options
|
||||
*/
|
||||
export const authConfig = {
|
||||
providers: [
|
||||
DiscordProvider,
|
||||
/**
|
||||
* ...add more providers here.
|
||||
*
|
||||
* Most other providers require a bit more work than the Discord provider. For example, the
|
||||
* GitHub provider requires you to add the `refresh_token_expires_in` field to the Account
|
||||
* model. Refer to the NextAuth.js docs for the provider you want to use. Example:
|
||||
*
|
||||
* @see https://next-auth.js.org/providers/github
|
||||
*/
|
||||
],
|
||||
adapter: DrizzleAdapter(db, {
|
||||
usersTable: users,
|
||||
accountsTable: accounts,
|
||||
sessionsTable: sessions,
|
||||
verificationTokensTable: verificationTokens,
|
||||
}) as Adapter,
|
||||
providers: [DiscordProvider],
|
||||
adapter: CustomDrizzleAdapter,
|
||||
|
||||
callbacks: {
|
||||
session: ({ session, user }) => ({
|
||||
...session,
|
||||
user: {
|
||||
...session.user,
|
||||
id: user.id,
|
||||
joinedAt: user.joinedAt,
|
||||
},
|
||||
}),
|
||||
session: async ({ session, user }) => {
|
||||
return {
|
||||
...session,
|
||||
user: {
|
||||
...session.user,
|
||||
id: user.id,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
} satisfies NextAuthConfig;
|
||||
|
||||
39
src/server/auth/custom-drizzle-adapter.ts
Normal file
39
src/server/auth/custom-drizzle-adapter.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import { DrizzleAdapter } from "@auth/drizzle-adapter";
|
||||
import { db } from "@/server/db";
|
||||
import {
|
||||
accounts,
|
||||
players,
|
||||
sessions,
|
||||
users,
|
||||
verificationTokens,
|
||||
} from "@/server/db/schema";
|
||||
import type { Adapter } from "next-auth/adapters";
|
||||
|
||||
export const CustomDrizzleAdapter: Adapter = {
|
||||
...(DrizzleAdapter(db, {
|
||||
usersTable: users as any,
|
||||
accountsTable: accounts,
|
||||
sessionsTable: sessions,
|
||||
verificationTokensTable: verificationTokens,
|
||||
}) as Adapter),
|
||||
|
||||
async createUser(rawUser) {
|
||||
// Insert user **without** the image field
|
||||
const [user] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
name: rawUser.name,
|
||||
email: rawUser.email,
|
||||
emailVerified: rawUser.emailVerified,
|
||||
})
|
||||
.returning();
|
||||
if (!user) throw new Error("Error creating user");
|
||||
|
||||
await db.insert(players).values({
|
||||
id: user.id,
|
||||
displayName: rawUser.name,
|
||||
avatar: rawUser.image,
|
||||
});
|
||||
return user;
|
||||
},
|
||||
};
|
||||
@ -10,6 +10,12 @@ import { createId } from "@paralleldrive/cuid2";
|
||||
*/
|
||||
export const createTable = pgTableCreator((name) => `game-master_${name}`);
|
||||
|
||||
const defaultTimeStamp = (name: string, d: any) =>
|
||||
d
|
||||
.timestamp(name, { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull();
|
||||
|
||||
export const lobbies = createTable("lobby", (d) => ({
|
||||
id: d
|
||||
.varchar({ length: 255 })
|
||||
@ -21,22 +27,22 @@ export const lobbies = createTable("lobby", (d) => ({
|
||||
createdById: d
|
||||
.varchar({ length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
createdAt: d
|
||||
.timestamp("created_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
.references(() => players.id, { onDelete: "cascade" }),
|
||||
createdAt: defaultTimeStamp("created_at", d),
|
||||
updatedAt: d
|
||||
.timestamp("updated_at", { withTimezone: true })
|
||||
.$onUpdate(() => new Date()),
|
||||
}));
|
||||
|
||||
export type Lobby = typeof lobbies.$inferSelect;
|
||||
export type Lobby = typeof lobbies.$inferSelect & {
|
||||
members?: LobbyMember[];
|
||||
leader?: Player;
|
||||
};
|
||||
|
||||
export const lobbyRelations = relations(lobbies, ({ many, one }) => ({
|
||||
leader: one(users, {
|
||||
leader: one(players, {
|
||||
fields: [lobbies.createdById],
|
||||
references: [users.id],
|
||||
references: [players.id],
|
||||
}),
|
||||
members: many(lobbyMembers),
|
||||
}));
|
||||
@ -44,38 +50,65 @@ export const lobbyRelations = relations(lobbies, ({ many, one }) => ({
|
||||
export const lobbyMembers = createTable(
|
||||
"lobby_member",
|
||||
(d) => ({
|
||||
userId: d
|
||||
playerId: d
|
||||
.varchar({ length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
.references(() => players.id, { onDelete: "cascade" }),
|
||||
lobbyId: d
|
||||
.varchar({ length: 255 })
|
||||
.notNull()
|
||||
.references(() => lobbies.id, { onDelete: "cascade" }),
|
||||
joinedAt: d.timestamp("created_at", { withTimezone: true }).notNull(),
|
||||
role: d.varchar({ length: 255 }).notNull(),
|
||||
joinedAt: defaultTimeStamp("joined_at", d),
|
||||
role: d
|
||||
.varchar({ length: 255 })
|
||||
.notNull()
|
||||
.$type<LobbyMemberRole>()
|
||||
.default("player"),
|
||||
isReady: d.boolean().notNull(),
|
||||
}),
|
||||
(t) => [primaryKey({ columns: [t.lobbyId, t.userId] })],
|
||||
(t) => [primaryKey({ columns: [t.playerId] })],
|
||||
);
|
||||
|
||||
export type LobbyMember = typeof lobbyMembers.$inferSelect & {
|
||||
player?: Player;
|
||||
};
|
||||
|
||||
export const lobbyMembersRelations = relations(lobbyMembers, ({ one }) => ({
|
||||
lobby: one(lobbies, {
|
||||
fields: [lobbyMembers.lobbyId],
|
||||
references: [lobbies.id],
|
||||
}),
|
||||
user: one(users, {
|
||||
fields: [lobbyMembers.userId],
|
||||
references: [users.id],
|
||||
player: one(players, {
|
||||
fields: [lobbyMembers.playerId],
|
||||
references: [players.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const players = createTable(
|
||||
"player",
|
||||
(d) => ({
|
||||
id: d
|
||||
.varchar({ length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
displayName: d.varchar({ length: 255 }),
|
||||
avatar: d.varchar({ length: 255 }),
|
||||
joinedAt: defaultTimeStamp("joined_at", d),
|
||||
}),
|
||||
(t) => [
|
||||
primaryKey({
|
||||
columns: [t.id],
|
||||
}),
|
||||
],
|
||||
);
|
||||
export type Player = typeof players.$inferSelect;
|
||||
|
||||
export const users = createTable("user", (d) => ({
|
||||
id: d
|
||||
.varchar({ length: 255 })
|
||||
.notNull()
|
||||
.primaryKey()
|
||||
.$defaultFn(() => crypto.randomUUID()),
|
||||
.$defaultFn(() => createId()),
|
||||
name: d.varchar({ length: 255 }),
|
||||
email: d.varchar({ length: 255 }).notNull(),
|
||||
emailVerified: d
|
||||
@ -84,11 +117,6 @@ export const users = createTable("user", (d) => ({
|
||||
withTimezone: true,
|
||||
})
|
||||
.default(sql`CURRENT_TIMESTAMP`),
|
||||
image: d.varchar({ length: 255 }),
|
||||
joinedAt: d
|
||||
.timestamp("joined_at", { withTimezone: true })
|
||||
.default(sql`CURRENT_TIMESTAMP`)
|
||||
.notNull(),
|
||||
}));
|
||||
|
||||
export const usersRelations = relations(users, ({ many }) => ({
|
||||
@ -102,7 +130,7 @@ export const accounts = createTable(
|
||||
userId: d
|
||||
.varchar({ length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
type: d.varchar({ length: 255 }).$type<AdapterAccount["type"]>().notNull(),
|
||||
provider: d.varchar({ length: 255 }).notNull(),
|
||||
providerAccountId: d.varchar({ length: 255 }).notNull(),
|
||||
@ -133,7 +161,7 @@ export const sessions = createTable(
|
||||
userId: d
|
||||
.varchar({ length: 255 })
|
||||
.notNull()
|
||||
.references(() => users.id),
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
expires: d.timestamp({ mode: "date" }).notNull(),
|
||||
}),
|
||||
(t) => [index("session_user_id_idx").on(t.userId)],
|
||||
|
||||
6
src/server/redis/events.d.ts
vendored
Normal file
6
src/server/redis/events.d.ts
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
import type { LobbyMember, Post } from "../db/schema";
|
||||
|
||||
export type PubSubEvents = {
|
||||
"lobby:member:join": LobbyMember;
|
||||
"lobby:member:leave": string;
|
||||
};
|
||||
72
src/server/redis/sse-redis.ts
Normal file
72
src/server/redis/sse-redis.ts
Normal file
@ -0,0 +1,72 @@
|
||||
import Redis from "ioredis";
|
||||
import type { PubSubEvents } from "./events";
|
||||
|
||||
const redisPub = new Redis();
|
||||
const redisSub = new Redis();
|
||||
|
||||
export const redisPublish = <T extends keyof PubSubEvents>(
|
||||
event: T,
|
||||
data: PubSubEvents[T],
|
||||
) => {
|
||||
redisPub.publish(event, JSON.stringify(data));
|
||||
};
|
||||
|
||||
const redisSubscribe = <T extends keyof PubSubEvents>(
|
||||
event: T,
|
||||
callback: (data: PubSubEvents[T]) => void,
|
||||
) => {
|
||||
redisSub.subscribe(event, (err) => {
|
||||
if (err) console.error(`Redis subscription error for ${event}:`, err);
|
||||
});
|
||||
|
||||
redisSub.on("message", (channel, message) => {
|
||||
if (channel === event) {
|
||||
callback(JSON.parse(message) as PubSubEvents[T]);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates an async iterator for a Redis subscription channel.
|
||||
* @param event - The Redis event to subscribe to.
|
||||
* @returns An async iterator that yields tracked events.
|
||||
*/
|
||||
export function redisAsyncIterator<K extends keyof PubSubEvents>(event: K) {
|
||||
return {
|
||||
[Symbol.asyncIterator]() {
|
||||
return {
|
||||
next(): Promise<IteratorResult<PubSubEvents[K]>> {
|
||||
return new Promise((resolve) => {
|
||||
redisSubscribe(event, (data: PubSubEvents[K]) => {
|
||||
resolve({ value: data, done: false });
|
||||
});
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function* combineRedisIterators<T extends keyof PubSubEvents>(
|
||||
events: T[],
|
||||
) {
|
||||
const iterators = events.map((event) =>
|
||||
redisAsyncIterator(event)[Symbol.asyncIterator](),
|
||||
);
|
||||
|
||||
while (true) {
|
||||
// Wait for the next event from any iterator
|
||||
const result = await Promise.race(
|
||||
iterators.map((iterator, index) =>
|
||||
iterator.next().then((untypedRes) => {
|
||||
const res = untypedRes as IteratorResult<PubSubEvents[T]>;
|
||||
return { res, event: events[index] };
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
if (result.res.done) break;
|
||||
|
||||
yield { event: result.event, data: result.res.value };
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClientProvider, type QueryClient } from "@tanstack/react-query";
|
||||
import { httpBatchStreamLink, loggerLink } from "@trpc/client";
|
||||
import {
|
||||
httpBatchStreamLink,
|
||||
httpSubscriptionLink,
|
||||
loggerLink,
|
||||
splitLink,
|
||||
} from "@trpc/client";
|
||||
import { createTRPCReact } from "@trpc/react-query";
|
||||
import { type inferRouterInputs, type inferRouterOutputs } from "@trpc/server";
|
||||
import { useState } from "react";
|
||||
@ -50,14 +55,22 @@ export function TRPCReactProvider(props: { children: React.ReactNode }) {
|
||||
process.env.NODE_ENV === "development" ||
|
||||
(op.direction === "down" && op.result instanceof Error),
|
||||
}),
|
||||
httpBatchStreamLink({
|
||||
transformer: SuperJSON,
|
||||
url: getBaseUrl() + "/api/trpc",
|
||||
headers: () => {
|
||||
const headers = new Headers();
|
||||
headers.set("x-trpc-source", "nextjs-react");
|
||||
return headers;
|
||||
},
|
||||
splitLink({
|
||||
// uses the httpSubscriptionLink for subscriptions
|
||||
condition: (op) => op.type === "subscription",
|
||||
true: httpSubscriptionLink({
|
||||
transformer: SuperJSON,
|
||||
url: `${getBaseUrl()}/api/trpc`,
|
||||
}),
|
||||
false: httpBatchStreamLink({
|
||||
transformer: SuperJSON,
|
||||
url: `${getBaseUrl()}/api/trpc`,
|
||||
headers: () => {
|
||||
const headers = new Headers();
|
||||
headers.set("x-trpc-source", "nextjs-react");
|
||||
return headers;
|
||||
},
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
|
||||
@ -1,60 +1,82 @@
|
||||
#!/usr/bin/env bash
|
||||
# Use this script to start a docker container for a local development database
|
||||
|
||||
# TO RUN ON WINDOWS:
|
||||
# 1. Install WSL (Windows Subsystem for Linux) - https://learn.microsoft.com/en-us/windows/wsl/install
|
||||
# 2. Install Docker Desktop for Windows - https://docs.docker.com/docker-for-windows/install/
|
||||
# 3. Open WSL - `wsl`
|
||||
# 4. Run this script - `./start-database.sh`
|
||||
|
||||
# On Linux and macOS you can run this script directly - `./start-database.sh`
|
||||
|
||||
DB_CONTAINER_NAME="game-master"
|
||||
|
||||
if ! [ -x "$(command -v docker)" ]; then
|
||||
echo -e "Docker is not installed. Please install docker and try again.\nDocker install guide: https://docs.docker.com/engine/install/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! docker info > /dev/null 2>&1; then
|
||||
echo "Docker daemon is not running. Please start Docker and try again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$(docker ps -q -f name=$DB_CONTAINER_NAME)" ]; then
|
||||
echo "Database container '$DB_CONTAINER_NAME' already running"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$(docker ps -q -a -f name=$DB_CONTAINER_NAME)" ]; then
|
||||
docker start "$DB_CONTAINER_NAME"
|
||||
echo "Existing database container '$DB_CONTAINER_NAME' started"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# import env variables from .env
|
||||
set -a
|
||||
source .env
|
||||
|
||||
DB_PASSWORD=$(echo "$DATABASE_URL" | awk -F':' '{print $3}' | awk -F'@' '{print $1}')
|
||||
DB_PORT=$(echo "$DATABASE_URL" | awk -F':' '{print $4}' | awk -F'\/' '{print $1}')
|
||||
DB_PORT=$(echo "$DATABASE_URL" | awk -F':' '{print $4}' | awk -F'/' '{print $1}')
|
||||
DB_NAME=$(echo "$DATABASE_URL" | awk -F'/' '{print $4}')
|
||||
DB_CONTAINER_NAME="$DB_NAME-postgres"
|
||||
|
||||
if [ "$DB_PASSWORD" = "password" ]; then
|
||||
echo "You are using the default database password"
|
||||
read -p "Should we generate a random password for you? [y/N]: " -r REPLY
|
||||
if ! [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
echo "Please change the default password in the .env file and try again"
|
||||
exit 1
|
||||
fi
|
||||
# Generate a random URL-safe password
|
||||
DB_PASSWORD=$(openssl rand -base64 12 | tr '+/' '-_')
|
||||
sed -i -e "s#:password@#:$DB_PASSWORD@#" .env
|
||||
REDIS_PORT=${REDIS_PORT:-6379}
|
||||
REDIS_CONTAINER_NAME="$DB_NAME-redis"
|
||||
|
||||
if ! [ -x "$(command -v docker)" ] && ! [ -x "$(command -v podman)" ]; then
|
||||
echo -e "Docker or Podman is not installed. Please install docker or podman and try again.\nDocker install guide: https://docs.docker.com/engine/install/\nPodman install guide: https://podman.io/getting-started/installation"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
docker run -d \
|
||||
--name $DB_CONTAINER_NAME \
|
||||
-e POSTGRES_USER="postgres" \
|
||||
-e POSTGRES_PASSWORD="$DB_PASSWORD" \
|
||||
-e POSTGRES_DB=game-master \
|
||||
-p "$DB_PORT":5432 \
|
||||
docker.io/postgres && echo "Database container '$DB_CONTAINER_NAME' was successfully created"
|
||||
# determine which docker command to use
|
||||
if [ -x "$(command -v docker)" ]; then
|
||||
DOCKER_CMD="docker"
|
||||
elif [ -x "$(command -v podman)" ]; then
|
||||
DOCKER_CMD="podman"
|
||||
fi
|
||||
|
||||
if ! $DOCKER_CMD info > /dev/null 2>&1; then
|
||||
echo "$DOCKER_CMD daemon is not running. Please start $DOCKER_CMD and try again."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# # Check if ports are in use
|
||||
# if command -v nc >/dev/null 2>&1; then
|
||||
# if nc -z localhost "$DB_PORT" 2>/dev/null; then
|
||||
# echo "Port $DB_PORT is already in use."
|
||||
# exit 1
|
||||
# fi
|
||||
# if nc -z localhost "$REDIS_PORT" 2>/dev/null; then
|
||||
# echo "Port $REDIS_PORT is already in use."
|
||||
# exit 1
|
||||
# fi
|
||||
# else
|
||||
# echo "Warning: Unable to check if ports are in use (netcat not installed)"
|
||||
# read -p "Do you want to continue anyway? [y/N]: " -r REPLY
|
||||
# if ! [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
# echo "Aborting."
|
||||
# exit 1
|
||||
# fi
|
||||
# fi
|
||||
|
||||
# Start PostgreSQL if not running
|
||||
if [ "$($DOCKER_CMD ps -q -f name=$DB_CONTAINER_NAME)" ]; then
|
||||
echo "Database container '$DB_CONTAINER_NAME' already running"
|
||||
else
|
||||
if [ "$($DOCKER_CMD ps -q -a -f name=$DB_CONTAINER_NAME)" ]; then
|
||||
$DOCKER_CMD start "$DB_CONTAINER_NAME"
|
||||
echo "Existing database container '$DB_CONTAINER_NAME' started"
|
||||
else
|
||||
$DOCKER_CMD run -d \
|
||||
--name $DB_CONTAINER_NAME \
|
||||
-e POSTGRES_USER="postgres" \
|
||||
-e POSTGRES_PASSWORD="$DB_PASSWORD" \
|
||||
-e POSTGRES_DB="$DB_NAME" \
|
||||
-p "$DB_PORT":5432 \
|
||||
docker.io/postgres && echo "Database container '$DB_CONTAINER_NAME' was successfully created"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Start Redis if not running
|
||||
if [ "$($DOCKER_CMD ps -q -f name=$REDIS_CONTAINER_NAME)" ]; then
|
||||
echo "Redis container '$REDIS_CONTAINER_NAME' already running"
|
||||
else
|
||||
if [ "$($DOCKER_CMD ps -q -a -f name=$REDIS_CONTAINER_NAME)" ]; then
|
||||
$DOCKER_CMD start "$REDIS_CONTAINER_NAME"
|
||||
echo "Existing Redis container '$REDIS_CONTAINER_NAME' started"
|
||||
else
|
||||
$DOCKER_CMD run -d \
|
||||
--name $REDIS_CONTAINER_NAME \
|
||||
-p "$REDIS_PORT":6379 \
|
||||
docker.io/redis && echo "Redis container '$REDIS_CONTAINER_NAME' was successfully created"
|
||||
fi
|
||||
fi
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user