This commit is contained in:
shrt 2025-03-30 11:33:38 +02:00
parent 681115e291
commit 4009c44841
34 changed files with 726 additions and 146 deletions

View File

@ -1 +0,0 @@
{"version":"7","dialect":"postgresql","entries":[]}

View File

@ -29,6 +29,7 @@
"@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2", "@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-popover": "^1.1.6", "@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-slider": "^1.2.3",
"@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-slot": "^1.1.2",
"@t3-oss/env-nextjs": "^0.12.0", "@t3-oss/env-nextjs": "^0.12.0",
"@tanstack/react-query": "^5.69.0", "@tanstack/react-query": "^5.69.0",

55
pnpm-lock.yaml generated
View File

@ -35,6 +35,9 @@ importers:
'@radix-ui/react-popover': '@radix-ui/react-popover':
specifier: ^1.1.6 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) 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-slider':
specifier: ^1.2.3
version: 1.2.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)
'@radix-ui/react-slot': '@radix-ui/react-slot':
specifier: ^1.1.2 specifier: ^1.1.2
version: 1.1.2(@types/react@19.0.12)(react@19.0.0) version: 1.1.2(@types/react@19.0.12)(react@19.0.0)
@ -918,6 +921,9 @@ packages:
'@petamoriken/float16@3.9.2': '@petamoriken/float16@3.9.2':
resolution: {integrity: sha512-VgffxawQde93xKxT3qap3OH+meZf7VaSB5Sqd4Rqc+FP5alWbpOyan/7tRbOAvynjpG3GpdtAuGU/NdhQpmrog==} resolution: {integrity: sha512-VgffxawQde93xKxT3qap3OH+meZf7VaSB5Sqd4Rqc+FP5alWbpOyan/7tRbOAvynjpG3GpdtAuGU/NdhQpmrog==}
'@radix-ui/number@1.1.0':
resolution: {integrity: sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==}
'@radix-ui/primitive@1.1.1': '@radix-ui/primitive@1.1.1':
resolution: {integrity: sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==} resolution: {integrity: sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==}
@ -1174,6 +1180,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-slider@1.2.3':
resolution: {integrity: sha512-nNrLAWLjGESnhqBqcCNW4w2nn7LxudyMzeB6VgdyAnFLC6kfQgnAjSL2v6UkQTnDctJBlxrmxfplWS4iYjdUTw==}
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-slot@1.1.2': '@radix-ui/react-slot@1.1.2':
resolution: {integrity: sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==} resolution: {integrity: sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==}
peerDependencies: peerDependencies:
@ -1219,6 +1238,15 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
'@radix-ui/react-use-previous@1.1.0':
resolution: {integrity: sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-rect@1.1.0': '@radix-ui/react-use-rect@1.1.0':
resolution: {integrity: sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==} resolution: {integrity: sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==}
peerDependencies: peerDependencies:
@ -3839,6 +3867,8 @@ snapshots:
'@petamoriken/float16@3.9.2': {} '@petamoriken/float16@3.9.2': {}
'@radix-ui/number@1.1.0': {}
'@radix-ui/primitive@1.1.1': {} '@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)': '@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)':
@ -4102,6 +4132,25 @@ snapshots:
'@types/react': 19.0.12 '@types/react': 19.0.12
'@types/react-dom': 19.0.4(@types/react@19.0.12) '@types/react-dom': 19.0.4(@types/react@19.0.12)
'@radix-ui/react-slider@1.2.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/number': 1.1.0
'@radix-ui/primitive': 1.1.1
'@radix-ui/react-collection': 1.1.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-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-direction': 1.1.0(@types/react@19.0.12)(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-use-controllable-state': 1.1.0(@types/react@19.0.12)(react@19.0.0)
'@radix-ui/react-use-layout-effect': 1.1.0(@types/react@19.0.12)(react@19.0.0)
'@radix-ui/react-use-previous': 1.1.0(@types/react@19.0.12)(react@19.0.0)
'@radix-ui/react-use-size': 1.1.0(@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-slot@1.1.2(@types/react@19.0.12)(react@19.0.0)': '@radix-ui/react-slot@1.1.2(@types/react@19.0.12)(react@19.0.0)':
dependencies: dependencies:
'@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.12)(react@19.0.0) '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.12)(react@19.0.0)
@ -4135,6 +4184,12 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.0.12 '@types/react': 19.0.12
'@radix-ui/react-use-previous@1.1.0(@types/react@19.0.12)(react@19.0.0)':
dependencies:
react: 19.0.0
optionalDependencies:
'@types/react': 19.0.12
'@radix-ui/react-use-rect@1.1.0(@types/react@19.0.12)(react@19.0.0)': '@radix-ui/react-use-rect@1.1.0(@types/react@19.0.12)(react@19.0.0)':
dependencies: dependencies:
'@radix-ui/rect': 1.1.0 '@radix-ui/rect': 1.1.0

BIN
public/game-placeholder.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

View File

@ -15,7 +15,7 @@ async function Page({
const lobby = await api.lobby.get({ id }); const lobby = await api.lobby.get({ id });
if (!lobby) return notFound(); if (!lobby) return notFound();
return <LobbyPage lobby={lobby} sessionPlayer={sessionPlayer} />; return <LobbyPage initialLobby={lobby} sessionPlayer={sessionPlayer} />;
} }
export default Page; export default Page;

View File

@ -23,7 +23,7 @@ async function Page() {
</Button> </Button>
</div> </div>
); );
return <LobbyPage lobby={lobby} sessionPlayer={sessionPlayer} />; return <LobbyPage initialLobby={lobby} sessionPlayer={sessionPlayer} />;
} }
export default Page; export default Page;

