diff --git a/package.json b/package.json
index 69a4244..d7c3bcd 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 58f0492..cab5fd4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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: {}
diff --git a/src/app/(routes)/lobby/[id]/page.tsx b/src/app/(routes)/lobby/[id]/page.tsx
index d1bc729..4b27a8b 100644
--- a/src/app/(routes)/lobby/[id]/page.tsx
+++ b/src/app/(routes)/lobby/[id]/page.tsx
@@ -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 ;
+ return ;
}
export default Page;
diff --git a/src/app/(routes)/lobby/page.tsx b/src/app/(routes)/lobby/page.tsx
index eddb693..8468f71 100644
--- a/src/app/(routes)/lobby/page.tsx
+++ b/src/app/(routes)/lobby/page.tsx
@@ -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 (
- <>
-
-
-
-
-
-
-
+ const sessionPlayer = await api.player.getBySession();
+ if (!sessionPlayer) return redirect(appRoutes.signIn);
+ const lobby = sessionPlayer ? await api.lobby.getCurrentLobby() : null;
+ if (!lobby)
+ return (
+
+
+
- >
- );
+ );
+ return
;
}
export default Page;
diff --git a/src/app/(routes)/me/edit/page.tsx b/src/app/(routes)/me/edit/page.tsx
new file mode 100644
index 0000000..77e11c9
--- /dev/null
+++ b/src/app/(routes)/me/edit/page.tsx
@@ -0,0 +1,7 @@
+import React from "react";
+
+function page() {
+ return
Session player profile edit page
;
+}
+
+export default page;
diff --git a/src/app/(routes)/me/page.tsx b/src/app/(routes)/me/page.tsx
new file mode 100644
index 0000000..b1fb57e
--- /dev/null
+++ b/src/app/(routes)/me/page.tsx
@@ -0,0 +1,7 @@
+import React from "react";
+
+function Page() {
+ return
Session player profile
;
+}
+
+export default Page;
diff --git a/src/app/(routes)/page.tsx b/src/app/(routes)/page.tsx
index c80db67..fb7f850 100644
--- a/src/app/(routes)/page.tsx
+++ b/src/app/(routes)/page.tsx
@@ -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 (
<>
@@ -21,43 +25,47 @@ export default async function QuizGameStartPage() {
- {/* Game card */}
-
- {/* Create Lobby Button */}
-
- {session ? (
-
- ) : (
+
+ {currentLobby ? (
+
+ ) : (
+
+ {session ? (
+
+ ) : (
+
+ )}
+
+ {/* Join Lobby Button */}
- )}
- {/* Join Lobby Button */}
-
-
- {/* Quick Play Option */}
-
-
+ {/* Quick Play Option */}
+
+
+
-
+ )}
>
);
diff --git a/src/app/(routes)/profile/[id]/page.tsx b/src/app/(routes)/player/[id]/page.tsx
similarity index 100%
rename from src/app/(routes)/profile/[id]/page.tsx
rename to src/app/(routes)/player/[id]/page.tsx
diff --git a/src/app/(routes)/profile/[id]/edit/page.tsx b/src/app/(routes)/profile/[id]/edit/page.tsx
deleted file mode 100644
index 3652ef0..0000000
--- a/src/app/(routes)/profile/[id]/edit/page.tsx
+++ /dev/null
@@ -1,9 +0,0 @@
-import React from 'react'
-
-function page() {
- return (
-
page
- )
-}
-
-export default page
\ No newline at end of file
diff --git a/src/app/_components/lobby-page.tsx b/src/app/_components/lobby-page.tsx
deleted file mode 100644
index c28b42d..0000000
--- a/src/app/_components/lobby-page.tsx
+++ /dev/null
@@ -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
;
- initialMembers: Array<{ leader: boolean } & PublicUser>;
-}) {
- const [members, setMembers] = React.useState(initialMembers);
- const [memberPresence, setMemberPresence] = React.useState>();
- const isJoined = members.find((member) => member.id === session?.user.id);
- const isOwner = lobby.createdById === session?.user.id;
-
-
-
- return (
-
-
{lobby.name}
-
- {members?.map((member, idx) => (
- -
-
- {member?.leader && (
-
- Leader
-
- )}
-
-
- ))}
-
- {JSON.stringify(memberPresence)}
- {isOwner &&
}
- {!isOwner && (
-
- )}
-
- );
-}
-
-export default LobbyPage;
diff --git a/src/app/_components/create-lobby-dialog.tsx b/src/app/_components/lobby/create-lobby-dialog.tsx
similarity index 96%
rename from src/app/_components/create-lobby-dialog.tsx
rename to src/app/_components/lobby/create-lobby-dialog.tsx
index a0a7e3d..70a91cf 100644
--- a/src/app/_components/create-lobby-dialog.tsx
+++ b/src/app/_components/lobby/create-lobby-dialog.tsx
@@ -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);
diff --git a/src/app/_components/delete-lobby-dialog.tsx b/src/app/_components/lobby/delete-lobby-dialog.tsx
similarity index 90%
rename from src/app/_components/delete-lobby-dialog.tsx
rename to src/app/_components/lobby/delete-lobby-dialog.tsx
index 3b8fa59..8409e81 100644
--- a/src/app/_components/delete-lobby-dialog.tsx
+++ b/src/app/_components/lobby/delete-lobby-dialog.tsx
@@ -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 (
diff --git a/src/app/_components/lobby-card.tsx b/src/app/_components/lobby/lobby-card.tsx
similarity index 92%
rename from src/app/_components/lobby-card.tsx
rename to src/app/_components/lobby/lobby-card.tsx
index 82410be..38dd62d 100644
--- a/src/app/_components/lobby-card.tsx
+++ b/src/app/_components/lobby/lobby-card.tsx
@@ -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 (
-
+
diff --git a/src/app/_components/lobby/lobby-form.tsx b/src/app/_components/lobby/lobby-form.tsx
index 0b21585..dceefb8 100644
--- a/src/app/_components/lobby/lobby-form.tsx
+++ b/src/app/_components/lobby/lobby-form.tsx
@@ -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>({
resolver: zodResolver(lobbyPatchSchema),
defaultValues: {
- name: "",
+ name: server_lobby?.name ?? "",
+ maxPlayers: server_lobby?.maxPlayers ?? 0,
},
});
async function onSubmit(lobby: z.infer) {
@@ -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({
)}
/>
-
+
+
+
);
diff --git a/src/app/_components/lobby-membership-dialog.tsx b/src/app/_components/lobby/lobby-membership-dialog.tsx
similarity index 82%
rename from src/app/_components/lobby-membership-dialog.tsx
rename to src/app/_components/lobby/lobby-membership-dialog.tsx
index 3c05ee6..9eb8a91 100644
--- a/src/app/_components/lobby-membership-dialog.tsx
+++ b/src/app/_components/lobby/lobby-membership-dialog.tsx
@@ -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 (
-
);
diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx
index 0863e40..59aa5fc 100644
--- a/src/components/ui/alert-dialog.tsx
+++ b/src/components/ui/alert-dialog.tsx
@@ -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) {
- return
+ return ;
}
function AlertDialogTrigger({
@@ -17,7 +17,7 @@ function AlertDialogTrigger({
}: React.ComponentProps) {
return (
- )
+ );
}
function AlertDialogPortal({
@@ -25,7 +25,7 @@ function AlertDialogPortal({
}: React.ComponentProps) {
return (
- )
+ );
}
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({
- )
+ );
}
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,
-}
+};
diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx
index 130a969..6e82903 100644
--- a/src/components/ui/button.tsx
+++ b/src/components/ui/button.tsx
@@ -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 & {
+ asChild?: boolean;
+ };
+
function Button({
className,
variant,
size,
asChild = false,
...props
-}: React.ComponentProps<"button"> &
- VariantProps & {
- asChild?: boolean;
- }) {
+}: ButtonProps) {
const Comp = asChild ? Slot : "button";
return (
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx
index 7d7a9d3..301f453 100644
--- a/src/components/ui/dialog.tsx
+++ b/src/components/ui/dialog.tsx
@@ -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) {
- return
+ return ;
}
function DialogTrigger({
...props
}: React.ComponentProps) {
- return
+ return ;
}
function DialogPortal({
...props
}: React.ComponentProps) {
- return
+ return ;
}
function DialogClose({
...props
}: React.ComponentProps) {
- return
+ return ;
}
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({
@@ -69,7 +69,7 @@ function DialogContent({
- )
+ );
}
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,
-}
+};
diff --git a/src/app.config.ts b/src/config/app.config.ts
similarity index 76%
rename from src/app.config.ts
rename to src/config/app.config.ts
index 45393cd..55e2420 100644
--- a/src/app.config.ts
+++ b/src/config/app.config.ts
@@ -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,
},
],
};
diff --git a/src/config/app.routes.ts b/src/config/app.routes.ts
new file mode 100644
index 0000000..468ec0b
--- /dev/null
+++ b/src/config/app.routes.ts
@@ -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",
+};
diff --git a/src/index.d.ts b/src/index.d.ts
index 7a76082..e6db811 100644
--- a/src/index.d.ts
+++ b/src/index.d.ts
@@ -2,3 +2,5 @@ type NavLink = {
label: string;
path: string;
};
+
+type LobbyMemberRole = "player" | "admin";
diff --git a/src/server/api/root.ts b/src/server/api/root.ts
index 7a2dd7b..9f132a3 100644
--- a/src/server/api/root.ts
+++ b/src/server/api/root.ts
@@ -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
diff --git a/src/server/api/routers/lobby.ts b/src/server/api/routers/lobby.ts
index 570b8d2..e204614 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, 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,
+ });
}
}),
});
diff --git a/src/server/api/routers/player.ts b/src/server/api/routers/player.ts
new file mode 100644
index 0000000..13d3993
--- /dev/null
+++ b/src/server/api/routers/player.ts
@@ -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;
+ }),
+});
diff --git a/src/server/auth/config.ts b/src/server/auth/config.ts
index d24e790..6808a1a 100644
--- a/src/server/auth/config.ts
+++ b/src/server/auth/config.ts
@@ -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;
/**
* Options for NextAuth.js used to configure adapters, providers, callbacks, etc.
@@ -41,33 +25,18 @@ export type PublicUser = Pick;
* @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;
diff --git a/src/server/auth/custom-drizzle-adapter.ts b/src/server/auth/custom-drizzle-adapter.ts
new file mode 100644
index 0000000..139337b
--- /dev/null
+++ b/src/server/auth/custom-drizzle-adapter.ts
@@ -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;
+ },
+};
diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts
index 882d201..320dac1 100644
--- a/src/server/db/schema.ts
+++ b/src/server/db/schema.ts
@@ -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()
+ .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().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)],
diff --git a/src/server/redis/events.d.ts b/src/server/redis/events.d.ts
new file mode 100644
index 0000000..f087966
--- /dev/null
+++ b/src/server/redis/events.d.ts
@@ -0,0 +1,6 @@
+import type { LobbyMember, Post } from "../db/schema";
+
+export type PubSubEvents = {
+ "lobby:member:join": LobbyMember;
+ "lobby:member:leave": string;
+};
diff --git a/src/server/redis/sse-redis.ts b/src/server/redis/sse-redis.ts
new file mode 100644
index 0000000..d630df5
--- /dev/null
+++ b/src/server/redis/sse-redis.ts
@@ -0,0 +1,72 @@
+import Redis from "ioredis";
+import type { PubSubEvents } from "./events";
+
+const redisPub = new Redis();
+const redisSub = new Redis();
+
+export const redisPublish = (
+ event: T,
+ data: PubSubEvents[T],
+) => {
+ redisPub.publish(event, JSON.stringify(data));
+};
+
+const redisSubscribe = (
+ 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(event: K) {
+ return {
+ [Symbol.asyncIterator]() {
+ return {
+ next(): Promise> {
+ return new Promise((resolve) => {
+ redisSubscribe(event, (data: PubSubEvents[K]) => {
+ resolve({ value: data, done: false });
+ });
+ });
+ },
+ };
+ },
+ };
+}
+
+export async function* combineRedisIterators(
+ 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;
+ return { res, event: events[index] };
+ }),
+ ),
+ );
+
+ if (result.res.done) break;
+
+ yield { event: result.event, data: result.res.value };
+ }
+}
diff --git a/src/trpc/react.tsx b/src/trpc/react.tsx
index 1830106..169cc51 100644
--- a/src/trpc/react.tsx
+++ b/src/trpc/react.tsx
@@ -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;
+ },
+ }),
}),
],
}),
diff --git a/start-database.sh b/start-database.sh
index c6ec2d8..f333584 100755
--- a/start-database.sh
+++ b/start-database.sh
@@ -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