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 ( - <> -
-
- - -
- - - {lobbies.map((lobby) => ( -
  • - -
  • - ))} -
    + 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 ( - + + + Lobby Settings + + + +
    +
    +

    Danger Zone

    +
    +
    + + + + + ); +} + +export default LobbySettingsDialog; diff --git a/src/components/user-card.tsx b/src/app/_components/lobby/lobby_player-card.tsx similarity index 59% rename from src/components/user-card.tsx rename to src/app/_components/lobby/lobby_player-card.tsx index 0ad9d55..f44c9f0 100644 --- a/src/components/user-card.tsx +++ b/src/app/_components/lobby/lobby_player-card.tsx @@ -1,17 +1,16 @@ import React from "react"; -import Avatar from "./avatar"; +import Avatar from "../../../components/avatar"; import { cn } from "@/lib/utils"; import { MoreHorizontal } from "lucide-react"; -import { Button } from "./ui/button"; +import { Button } from "../../../components/ui/button"; +import type { Player } from "@/server/db/schema"; -function UserCard({ - name, - image, +function LobbyPlayerCard({ + player, children, className, }: { - name: string; - image: string; + player: Pick; children?: React.ReactNode; className?: string; }) { @@ -23,8 +22,8 @@ function UserCard({ )} >
    - -

    {name}

    + +

    {player.displayName}

    {children} + ); +} + +export default CopyToClip; diff --git a/src/components/footer.tsx b/src/components/footer.tsx index 3a996e1..745fc33 100644 --- a/src/components/footer.tsx +++ b/src/components/footer.tsx @@ -1,7 +1,7 @@ import Link from "next/link"; import React from "react"; import { Icons } from "./icons"; -import { appConfig } from "@/app.config"; +import { appConfig } from "@/config/app.config"; function Footer() { return ( diff --git a/src/components/navbar.tsx b/src/components/navbar.tsx index e9e40f2..81a5051 100644 --- a/src/components/navbar.tsx +++ b/src/components/navbar.tsx @@ -1,13 +1,16 @@ import React from "react"; -import { auth } from "@/server/auth"; import { Button } from "./ui/button"; import Link from "next/link"; -import UserPopover from "@/app/_components/user-popover"; -import { appConfig } from "@/app.config"; +import UserPopover from "@/app/_components/profile-popover"; +import { appConfig } from "@/config/app.config"; import NavLink from "./nav-link"; +import { api } from "@/trpc/server"; +import { Icons } from "./icons"; +import { appRoutes } from "@/config/app.routes"; async function Navbar() { - const session = await auth(); + const sessionPlayer = await api.player.getBySession(); + const currentLobby = sessionPlayer ? await api.lobby.getCurrentLobby() : null; return (
    ); 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