View File

@ -0,0 +1,102 @@
import { cn } from "@/lib/utils";
import Image from "next/image";
import React from "react";
type Game = {
id: number;
name: string;
description: string;
image: string;
minPlayers: number;
maxPlayers: number;
};
const mokGames: Array<Game> = [
{
id: 1,
name: "Mok Game 1",
description: "A simple game of strategy and luck",
image: "/game-placeholder.jpg",
maxPlayers: 2,
minPlayers: 2,
},
{
id: 2,
name: "Mok Game 2",
description: "A simple game of strategy and luck",
image: "/game-placeholder.jpg",
maxPlayers: 2,
minPlayers: 2,
},
{
id: 3,
name: "Mok Game 3",
description: "A simple game of strategy and luck",
image: "/game-placeholder.jpg",
maxPlayers: 2,
minPlayers: 2,
},
];
function GameSelector({
selectGame,
selectedGame,
}: {
selectGame?: (gameId: Game["id"]) => void;
selectedGame?: Game["id"];
}) {
const [selected, setSelected] = React.useState(selectedGame ?? 1);
return (
<div className="container-bg grid w-full grid-cols-3 gap-4 p-6">
{mokGames.map((game) => (
<GameCard
key={game.id}
game={game}
selected={selected === game.id}
onClick={() => setSelected(game.id)}
/>
))}
</div>
);
}
const GameCard = ({
game,
selected,
onClick,
}: {
game: Game;
selected?: boolean;
onClick?: () => void;
}) => {
return (
<div
className={cn(
"container-bg group w-full cursor-pointer items-center justify-between space-y-1 overflow-hidden",
selected &&
"before:bg-primary/40 before:absolute before:inset-0 before:-z-10 before:w-full before:animate-pulse before:rounded-md before:blur-xl",
)}
onClick={onClick}
>
<div className="relative h-20 w-full">
<Image
fill
alt="game-image"
src={game.image}
className={cn(
"object-cover opacity-50 transition-all duration-300 ease-in-out group-hover:opacity-90",
selected && "opacity-100",
)}
/>
</div>
<div className="flex items-center gap-2 px-4 pb-4">
<div>
<h4 className="font-meidum text-xl font-bold">{game.name}</h4>
<div className="text-xs text-white/75">{game.description}</div>
</div>
</div>
</div>
);
};
export default GameSelector;

View File

