From e2d5447a3c935f8a27ed186a02e10f639634dade Mon Sep 17 00:00:00 2001 From: Vico Date: Tue, 25 Mar 2025 22:38:40 +0100 Subject: [PATCH] Lobby Membership --- package.json | 1 + pnpm-lock.yaml | 30 ++++ src/app/(routes)/lobby/[id]/page.tsx | 19 ++- src/app/(routes)/lobby/page.tsx | 18 +- src/app/(routes)/page.tsx | 3 +- src/app/_components/delete-lobby-dialog.tsx | 57 +++++++ .../_components/lobby-membership-dialog.tsx | 73 ++++++++ src/components/ui/alert-dialog.tsx | 157 ++++++++++++++++++ src/components/ui/alert.tsx | 66 ++++++++ src/server/api/routers/lobby.ts | 54 +++++- src/server/auth/config.ts | 2 +- 11 files changed, 471 insertions(+), 9 deletions(-) create mode 100644 src/app/_components/delete-lobby-dialog.tsx create mode 100644 src/app/_components/lobby-membership-dialog.tsx create mode 100644 src/components/ui/alert-dialog.tsx create mode 100644 src/components/ui/alert.tsx diff --git a/package.json b/package.json index 023a41b..ceb48f8 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@auth/drizzle-adapter": "^1.7.2", "@hookform/resolvers": "^4.1.3", "@paralleldrive/cuid2": "^2.2.2", + "@radix-ui/react-alert-dialog": "^1.1.6", "@radix-ui/react-avatar": "^1.1.3", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-label": "^2.1.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6948633..88727ce 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@paralleldrive/cuid2': specifier: ^2.2.2 version: 2.2.2 + '@radix-ui/react-alert-dialog': + specifier: ^1.1.6 + version: 1.1.6(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@radix-ui/react-avatar': specifier: ^1.1.3 version: 1.1.3(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -729,6 +732,19 @@ packages: '@radix-ui/primitive@1.1.1': resolution: {integrity: sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==} + '@radix-ui/react-alert-dialog@1.1.6': + resolution: {integrity: sha512-p4XnPqgej8sZAAReCAKgz1REYZEBLR8hU9Pg27wFnCWIMc8g1ccCs0FjBcy05V15VTu8pAePw/VDYeOm/uZ6yQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-avatar@1.1.3': resolution: {integrity: sha512-Paen00T4P8L8gd9bNsRMw7Cbaz85oxiv+hzomsRZgFm2byltPFDtfcoqlWJ8GyZlIBWgLssJlzLCnKU0G0302g==} peerDependencies: @@ -3160,6 +3176,20 @@ snapshots: '@radix-ui/primitive@1.1.1': {} + '@radix-ui/react-alert-dialog@1.1.6(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.12)(react@19.0.0) + '@radix-ui/react-context': 1.1.1(@types/react@19.0.12)(react@19.0.0) + '@radix-ui/react-dialog': 1.1.6(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-slot': 1.1.2(@types/react@19.0.12)(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.12 + '@types/react-dom': 19.0.4(@types/react@19.0.12) + '@radix-ui/react-avatar@1.1.3(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: '@radix-ui/react-context': 1.1.1(@types/react@19.0.12)(react@19.0.0) diff --git a/src/app/(routes)/lobby/[id]/page.tsx b/src/app/(routes)/lobby/[id]/page.tsx index 2d3972b..7d8cd71 100644 --- a/src/app/(routes)/lobby/[id]/page.tsx +++ b/src/app/(routes)/lobby/[id]/page.tsx @@ -4,6 +4,11 @@ import { notFound } from "next/navigation"; import React from "react"; import UserCard from "@/components/user-card"; import type { PublicUser } from "@/server/auth/config"; +import { Button } from "@/components/ui/button"; +import { auth } from "@/server/auth"; +import DeleteLobbyDialog from "@/app/_components/delete-lobby-dialog"; +import LobbyMembershipDialog from "@/app/_components/lobby-membership-dialog"; + async function Page({ params, }: { @@ -11,7 +16,7 @@ async function Page({ id: string; }>; }) { - const session = { user: {} as User }; //await auth(); + const session = await auth(); const { id } = await params; const lobby = await api.lobby.get({ id }); if (!lobby) return notFound(); @@ -20,18 +25,26 @@ async function Page({ { ...lobby.leader, leader: true }, ...(lobby?.members?.map(({ user }) => ({ ...user, leader: false })) ?? []), ]; + + const isJoined = members.find((member) => member.id === session?.user.id); + const isOwner = lobby.createdById === session?.user.id; + return (

{lobby.name}