@ -20,6 +20,8 @@ import { api } from "@/trpc/react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { appRoutes } from "@/config/app.routes"; import { appRoutes } from "@/config/app.routes";
import { Slider } from "@/components/ui/slider";
import { getMinMax } from "@/lib/validations/utils";
function LobbyForm({ function LobbyForm({
server_lobby, server_lobby,
@ -36,7 +38,7 @@ function LobbyForm({
resolver: zodResolver(lobbyPatchSchema), resolver: zodResolver(lobbyPatchSchema),
defaultValues: { defaultValues: {
name: server_lobby?.name ?? "", name: server_lobby?.name ?? "",
maxPlayers: server_lobby?.maxPlayers ?? 0, maxPlayers: server_lobby?.maxPlayers ?? 2,
}, },
}); });
async function onSubmit(lobby: z.infer<typeof lobbyPatchSchema>) { async function onSubmit(lobby: z.infer<typeof lobbyPatchSchema>) {
@ -53,6 +55,7 @@ function LobbyForm({
} }
} else toast.error("Something went wrong."); } else toast.error("Something went wrong.");
setLoading(false); setLoading(false);
form.reset();
} }
return ( return (
@ -75,6 +78,32 @@ function LobbyForm({
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={form.control}
name="maxPlayers"
render={({ field }) => {
const { min, max } = getMinMax(lobbyPatchSchema.shape.maxPlayers);
return (
<FormItem>
<FormLabel>
Max Players
<span className="text-xl font-bold">{`${field.value}`}</span>
</FormLabel>
<FormControl>
<Slider
className="w-full max-w-xs"
defaultValue={[field.value]}
max={max ?? 100}
min={min ?? 1}
step={1}
onValueChange={(value) => field.onChange(value[0])}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<div className="flex items-center justify-end"> <div className="flex items-center justify-end">
<Button <Button
type="submit" type="submit"

View File

@ -15,6 +15,8 @@ import {
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { api } from "@/trpc/react"; import { api } from "@/trpc/react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useRouter } from "next/navigation";
import { appRoutes } from "@/config/app.routes";
function LobbyMembershipDialog({ function LobbyMembershipDialog({
lobbyId, lobbyId,
@ -26,12 +28,16 @@ function LobbyMembershipDialog({
const [loading, setLoading] = React.useState(false); const [loading, setLoading] = React.useState(false);
const membership = api.lobby.membership.useMutation(); const membership = api.lobby.membership.useMutation();
const labelText = join ? "join" : "leave"; const labelText = join ? "join" : "leave";
const router = useRouter();
const handleConfirm = async () => { const handleConfirm = async () => {
setLoading(true); setLoading(true);
const result = await membership.mutateAsync({ lobbyId, join }); const result = await membership.mutateAsync({ lobbyId, join });
if (result) { if (result) {
toast.success(`Successfully ${labelText} the lobby.`); toast.success(`Successfully ${labelText} the lobby.`);
if (join) router.push(appRoutes.currentlobby);
else router.push(appRoutes.lobby(lobbyId));
router.refresh();
} else toast.error("Something went wrong"); } else toast.error("Something went wrong");
setLoading(false); setLoading(false);
}; };

View File

@ -1,79 +1,88 @@
"use client"; "use client";
import React from "react"; import React from "react";
import type { Lobby, LobbyMember, Player } from "@/server/db/schema"; import type { Lobby, Player } from "@/server/db/schema";
import LobbyPlayerCard from "@/app/_components/lobby/lobby_player-card"; import LobbyPlayerCard from "@/app/_components/lobby/lobby-player/lobby-player-card";
import DeleteLobbyDialog from "@/app/_components/lobby/delete-lobby-dialog";
import LobbyMembershipDialog from "@/app/_components/lobby/lobby-membership-dialog"; import LobbyMembershipDialog from "@/app/_components/lobby/lobby-membership-dialog";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { api } from "@/trpc/react";
import CopyToClip from "@/components/copy-to-clip"; import CopyToClip from "@/components/copy-to-clip";
import { appRoutes } from "@/config/app.routes"; import { appRoutes } from "@/config/app.routes";
import LobbySettingsDialog from "./lobby-settings-dialog"; import LobbySettingsDialog from "./lobby-settings-dialog";
import { useRealtimeLobby } from "@/hooks/use-lobby";
import { getBaseUrl } from "@/lib/utils";
import GameSelector from "./game-selector";
function LobbyPage({ function LobbyPage({
sessionPlayer, sessionPlayer,
initialLobby,
lobby,
}: { }: {
sessionPlayer?: Player | null; sessionPlayer?: Player | null;
lobby: Lobby; initialLobby: Lobby;
}) { }) {
const [members, setMembers] = React.useState<Array<LobbyMember>>( const { members, lobby } = useRealtimeLobby({ initialLobby, sessionPlayer });
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 isJoined = members.find((m) => m.playerId === sessionPlayer?.id);
const isAdmin = isJoined?.role === "admin"; const isAdmin = isJoined?.role === "admin";
return ( return (
<div className="container-bg w-full max-w-md space-y-4 p-6"> <div className="grid max-w-4xl grid-cols-3 grid-rows-2 gap-4">
<div className="flex items-center justify-between"> <div className="container-bg col-span-2 w-full space-y-4 p-6">
<h1 className="w-full overflow-hidden text-2xl font-bold text-ellipsis capitalize"> <div className="flex items-center gap-2">
{lobby.name} {isJoined && (
</h1> <CopyToClip
{isAdmin && <LobbySettingsDialog lobby={lobby} />} target="invite link"
</div> text={getBaseUrl() + appRoutes.lobby(lobby.id)}
buttonProps={{ variant: "outline" }}
<ul className="space-y-2"> >
{members?.map((member, idx) => ( Invite Players
<li key={idx}> </CopyToClip>
<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 className="ml-auto">
{sessionPlayer && (
<LobbyMembershipDialog lobbyId={lobby.id} join={!isJoined} />
)}
</div>
</div> </div>
<div className="flex w-full items-center justify-between text-nowrap">
<h1 className="w-full overflow-hidden text-2xl font-bold text-ellipsis">
{lobby.name}
</h1>
{isAdmin && <LobbySettingsDialog lobby={lobby} />}
</div>
<div className="border-border/20 flex items-center justify-between border-b pb-4">
<h2 className="text-sm font-medium">
Players ({members?.length || 0}/{lobby.maxPlayers || 8})
</h2>
<span className="text-xs">
{members?.length === (lobby.maxPlayers || 8)
? "Lobby Full"
: "Waiting..."}
</span>
</div>
<ul className="space-y-2">
{members?.map((member, idx) => (
<li key={idx}>
<LobbyPlayerCard
lobbyId={lobby.id}
highlight={member?.player?.id === sessionPlayer?.id}
player={member?.player!}
className="relative"
showOptions={isAdmin && member?.playerId !== sessionPlayer?.id}
>
{member?.role === "admin" && (
<Badge className="absolute -top-2 right-2 z-20 p-px px-2 text-white">
Admin
</Badge>
)}
</LobbyPlayerCard>
</li>
))}
</ul>
</div>
<div className="container-bg col-span-1 size-full"></div>
<div className="col-span-3">
<GameSelector />
</div> </div>
</div> </div>
); );

View File

@ -0,0 +1,66 @@
import type { Player } from "@/server/db/schema";
import React from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { ShieldQuestion, UserX } from "lucide-react";
import { api } from "@/trpc/react";
import { Button } from "@/components/ui/button";
function KickPlayerDialog({
player,
lobbyId,
}: {
player: Pick<Player, "id" | "displayName" | "avatar">;
lobbyId: string;
}) {
const kickPlayer = api.lobby.kick.useMutation();
const handleAction = () => {
kickPlayer.mutate({ lobbyId: lobbyId, playerId: player.id });
};
return (
<AlertDialog>
<AlertDialogTrigger className="flex items-center gap-1">
<UserX className="group-focus:text-destructive size-4 text-white" />
Kick Player
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-1">
Are you absolutely sure to
<span className="text-destructive font-bold">
kick {player.displayName}
</span>
<ShieldQuestion className="text-destructive size-4" />
</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will remove {player.displayName}
from the lobby.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button
asChild
variant={"destructive"}
disabled={kickPlayer.isPending}
>
<AlertDialogAction onClick={handleAction}>
{kickPlayer.isPending ? "Kicking..." : "Kick Player"}
</AlertDialogAction>
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
export default KickPlayerDialog;

View File

@ -1,23 +1,30 @@
import React from "react"; import React from "react";
import Avatar from "../../../components/avatar"; import Avatar from "@/components/avatar";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { MoreHorizontal } from "lucide-react";
import { Button } from "../../../components/ui/button";
import type { Player } from "@/server/db/schema"; import type { Player } from "@/server/db/schema";
import LobbyPlayerOptions from "./lobby-player-options";
function LobbyPlayerCard({ function LobbyPlayerCard({
lobbyId,
player, player,
children, children,
className, className,
highlight,
showOptions = false,
}: { }: {
player: Pick<Player, "displayName" | "avatar">; lobbyId: string;
player: Player;
children?: React.ReactNode; children?: React.ReactNode;
className?: string; className?: string;
highlight?: boolean;
showOptions?: boolean;
}) { }) {
return ( return (
<div <div
className={cn( className={cn(
"container-bg flex items-center justify-between gap-2 p-4", "container-bg flex items-center justify-between gap-2 p-4",
highlight &&
"before:bg-primary/40 before:absolute before:inset-0 before:-z-10 before:w-1/3 before:animate-pulse before:rounded-md before:blur-xl",
className, className,
)} )}
> >
@ -26,13 +33,7 @@ function LobbyPlayerCard({
<h4 className="font-meidum text-xl">{player.displayName}</h4> <h4 className="font-meidum text-xl">{player.displayName}</h4>
</div> </div>
{children} {children}
<Button {showOptions && <LobbyPlayerOptions lobbyId={lobbyId} player={player} />}
size={"icon"}
variant={"ghost"}
className="hover:bg-border/10 hover:text-white"
>
<MoreHorizontal />
</Button>
</div> </div>
); );
} }

View File

@ -0,0 +1,86 @@
"use client";
import React from "react";
import { Button } from "@/components/ui/button";
import {
Ban,
Eye,
Handshake,
HeartHandshake,
MoreHorizontal,
UserCog,
UserX,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { Player } from "@/server/db/schema";
import Link from "next/link";
import { appRoutes } from "@/config/app.routes";
import { cn } from "@/lib/utils";
import KickPlayerDialog from "./kick-player-dialog";
import { api } from "@/trpc/react";
function LobbyPlayerOptions({
player,
lobbyId,
}: {
player: Player;
lobbyId: string;
}) {
const [open, setOpen] = React.useState(false);
const dropdownItemClassName = " flex items-center gap-1 ";
const changeRole = api.lobby.changeRole.useMutation();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size={"icon"}
variant={"ghost"}
className="hover:bg-border/10 hover:text-white"
>
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel className="w-32 truncate overflow-hidden font-bold">
{player.displayName}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem className={dropdownItemClassName} asChild>
<Link href={appRoutes.playerProfile(player.id)} target="_blank">
<Eye className="size-4 text-white" />
View Profile
</Link>
</DropdownMenuItem>
<DropdownMenuItem className={dropdownItemClassName} asChild>
<Link href={appRoutes.friend(player.id)} target="_blank">
<HeartHandshake className="size-4 text-white" />
My Friend
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className={dropdownItemClassName}>
<UserCog className="size-4 text-white" />
Change Role
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => e.preventDefault()}
className={cn(
dropdownItemClassName,
"group focus:border-destructive focus:text-destructive border border-transparent focus:font-bold",
)}
>
<KickPlayerDialog lobbyId={lobbyId} player={player} />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
export default LobbyPlayerOptions;

View File

@ -14,8 +14,9 @@ import DeleteLobbyDialog from "./delete-lobby-dialog";
import { Settings } from "lucide-react"; import { Settings } from "lucide-react";
function LobbySettingsDialog({ lobby }: { lobby: Lobby }) { function LobbySettingsDialog({ lobby }: { lobby: Lobby }) {
const [open, setOpen] = React.useState(false);
return ( return (
<Dialog> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant={"ghost"} size={"icon"}> <Button variant={"ghost"} size={"icon"}>
<Settings className="size-4" /> <Settings className="size-4" />
@ -25,8 +26,8 @@ function LobbySettingsDialog({ lobby }: { lobby: Lobby }) {
<DialogTitle>Lobby Settings</DialogTitle> <DialogTitle>Lobby Settings</DialogTitle>
<DialogDescription></DialogDescription> <DialogDescription></DialogDescription>
<LobbyForm server_lobby={lobby} /> <LobbyForm cb={() => setOpen(false)} server_lobby={lobby} />
<div className="flex items-center"> <div className="mt-20 flex items-center">
<div className="bg-muted-foreground h-px w-12" /> <div className="bg-muted-foreground h-px w-12" />
<p className="relative z-10 px-2 text-xs">Danger Zone</p> <p className="relative z-10 px-2 text-xs">Danger Zone</p>
<div className="bg-muted-foreground h-px grow" /> <div className="bg-muted-foreground h-px grow" />

View File

@ -15,8 +15,7 @@ import type { Player } from "@/server/db/schema";
import { appRoutes } from "@/config/app.routes"; import { appRoutes } from "@/config/app.routes";
function UserPopover({ player }: { player: Player }) { function UserPopover({ player }: { player: Player }) {
const dropdownItemClassName = const dropdownItemClassName = " flex items-center gap-1 ";
"focus:bg-border/30 focus:text-white flex items-center gap-1 ";
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger> <DropdownMenuTrigger>
@ -26,7 +25,7 @@ function UserPopover({ player }: { player: Player }) {
className="border-border/20 size-10 cursor-pointer border-2" className="border-border/20 size-10 cursor-pointer border-2"
/> />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className="bg-border/30 container-bg text-white"> <DropdownMenuContent>
<DropdownMenuLabel className="w-32 truncate overflow-hidden font-bold"> <DropdownMenuLabel className="w-32 truncate overflow-hidden font-bold">
{player.displayName} {player.displayName}
</DropdownMenuLabel> </DropdownMenuLabel>

View File

@ -2,6 +2,7 @@
import React from "react"; import React from "react";
import { Button, type ButtonProps } from "./ui/button"; import { Button, type ButtonProps } from "./ui/button";
import { toast } from "sonner"; import { toast } from "sonner";
import type { title } from "process";
function CopyToClip({ function CopyToClip({
text, text,
@ -16,7 +17,7 @@ function CopyToClip({
}) { }) {
const handleclick = () => { const handleclick = () => {
navigator.clipboard.writeText(text); navigator.clipboard.writeText(text);
toast.success(`Copied ${target ?? ""} to clipboard`); toast(<p>Copied {target ? <b>{target}</b> : ""} to clipboard</p>);
}; };
return ( return (
<Button {...buttonProps} onClick={handleclick}> <Button {...buttonProps} onClick={handleclick}>

View File

@ -112,7 +112,7 @@ function AlertDialogDescription({
return ( return (
<AlertDialogPrimitive.Description <AlertDialogPrimitive.Description
data-slot="alert-dialog-description" data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)} className={cn("text-sm text-white/75", className)}
{...props} {...props}
/> />
); );

View File

@ -42,7 +42,7 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content" data-slot="dropdown-menu-content"
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md", "bg-background/30 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 text-white shadow-md",
className, className,
)} )}
{...props} {...props}
@ -74,7 +74,7 @@ function DropdownMenuItem({
data-inset={inset} data-inset={inset}
data-variant={variant} data-variant={variant}
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-border/30 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:text-white data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className, className,
)} )}
{...props} {...props}

View File

@ -1,20 +1,20 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover" import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Popover({ function Popover({
...props ...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) { }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} /> return <PopoverPrimitive.Root data-slot="popover" {...props} />;
} }
function PopoverTrigger({ function PopoverTrigger({
...props ...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) { }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} /> return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
} }
function PopoverContent({ function PopoverContent({
@ -30,19 +30,19 @@ function PopoverContent({
align={align} align={align}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-popover text-popover-foreground 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden", "bg-background/30 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 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className className,
)} )}
{...props} {...props}
/> />
</PopoverPrimitive.Portal> </PopoverPrimitive.Portal>
) );
} }
function PopoverAnchor({ function PopoverAnchor({
...props ...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) { }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} /> return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
} }
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

View File

@ -0,0 +1,63 @@
"use client"
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max]
)
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
)
}
export { Slider }

View File

@ -1,25 +1,27 @@
"use client" "use client";
import { useTheme } from "next-themes" import { useTheme } from "next-themes";
import { Toaster as Sonner, ToasterProps } from "sonner" import { Toaster as Sonner, type ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => { const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme() const { theme = "system" } = useTheme();
return ( return (
<Sonner <Sonner
theme={theme as ToasterProps["theme"]} theme={theme as ToasterProps["theme"]}
className="toaster group" className="toaster group"
richColors
style={ style={
{ {
"--normal-bg": "var(--popover)", "--normal-bg":
"--normal-text": "var(--popover-foreground)", "color-mix(in oklab, var(--background), transparent 20%)",
"--normal-text": "var(--foreground)",
"--normal-border": "var(--border)", "--normal-border": "var(--border)",
} as React.CSSProperties } as React.CSSProperties
} }
{...props} {...props}
/> />
) );
} };
export { Toaster } export { Toaster };

View File

@ -8,6 +8,10 @@ export const appRoutes = {
editProfile: "/me/edit", editProfile: "/me/edit",
playerProfile: (id: string) => `/player/${id}`, playerProfile: (id: string) => `/player/${id}`,
friend: (playerId: string) => `/me/friend/${playerId}`,
firendsOverview: "/me/friends",
signIn: "/api/auth/signin", signIn: "/api/auth/signin",
signOut: "/api/auth/signout", signOut: "/api/auth/signout",
}; };

50
src/hooks/use-lobby.ts Normal file
View File

@ -0,0 +1,50 @@
"use client";
import { appRoutes } from "@/config/app.routes";
import type { Lobby, LobbyMember, Player } from "@/server/db/schema";
import type { LobbyMemberLeaveEventData } from "@/server/redis/events";
import { api } from "@/trpc/react";
import { useRouter } from "next/navigation";
import React from "react";
export function useRealtimeLobby({
initialLobby,
sessionPlayer,
}: {
initialLobby: Lobby;
sessionPlayer?: Player | null;
}) {
const [lobby, setLobby] = React.useState(initialLobby);
const [members, setMembers] = React.useState<Array<LobbyMember>>(
initialLobby?.members ?? [],
);
const router = useRouter();
api.lobby.onMemberUpdate.useSubscription(undefined, {
onData({ data: _data }) {
const joined = _data.joined;
if (joined) {
const data = _data.membership as LobbyMember;
setMembers((prev) => {
if (prev.find((m) => m.playerId === data.playerId)) return prev;
return [...prev, data];
});
} else {
const data = _data.membership as LobbyMemberLeaveEventData;
setMembers((prev) => prev.filter((m) => m.playerId !== data.playerId));
if (data?.kicked && data?.playerId === sessionPlayer?.id) {
router.push(appRoutes.lobby(lobby.id));
router.refresh();
}
}
},
});
api.lobby.onUpdate.useSubscription(undefined, {
onData({ data }) {
if (!data?.lobby) return;
setLobby((prev) => ({ ...data.lobby, members: prev.members }));
},
});
return { lobby, members };
}

2
src/index.d.ts vendored
View File

@ -2,5 +2,3 @@ type NavLink = {
label: string; label: string;
path: string; path: string;
}; };
type LobbyMemberRole = "player" | "admin";

View File

@ -13,3 +13,9 @@ export const formatDate = (date: Date) =>
hour: "numeric", hour: "numeric",
minute: "numeric", minute: "numeric",
}); });
export function getBaseUrl() {
if (typeof window !== "undefined") return window.location.origin;
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
return `http://localhost:${process.env.PORT ?? 3000}`;
}

View File

@ -6,8 +6,11 @@ export const lobbyPatchSchema = z.object({
.min(2, { .min(2, {
message: "Name must contain at least 2 characters", message: "Name must contain at least 2 characters",
}) })
.max(255, { .max(64, {
message: "Name can only contain up to 255 characters", message: "Name can only contain up to 64 characters",
}), }),
maxPlayers: z.number().default(0), maxPlayers: z.number().min(2).max(12).default(2),
}); });
export const LobbyMemberRoleSchema = z.enum(["admin", "player"]);
export type LobbyMemberRole = z.infer<typeof LobbyMemberRoleSchema>;

View File

@ -0,0 +1,7 @@
import { z } from "zod";
export const trcpSubscriptionInput = z
.object({
lastEventId: z.string().nullish(),
})
.optional();

View File

@ -0,0 +1,11 @@
import { z } from "zod";
export const getMinMax = (schema: z.ZodNumber | z.ZodDefault<z.ZodNumber>) => {
const baseSchema =
schema instanceof z.ZodDefault ? schema.removeDefault() : schema;
const min =
baseSchema._def.checks.find((c) => c.kind === "min")?.value ?? null;
const max =
baseSchema._def.checks.find((c) => c.kind === "max")?.value ?? null;
return { min, max };
};

View File

@ -1,12 +1,15 @@
import { z } from "zod"; import { z } from "zod";
import { lobbies, lobbyMembers, players } from "@/server/db/schema"; import { lobbies, lobbyMembers, players, type Lobby } from "@/server/db/schema";
import { import {
createTRPCRouter, createTRPCRouter,
protectedProcedure, protectedProcedure,
publicProcedure, publicProcedure,
} from "@/server/api/trpc"; } from "@/server/api/trpc";
import { lobbyPatchSchema } from "@/lib/validations/lobby"; import {
LobbyMemberRoleSchema,
lobbyPatchSchema,
} from "@/lib/validations/lobby";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { import {
combineRedisIterators, combineRedisIterators,
@ -14,6 +17,8 @@ import {
redisPublish, redisPublish,
} from "@/server/redis/sse-redis"; } from "@/server/redis/sse-redis";
import { tracked } from "@trpc/server"; import { tracked } from "@trpc/server";
import { trcpSubscriptionInput } from "@/lib/validations/trcp";
import type { LobbyMemberLeaveEventData } from "@/server/redis/events";
export const lobbyRouter = createTRPCRouter({ export const lobbyRouter = createTRPCRouter({
// queries // queries
@ -100,7 +105,9 @@ export const lobbyRouter = createTRPCRouter({
eq(lobbies.createdById, ctx.session.user.id), eq(lobbies.createdById, ctx.session.user.id),
), ),
) )
.returning({ id: lobbies.id }); .returning();
if (lobby) redisPublish("lobby:update", lobby);
return lobby; return lobby;
}), }),
delete: protectedProcedure delete: protectedProcedure
@ -121,7 +128,6 @@ export const lobbyRouter = createTRPCRouter({
), ),
) )
.returning({ id: lobbies.id }); .returning({ id: lobbies.id });
return lobby; return lobby;
}), }),
@ -160,23 +166,81 @@ export const lobbyRouter = createTRPCRouter({
), ),
) )
.returning(); .returning();
if (member) redisPublish("lobby:member:leave", member.playerId); if (member)
redisPublish("lobby:member:leave", { playerId: member.playerId });
return member; return member;
} }
}), }),
// admin mutaions
changeRole: protectedProcedure
.input(
z.object({
lobbyId: z.string(),
playerId: z.string(),
role: LobbyMemberRoleSchema,
}),
)
.mutation(async ({ ctx, input }) => {
console.log("Check if user is admin");
const [member] = await ctx.db
.update(lobbyMembers)
.set(input.role === "admin" ? { role: "admin" } : { role: "player" })
.where(
and(
eq(lobbyMembers.lobbyId, input.lobbyId),
eq(lobbyMembers.playerId, input.playerId),
),
)
.returning();
if (member) redisPublish("lobby:member:update", member);
return member;
}),
kick: protectedProcedure
.input(
z.object({
lobbyId: z.string(),
playerId: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
console.log("Check if user is admin");
const [member] = await ctx.db
.delete(lobbyMembers)
.where(
and(
eq(lobbyMembers.lobbyId, input.lobbyId),
eq(lobbyMembers.playerId, input.playerId),
),
)
.returning();
if (member)
redisPublish("lobby:member:leave", {
playerId: member.playerId,
kicked: true,
});
return member;
}),
// subscriptions // subscriptions
onMemberUpdate: protectedProcedure onUpdate: publicProcedure
.input( .input(trcpSubscriptionInput)
z
.object({
lastEventId: z.string().nullish(),
})
.optional(),
)
.subscription(async function* (opts) { .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 lobby of redisAsyncIterator("lobby:update")) {
yield tracked(lobby?.updatedAt?.toString(), {
lobby,
});
}
}),
onMemberUpdate: publicProcedure
.input(trcpSubscriptionInput)
.subscription(async function* (opts) {
if (opts.input?.lastEventId) { if (opts.input?.lastEventId) {
// fetch posts from a database that were missed. // fetch posts from a database that were missed.
} }
@ -184,15 +248,28 @@ export const lobbyRouter = createTRPCRouter({
for await (const { event, data: membership } of combineRedisIterators([ for await (const { event, data: membership } of combineRedisIterators([
"lobby:member:join", "lobby:member:join",
"lobby:member:leave", "lobby:member:leave",
"lobby:member:update",
])) { ])) {
const joined = event === "lobby:member:join"; switch (event) {
const id = case "lobby:member:join":
typeof membership === "string" ? membership : membership.playerId; yield tracked(String(membership.playerId), {
joined: true,
yield tracked(String(id), { membership,
joined, });
membership, break;
}); case "lobby:member:leave":
const data = membership as LobbyMemberLeaveEventData;
yield tracked(String(membership.playerId), {
joined: false,
membership: data,
});
break;
// case "lobby:member:update":
// yield tracked(String(membership.playerId), {
// membership,
// });
// break;
}
} }
}), }),
}); });

View File

@ -2,6 +2,7 @@ import { relations, sql } from "drizzle-orm";
import { pgTableCreator, index, primaryKey } from "drizzle-orm/pg-core"; import { pgTableCreator, index, primaryKey } from "drizzle-orm/pg-core";
import { type AdapterAccount } from "next-auth/adapters"; import { type AdapterAccount } from "next-auth/adapters";
import { createId } from "@paralleldrive/cuid2"; import { createId } from "@paralleldrive/cuid2";
import type { LobbyMemberRole } from "@/lib/validations/lobby";
/** /**
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same * This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
* database instance for multiple projects. * database instance for multiple projects.

View File

@ -1,6 +1,10 @@
import type { LobbyMember, Post } from "../db/schema"; import type { LobbyMember, Post } from "../db/schema";
export type LobbyMemberLeaveEventData = { playerId: string; kicked?: boolean };
export type PubSubEvents = { export type PubSubEvents = {
"lobby:update": Lobby;
"lobby:member:update": LobbyMember;
"lobby:member:join": LobbyMember; "lobby:member:join": LobbyMember;
"lobby:member:leave": string; "lobby:member:leave": LobbyMemberLeaveEventData;
}; };

View File

@ -31,18 +31,19 @@ const redisSubscribe = <T extends keyof PubSubEvents>(
* @param event - The Redis event to subscribe to. * @param event - The Redis event to subscribe to.
* @returns An async iterator that yields tracked events. * @returns An async iterator that yields tracked events.
*/ */
export function redisAsyncIterator<K extends keyof PubSubEvents>(event: K) { export function redisAsyncIterator<K extends keyof PubSubEvents>(
event: K,
): AsyncIterableIterator<PubSubEvents[K]> {
return { return {
[Symbol.asyncIterator]() { [Symbol.asyncIterator]() {
return { return this;
next(): Promise<IteratorResult<PubSubEvents[K]>> { },
return new Promise((resolve) => { async next(): Promise<IteratorResult<PubSubEvents[K]>> {
redisSubscribe(event, (data: PubSubEvents[K]) => { return new Promise((resolve) => {
resolve({ value: data, done: false }); redisSubscribe(event, (data: PubSubEvents[K]) => {
}); resolve({ value: data, done: false });
}); });
}, });
};
}, },
}; };
} }