+ {isOwner && } + {!isOwner && ( + + )}
); } diff --git a/src/app/(routes)/lobby/page.tsx b/src/app/(routes)/lobby/page.tsx index 82258a9..fbfe118 100644 --- a/src/app/(routes)/lobby/page.tsx +++ b/src/app/(routes)/lobby/page.tsx @@ -1,7 +1,21 @@ import React from "react"; +import { api } from "@/trpc/server"; +import Link from "next/link"; -function Page() { - return
All lobbies
; +async function Page() { + const lobbies = await api.lobby.getAll(); + + return ( +
+ + {lobbies.map((lobby) => ( +
  • + {lobby.name} +
  • + ))} +
    +
    + ); } export default Page; diff --git a/src/app/(routes)/page.tsx b/src/app/(routes)/page.tsx index 0acf3e1..48d8d70 100644 --- a/src/app/(routes)/page.tsx +++ b/src/app/(routes)/page.tsx @@ -3,10 +3,9 @@ import { Button } from "@/components/ui/button"; import { Sparkles, Users, Plus } from "lucide-react"; import CreateLobbyDialog from "../_components/create-lobby-dialog"; import { auth } from "@/server/auth"; -import type { User } from "next-auth"; export default async function QuizGameStartPage() { - const session = { user: {} as User }; //await auth(); + const session = await auth(); return (
    diff --git a/src/app/_components/delete-lobby-dialog.tsx b/src/app/_components/delete-lobby-dialog.tsx new file mode 100644 index 0000000..3b8fa59 --- /dev/null +++ b/src/app/_components/delete-lobby-dialog.tsx @@ -0,0 +1,57 @@ +"use client"; + +import React from "react"; +import { Button } from "@/components/ui/button"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { api } from "@/trpc/react"; +import { toast } from "sonner"; +import { useRouter } from "next/navigation"; + +function DeleteLobbyDialog({ lobbyId }: { lobbyId: string }) { + const [loading, setLoading] = React.useState(false); + const { mutateAsync } = api.lobby.delete.useMutation(); + const router = useRouter(); + const handleConfirm = async () => { + setLoading(true); + const result = await mutateAsync({ lobbyId }); + if (result) { + router.push("/"); + } else toast.error("Something went wrong"); + setLoading(false); + }; + + return ( + + + + + Are you absolutely sure? + + This action cannot be undone. You will permanently delete this + lobby! + + + + Cancel + + Delete Lobby + + + + + ); +} + +export default DeleteLobbyDialog; diff --git a/src/app/_components/lobby-membership-dialog.tsx b/src/app/_components/lobby-membership-dialog.tsx new file mode 100644 index 0000000..3c05ee6 --- /dev/null +++ b/src/app/_components/lobby-membership-dialog.tsx @@ -0,0 +1,73 @@ +"use client"; + +import React from "react"; +import { Button } from "@/components/ui/button"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { api } from "@/trpc/react"; +import { toast } from "sonner"; +import { useRouter } from "next/navigation"; + +function LobbyMembershipDialog({ + lobbyId, + join, +}: { + lobbyId: string; + join: boolean; +}) { + const [loading, setLoading] = React.useState(false); + const { mutateAsync } = api.lobby.membership.useMutation(); + const router = useRouter(); + const handleConfirm = async () => { + setLoading(true); + const result = await mutateAsync({ lobbyId, join }); + if (result) { + if (!join) router.push("/"); + else toast.success("Successfully joined the lobby."); + } else toast.error("Something went wrong"); + setLoading(false); + }; + + const labelText = join ? "join" : "leave"; + + return ( + + + + + + {labelText} Lobby + + + You are about to {labelText} the lobby. + + + + Cancel + + {labelText} + + + + + ); +} + +export default LobbyMembershipDialog; diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..0863e40 --- /dev/null +++ b/src/components/ui/alert-dialog.tsx @@ -0,0 +1,157 @@ +"use client" + +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" + +function AlertDialog({ + ...props +}: React.ComponentProps) { + return +} + +function AlertDialogTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + ) +} + +function AlertDialogHeader({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
    + ) +} + +function AlertDialogFooter({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
    + ) +} + +function AlertDialogTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogAction({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AlertDialogCancel({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx new file mode 100644 index 0000000..1421354 --- /dev/null +++ b/src/components/ui/alert.tsx @@ -0,0 +1,66 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
    + ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
    + ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
    + ) +} + +export { Alert, AlertTitle, AlertDescription } diff --git a/src/server/api/routers/lobby.ts b/src/server/api/routers/lobby.ts index 0ea15bf..e77404b 100644 --- a/src/server/api/routers/lobby.ts +++ b/src/server/api/routers/lobby.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -import { lobbies } from "@/server/db/schema"; +import { lobbies, lobbyMembers } from "@/server/db/schema"; import { createTRPCRouter, protectedProcedure, @@ -11,6 +11,23 @@ import { and, eq } from "drizzle-orm"; 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), + with: { + lobby: true, + }, + }); + + return [ + ...ownedLobbies, + ...(joinedLobbies?.map(({ lobby }) => lobby) ?? []), + ]; + }), + get: publicProcedure .input( z.object({ @@ -26,6 +43,7 @@ export const lobbyRouter = createTRPCRouter({ columns: { image: true, name: true, + id: true, }, }, members: { @@ -34,6 +52,7 @@ export const lobbyRouter = createTRPCRouter({ columns: { image: true, name: true, + id: true, }, }, }, @@ -97,4 +116,37 @@ export const lobbyRouter = createTRPCRouter({ .returning({ id: lobbies.id }); return lobby; }), + + membership: protectedProcedure + .input( + z.object({ + lobbyId: z.string(), + join: z.boolean(), + }), + ) + .mutation(async ({ ctx, input }) => { + if (input.join) { + return ( + await ctx.db + .insert(lobbyMembers) + .values({ + lobbyId: input.lobbyId, + userId: ctx.session.user.id, + }) + .returning() + )[0]; + } else { + return ( + await ctx.db + .delete(lobbyMembers) + .where( + and( + eq(lobbyMembers.lobbyId, input.lobbyId), + eq(lobbyMembers.userId, ctx.session.user.id), + ), + ) + .returning() + )[0]; + } + }), }); diff --git a/src/server/auth/config.ts b/src/server/auth/config.ts index d7e4bd5..c69f9ba 100644 --- a/src/server/auth/config.ts +++ b/src/server/auth/config.ts @@ -30,7 +30,7 @@ declare module "next-auth" { // // role: UserRole; // } } -export type PublicUser = Pick; +export type PublicUser = Pick; /** * Options for NextAuth.js used to configure adapters, providers, callbacks, etc.