View File

@ -6,6 +6,7 @@
:root { :root {
--radius: 0.625rem; --radius: 0.625rem;
--background: oklch(1 0 0); --background: oklch(1 0 0);
--background-transparent: oklch(1 0 0 / 20%);
--foreground: oklch(0.145 0 0); --foreground: oklch(0.145 0 0);
--card: oklch(1 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0); --card-foreground: oklch(0.145 0 0);
@ -41,6 +42,7 @@
.dark { .dark {
--background: oklch(0.145 0 0); --background: oklch(0.145 0 0);
--background-transparent: oklch(0.145 0 0 / 20%);
--foreground: oklch(0.985 0 0); --foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0); --card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.985 0 0);
@ -80,6 +82,7 @@
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px); --radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background); --color-background: var(--background);
--color-background-transparent: var(--background-transparent);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-card: var(--card); --color-card: var(--card);
--color-card-foreground: var(--card-foreground); --color-card-foreground: var(--card-foreground);
@ -126,7 +129,7 @@
} }
.container-bg { .container-bg {
@apply border-border/20 bg-border/10 rounded-2xl border text-white shadow-[0_0_15px_rgba(124,58,237,0.5)] backdrop-blur-md transition-all; @apply border-border/20 bg-background-transparent rounded-2xl border text-white shadow-[0_0_15px_rgba(124,58,237,0.5)] backdrop-blur-md transition-all;
} }
.gradient-bg { .gradient-bg {
@apply bg-gradient-to-br from-indigo-950 via-purple-900 to-indigo-900; @apply bg-gradient-to-br from-indigo-950 via-purple-900 to-indigo-900;

View File

@ -14,6 +14,7 @@ import SuperJSON from "superjson";
import { type AppRouter } from "@/server/api/root"; import { type AppRouter } from "@/server/api/root";
import { createQueryClient } from "./query-client"; import { createQueryClient } from "./query-client";
import { getBaseUrl } from "@/lib/utils";
let clientQueryClientSingleton: QueryClient | undefined = undefined; let clientQueryClientSingleton: QueryClient | undefined = undefined;
const getQueryClient = () => { const getQueryClient = () => {
@ -84,9 +85,3 @@ export function TRPCReactProvider(props: { children: React.ReactNode }) {
</QueryClientProvider> </QueryClientProvider>
); );
} }
function getBaseUrl() {
if (typeof window !== "undefined") return window.location.origin;
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
return `http://localhost:${process.env.PORT ?? 3000}`;
}