3 Commits
53 changed files with 1467 additions and 491 deletions
-1
View File
@@ -1 +0,0 @@
{"version":"7","dialect":"postgresql","entries":[]}
+2
View File
@@ -29,6 +29,7 @@
"@radix-ui/react-dropdown-menu": "^2.1.6", "@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2", "@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-popover": "^1.1.6", "@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-slider": "^1.2.3",
"@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-slot": "^1.1.2",
"@t3-oss/env-nextjs": "^0.12.0", "@t3-oss/env-nextjs": "^0.12.0",
"@tanstack/react-query": "^5.69.0", "@tanstack/react-query": "^5.69.0",
@@ -41,6 +42,7 @@
"dotenv": "^16.4.7", "dotenv": "^16.4.7",
"drizzle-orm": "^0.41.0", "drizzle-orm": "^0.41.0",
"express": "^4.21.2", "express": "^4.21.2",
"ioredis": "^5.6.0",
"lucide-react": "^0.483.0", "lucide-react": "^0.483.0",
"mysql2": "^3.11.0", "mysql2": "^3.11.0",
"next": "^15.2.3", "next": "^15.2.3",
+116
View File
@@ -35,6 +35,9 @@ importers:
'@radix-ui/react-popover': '@radix-ui/react-popover':
specifier: ^1.1.6 specifier: ^1.1.6
version: 1.1.6(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) version: 1.1.6(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-slider':
specifier: ^1.2.3
version: 1.2.3(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-slot': '@radix-ui/react-slot':
specifier: ^1.1.2 specifier: ^1.1.2
version: 1.1.2(@types/react@19.0.12)(react@19.0.0) version: 1.1.2(@types/react@19.0.12)(react@19.0.0)
@@ -71,6 +74,9 @@ importers:
express: express:
specifier: ^4.21.2 specifier: ^4.21.2
version: 4.21.2 version: 4.21.2
ioredis:
specifier: ^5.6.0
version: 5.6.0
lucide-react: lucide-react:
specifier: ^0.483.0 specifier: ^0.483.0
version: 0.483.0(react@19.0.0) version: 0.483.0(react@19.0.0)
@@ -826,6 +832,9 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@ioredis/commands@1.2.0':
resolution: {integrity: sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==}
'@napi-rs/wasm-runtime@0.2.7': '@napi-rs/wasm-runtime@0.2.7':
resolution: {integrity: sha512-5yximcFK5FNompXfJFoWanu5l8v1hNGqNHh9du1xETp9HWk/B/PzvchX55WYOPaIeNglG8++68AAiauBAtbnzw==} resolution: {integrity: sha512-5yximcFK5FNompXfJFoWanu5l8v1hNGqNHh9du1xETp9HWk/B/PzvchX55WYOPaIeNglG8++68AAiauBAtbnzw==}
@@ -912,6 +921,9 @@ packages:
'@petamoriken/float16@3.9.2': '@petamoriken/float16@3.9.2':
resolution: {integrity: sha512-VgffxawQde93xKxT3qap3OH+meZf7VaSB5Sqd4Rqc+FP5alWbpOyan/7tRbOAvynjpG3GpdtAuGU/NdhQpmrog==} resolution: {integrity: sha512-VgffxawQde93xKxT3qap3OH+meZf7VaSB5Sqd4Rqc+FP5alWbpOyan/7tRbOAvynjpG3GpdtAuGU/NdhQpmrog==}
'@radix-ui/number@1.1.0':
resolution: {integrity: sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==}
'@radix-ui/primitive@1.1.1': '@radix-ui/primitive@1.1.1':
resolution: {integrity: sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==} resolution: {integrity: sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==}
@@ -1168,6 +1180,19 @@ packages:
'@types/react-dom': '@types/react-dom':
optional: true optional: true
'@radix-ui/react-slider@1.2.3':
resolution: {integrity: sha512-nNrLAWLjGESnhqBqcCNW4w2nn7LxudyMzeB6VgdyAnFLC6kfQgnAjSL2v6UkQTnDctJBlxrmxfplWS4iYjdUTw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-slot@1.1.2': '@radix-ui/react-slot@1.1.2':
resolution: {integrity: sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==} resolution: {integrity: sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==}
peerDependencies: peerDependencies:
@@ -1213,6 +1238,15 @@ packages:
'@types/react': '@types/react':
optional: true optional: true
'@radix-ui/react-use-previous@1.1.0':
resolution: {integrity: sha512-Z/e78qg2YFnnXcW88A4JmTtm4ADckLno6F7OXotmkQfeuCVaKuYzqAATPhVzl3delXE7CxIV8shofPn3jPc5Og==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-rect@1.1.0': '@radix-ui/react-use-rect@1.1.0':
resolution: {integrity: sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==} resolution: {integrity: sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==}
peerDependencies: peerDependencies:
@@ -1691,6 +1725,10 @@ packages:
resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==}
engines: {node: '>=6'} 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: color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'} engines: {node: '>=7.0.0'}
@@ -2308,6 +2346,10 @@ packages:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
ioredis@5.6.0:
resolution: {integrity: sha512-tBZlIIWbndeWBWCXWZiqtOF/yxf6yZX3tAlTJ7nfo5jhd6dctNxF7QnYlZLZ1a0o0pDoen7CgZqO+zjNaFbJAg==}
engines: {node: '>=12.22.0'}
ipaddr.js@1.9.1: ipaddr.js@1.9.1:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'} engines: {node: '>= 0.10'}
@@ -2551,6 +2593,12 @@ packages:
resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==}
engines: {node: '>=10'} 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: lodash.merge@4.6.2:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
@@ -2950,6 +2998,14 @@ packages:
resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==} resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==}
engines: {node: '>=0.10.0'} 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: reflect.getprototypeof@1.0.10:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -3102,6 +3158,9 @@ packages:
stable-hash@0.0.5: stable-hash@0.0.5:
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
standard-as-callback@2.1.0:
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
statuses@2.0.1: statuses@2.0.1:
resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
@@ -3745,6 +3804,8 @@ snapshots:
'@img/sharp-win32-x64@0.33.5': '@img/sharp-win32-x64@0.33.5':
optional: true optional: true
'@ioredis/commands@1.2.0': {}
'@napi-rs/wasm-runtime@0.2.7': '@napi-rs/wasm-runtime@0.2.7':
dependencies: dependencies:
'@emnapi/core': 1.3.1 '@emnapi/core': 1.3.1
@@ -3806,6 +3867,8 @@ snapshots:
'@petamoriken/float16@3.9.2': {} '@petamoriken/float16@3.9.2': {}
'@radix-ui/number@1.1.0': {}
'@radix-ui/primitive@1.1.1': {} '@radix-ui/primitive@1.1.1': {}
'@radix-ui/react-alert-dialog@1.1.6(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': '@radix-ui/react-alert-dialog@1.1.6(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
@@ -4069,6 +4132,25 @@ snapshots:
'@types/react': 19.0.12 '@types/react': 19.0.12
'@types/react-dom': 19.0.4(@types/react@19.0.12) '@types/react-dom': 19.0.4(@types/react@19.0.12)
'@radix-ui/react-slider@1.2.3(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
dependencies:
'@radix-ui/number': 1.1.0
'@radix-ui/primitive': 1.1.1
'@radix-ui/react-collection': 1.1.2(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.12)(react@19.0.0)
'@radix-ui/react-context': 1.1.1(@types/react@19.0.12)(react@19.0.0)
'@radix-ui/react-direction': 1.1.0(@types/react@19.0.12)(react@19.0.0)
'@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-use-controllable-state': 1.1.0(@types/react@19.0.12)(react@19.0.0)
'@radix-ui/react-use-layout-effect': 1.1.0(@types/react@19.0.12)(react@19.0.0)
'@radix-ui/react-use-previous': 1.1.0(@types/react@19.0.12)(react@19.0.0)
'@radix-ui/react-use-size': 1.1.0(@types/react@19.0.12)(react@19.0.0)
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
optionalDependencies:
'@types/react': 19.0.12
'@types/react-dom': 19.0.4(@types/react@19.0.12)
'@radix-ui/react-slot@1.1.2(@types/react@19.0.12)(react@19.0.0)': '@radix-ui/react-slot@1.1.2(@types/react@19.0.12)(react@19.0.0)':
dependencies: dependencies:
'@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.12)(react@19.0.0) '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.12)(react@19.0.0)
@@ -4102,6 +4184,12 @@ snapshots:
optionalDependencies: optionalDependencies:
'@types/react': 19.0.12 '@types/react': 19.0.12
'@radix-ui/react-use-previous@1.1.0(@types/react@19.0.12)(react@19.0.0)':
dependencies:
react: 19.0.0
optionalDependencies:
'@types/react': 19.0.12
'@radix-ui/react-use-rect@1.1.0(@types/react@19.0.12)(react@19.0.0)': '@radix-ui/react-use-rect@1.1.0(@types/react@19.0.12)(react@19.0.0)':
dependencies: dependencies:
'@radix-ui/rect': 1.1.0 '@radix-ui/rect': 1.1.0
@@ -4592,6 +4680,8 @@ snapshots:
clsx@2.1.1: {} clsx@2.1.1: {}
cluster-key-slot@1.1.2: {}
color-convert@2.0.1: color-convert@2.0.1:
dependencies: dependencies:
color-name: 1.1.4 color-name: 1.1.4
@@ -5367,6 +5457,20 @@ snapshots:
hasown: 2.0.2 hasown: 2.0.2
side-channel: 1.1.0 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: {} ipaddr.js@1.9.1: {}
is-array-buffer@3.0.5: is-array-buffer@3.0.5:
@@ -5593,6 +5697,10 @@ snapshots:
dependencies: dependencies:
p-locate: 5.0.0 p-locate: 5.0.0
lodash.defaults@4.2.0: {}
lodash.isarguments@3.1.0: {}
lodash.merge@4.6.2: {} lodash.merge@4.6.2: {}
long@5.3.1: {} long@5.3.1: {}
@@ -5902,6 +6010,12 @@ snapshots:
react@19.0.0: {} 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: reflect.getprototypeof@1.0.10:
dependencies: dependencies:
call-bind: 1.0.8 call-bind: 1.0.8
@@ -6128,6 +6242,8 @@ snapshots:
stable-hash@0.0.5: {} stable-hash@0.0.5: {}
standard-as-callback@2.1.0: {}
statuses@2.0.1: {} statuses@2.0.1: {}
streamsearch@1.1.0: {} streamsearch@1.1.0: {}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

+3 -11
View File
@@ -1,10 +1,7 @@
import { api } from "@/trpc/server"; import { api } from "@/trpc/server";
import type { User } from "next-auth";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import React from "react"; import React from "react";
import type { PublicUser } from "@/server/auth/config"; import LobbyPage from "@/app/_components/lobby/lobby-page";
import { auth } from "@/server/auth";
import LobbyPage from "@/app/_components/lobby-page";
async function Page({ async function Page({
params, params,
@@ -13,17 +10,12 @@ async function Page({
id: string; id: string;
}>; }>;
}) { }) {
const session = await auth(); const sessionPlayer = await api.player.getBySession();
const { id } = await params; const { id } = await params;
const lobby = await api.lobby.get({ id }); const lobby = await api.lobby.get({ id });
if (!lobby) return notFound(); if (!lobby) return notFound();
const members: Array<{ leader: boolean } & PublicUser> = [ return <LobbyPage initialLobby={lobby} sessionPlayer={sessionPlayer} />;
{ ...lobby.leader, leader: true },
...(lobby?.members?.map(({ user }) => ({ ...user, leader: false })) ?? []),
];
return <LobbyPage lobby={lobby} initialMembers={members} session={session} />;
} }
export default Page; export default Page;
+9 -16
View File
@@ -1,15 +1,17 @@
import React from "react"; import React from "react";
import { api } from "@/trpc/server"; import { api } from "@/trpc/server";
import CreateLobbyDialog from "@/app/_components/create-lobby-dialog"; import CreateLobbyDialog from "@/app/_components/lobby/create-lobby-dialog";
import LobbyCard from "@/app/_components/lobby-card";
import { Button } from "@/components/ui/button"; 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() { async function Page() {
const lobbies = await api.lobby.getAll(); const sessionPlayer = await api.player.getBySession();
if (!sessionPlayer) return redirect(appRoutes.signIn);
const lobby = sessionPlayer ? await api.lobby.getCurrentLobby() : null;
if (!lobby)
return ( return (
<>
<div className="w-full max-w-md space-y-4">
<div className="flex w-full gap-4"> <div className="flex w-full gap-4">
<CreateLobbyDialog className="grow" /> <CreateLobbyDialog className="grow" />
<Button <Button
@@ -20,17 +22,8 @@ async function Page() {
Placeholder Placeholder
</Button> </Button>
</div> </div>
<menu>
{lobbies.map((lobby) => (
<li key={lobby.id}>
<LobbyCard lobby={lobby} />
</li>
))}
</menu>
</div>
</>
); );
return <LobbyPage initialLobby={lobby} sessionPlayer={sessionPlayer} />;
} }
export default Page; export default Page;
+7
View File
@@ -0,0 +1,7 @@
import React from "react";
function page() {
return <div>Session player profile edit page</div>;
}
export default page;
+7
View File
@@ -0,0 +1,7 @@
import React from "react";
function Page() {
return <div>Session player profile </div>;
}
export default Page;
+13 -5
View File
@@ -1,13 +1,17 @@
import Link from "next/link"; import Link from "next/link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Sparkles, Users, Plus } from "lucide-react"; 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 { auth } from "@/server/auth";
import { Icons } from "@/components/icons"; 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() { export default async function QuizGameStartPage() {
const session = await auth(); const session = await auth();
const currentLobby = session ? await api.lobby.getCurrentLobby() : null;
return ( return (
<> <>
<div className="mb-8 flex flex-col items-center justify-center gap-2 pt-12 text-center"> <div className="mb-8 flex flex-col items-center justify-center gap-2 pt-12 text-center">
@@ -21,9 +25,12 @@ export default async function QuizGameStartPage() {
</p> </p>
</div> </div>
{/* Game card */} <div className="container-bg w-full max-w-md p-8">
<div className="container-bg max-w-md p-8"> {currentLobby ? (
{/* Create Lobby Button */} <Button variant={"party"} size={"xxl"} className="w-full" asChild>
<Link href={appRoutes.currentlobby}>Jump to Lobby</Link>
</Button>
) : (
<div className="space-y-6"> <div className="space-y-6">
{session ? ( {session ? (
<CreateLobbyDialog className="text-shadow-primary w-full" /> <CreateLobbyDialog className="text-shadow-primary w-full" />
@@ -58,6 +65,7 @@ export default async function QuizGameStartPage() {
</Button> </Button>
</div> </div>
</div> </div>
)}
</div> </div>
</> </>
); );
@@ -1,9 +0,0 @@
import React from 'react'
function page() {
return (
<div>page</div>
)
}
export default page
-57
View File
@@ -1,57 +0,0 @@
"use client";
import React from "react";
import type { PublicUser } from "@/server/auth/config";
import type { Lobby } from "@/server/db/schema";
import UserCard from "@/components/user-card";
import DeleteLobbyDialog from "@/app/_components/delete-lobby-dialog";
import LobbyMembershipDialog from "@/app/_components/lobby-membership-dialog";
import { type Session } from "next-auth";
import { Badge } from "@/components/ui/badge";
function LobbyPage({
session,
initialMembers,
lobby,
}: {
session: Session | null;
lobby: Pick<Lobby, "id" | "name" | "createdAt" | "createdById">;
initialMembers: Array<{ leader: boolean } & PublicUser>;
}) {
const [members, setMembers] = React.useState(initialMembers);
const [memberPresence, setMemberPresence] = React.useState<Array<string>>();
const isJoined = members.find((member) => member.id === session?.user.id);
const isOwner = lobby.createdById === session?.user.id;
return (
<div className="container-bg w-full max-w-md space-y-4 p-6">
<h1 className="text-2xl font-bold capitalize">{lobby.name}</h1>
<ul className="space-y-2">
{members?.map((member, idx) => (
<li key={idx}>
<UserCard
name={member.name!}
image={member.image!}
className="relative"
>
{member?.leader && (
<Badge className="absolute -top-2 right-2 p-px px-2 text-white">
Leader
</Badge>
)}
</UserCard>
</li>
))}
</ul>
{JSON.stringify(memberPresence)}
{isOwner && <DeleteLobbyDialog lobbyId={lobby.id} />}
{!isOwner && (
<LobbyMembershipDialog lobbyId={lobby.id} join={!isJoined} />
)}
</div>
);
}
export default LobbyPage;
@@ -12,7 +12,7 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import LobbyForm from "./lobby/lobby-form"; import LobbyForm from "./lobby-form";
function CreateLobbyDialog({ className }: { className?: string }) { function CreateLobbyDialog({ className }: { className?: string }) {
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
@@ -16,6 +16,7 @@ import {
import { api } from "@/trpc/react"; import { api } from "@/trpc/react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { appRoutes } from "@/config/app.routes";
function DeleteLobbyDialog({ lobbyId }: { lobbyId: string }) { function DeleteLobbyDialog({ lobbyId }: { lobbyId: string }) {
const [loading, setLoading] = React.useState(false); const [loading, setLoading] = React.useState(false);
@@ -25,7 +26,8 @@ function DeleteLobbyDialog({ lobbyId }: { lobbyId: string }) {
setLoading(true); setLoading(true);
const result = await mutateAsync({ lobbyId }); const result = await mutateAsync({ lobbyId });
if (result) { if (result) {
router.push("/"); router.push(appRoutes.home);
router.refresh();
} else toast.error("Something went wrong"); } else toast.error("Something went wrong");
setLoading(false); setLoading(false);
}; };
@@ -33,7 +35,7 @@ function DeleteLobbyDialog({ lobbyId }: { lobbyId: string }) {
return ( return (
<AlertDialog> <AlertDialog>
<Button asChild variant={"destructive"}> <Button asChild variant={"destructive"}>
<AlertDialogTrigger>Remove Lobby</AlertDialogTrigger> <AlertDialogTrigger>Delete Lobby</AlertDialogTrigger>
</Button> </Button>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
+102
View File
@@ -0,0 +1,102 @@
import { cn } from "@/lib/utils";
import Image from "next/image";
import React from "react";
type Game = {
id: number;
name: string;
description: string;
image: string;
minPlayers: number;
maxPlayers: number;
};
const mokGames: Array<Game> = [
{
id: 1,
name: "Mok Game 1",
description: "A simple game of strategy and luck",
image: "/game-placeholder.jpg",
maxPlayers: 2,
minPlayers: 2,
},
{
id: 2,
name: "Mok Game 2",
description: "A simple game of strategy and luck",
image: "/game-placeholder.jpg",
maxPlayers: 2,
minPlayers: 2,
},
{
id: 3,
name: "Mok Game 3",
description: "A simple game of strategy and luck",
image: "/game-placeholder.jpg",
maxPlayers: 2,
minPlayers: 2,
},
];
function GameSelector({
selectGame,
selectedGame,
}: {
selectGame?: (gameId: Game["id"]) => void;
selectedGame?: Game["id"];
}) {
const [selected, setSelected] = React.useState(selectedGame ?? 1);
return (
<div className="container-bg grid w-full grid-cols-3 gap-4 p-6">
{mokGames.map((game) => (
<GameCard
key={game.id}
game={game}
selected={selected === game.id}
onClick={() => setSelected(game.id)}
/>
))}
</div>
);
}
const GameCard = ({
game,
selected,
onClick,
}: {
game: Game;
selected?: boolean;
onClick?: () => void;
}) => {
return (
<div
className={cn(
"container-bg group w-full cursor-pointer items-center justify-between space-y-1 overflow-hidden",
selected &&
"before:bg-primary/40 before:absolute before:inset-0 before:-z-10 before:w-full before:animate-pulse before:rounded-md before:blur-xl",
)}
onClick={onClick}
>
<div className="relative h-20 w-full">
<Image
fill
alt="game-image"
src={game.image}
className={cn(
"object-cover opacity-50 transition-all duration-300 ease-in-out group-hover:opacity-90",
selected && "opacity-100",
)}
/>
</div>
<div className="flex items-center gap-2 px-4 pb-4">
<div>
<h4 className="font-meidum text-xl font-bold">{game.name}</h4>
<div className="text-xs text-white/75">{game.description}</div>
</div>
</div>
</div>
);
};
export default GameSelector;
@@ -9,10 +9,11 @@ import {
import Link from "next/link"; import Link from "next/link";
import { formatDate } from "@/lib/utils"; import { formatDate } from "@/lib/utils";
import { ArrowRight, ChevronRight } from "lucide-react"; import { ArrowRight, ChevronRight } from "lucide-react";
import { appRoutes } from "@/config/app.routes";
function LobbyCard({ lobby }: { lobby: Lobby }) { function LobbyCard({ lobby }: { lobby: Lobby }) {
return ( return (
<Link href={`/lobby/${lobby.id}`}> <Link href={appRoutes.lobby(lobby.id)}>
<Card className="group border-none bg-transparent p-0 shadow-none"> <Card className="group border-none bg-transparent p-0 shadow-none">
<div className="container-bg py-4"> <div className="container-bg py-4">
<CardHeader className="flex w-full items-center justify-between"> <CardHeader className="flex w-full items-center justify-between">
+44 -4
View File
@@ -19,6 +19,9 @@ import { toast } from "sonner";
import { api } from "@/trpc/react"; import { api } from "@/trpc/react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { appRoutes } from "@/config/app.routes";
import { Slider } from "@/components/ui/slider";
import { getMinMax } from "@/lib/validations/utils";
function LobbyForm({ function LobbyForm({
server_lobby, server_lobby,
@@ -34,7 +37,8 @@ function LobbyForm({
const form = useForm<z.infer<typeof lobbyPatchSchema>>({ const form = useForm<z.infer<typeof lobbyPatchSchema>>({
resolver: zodResolver(lobbyPatchSchema), resolver: zodResolver(lobbyPatchSchema),
defaultValues: { defaultValues: {
name: "", name: server_lobby?.name ?? "",
maxPlayers: server_lobby?.maxPlayers ?? 2,
}, },
}); });
async function onSubmit(lobby: z.infer<typeof lobbyPatchSchema>) { async function onSubmit(lobby: z.infer<typeof lobbyPatchSchema>) {
@@ -45,9 +49,13 @@ function LobbyForm({
: await createLobby({ lobby }); : await createLobby({ lobby });
cb?.(); cb?.();
if (result) { if (result) {
if (!existingLobby) router.push(`/lobby/${result.id}`); if (!existingLobby) {
router.push(appRoutes.currentlobby);
router.refresh();
}
} else toast.error("Something went wrong."); } else toast.error("Something went wrong.");
setLoading(false); setLoading(false);
form.reset();
} }
return ( return (
@@ -70,9 +78,41 @@ function LobbyForm({
</FormItem> </FormItem>
)} )}
/> />
<Button type="submit" disabled={loading || !form.formState.isDirty}> <FormField
Create Lobby control={form.control}
name="maxPlayers"
render={({ field }) => {
const { min, max } = getMinMax(lobbyPatchSchema.shape.maxPlayers);
return (
<FormItem>
<FormLabel>
Max Players
<span className="text-xl font-bold">{`${field.value}`}</span>
</FormLabel>
<FormControl>
<Slider
className="w-full max-w-xs"
defaultValue={[field.value]}
max={max ?? 100}
min={min ?? 1}
step={1}
onValueChange={(value) => field.onChange(value[0])}
/>
</FormControl>
<FormMessage />
</FormItem>
);
}}
/>
<div className="flex items-center justify-end">
<Button
type="submit"
disabled={loading || !form.formState.isDirty}
className="ml-auto"
>
{server_lobby?.id?.length ? "Update" : "Create"} Lobby
</Button> </Button>
</div>
</form> </form>
</Form> </Form>
); );
@@ -16,6 +16,7 @@ import {
import { api } from "@/trpc/react"; import { api } from "@/trpc/react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { appRoutes } from "@/config/app.routes";
function LobbyMembershipDialog({ function LobbyMembershipDialog({
lobbyId, lobbyId,
@@ -25,23 +26,25 @@ function LobbyMembershipDialog({
join: boolean; join: boolean;
}) { }) {
const [loading, setLoading] = React.useState(false); const [loading, setLoading] = React.useState(false);
const { mutateAsync } = api.lobby.membership.useMutation(); const membership = api.lobby.membership.useMutation();
const labelText = join ? "join" : "leave";
const router = useRouter(); const router = useRouter();
const handleConfirm = async () => { const handleConfirm = async () => {
setLoading(true); setLoading(true);
const result = await mutateAsync({ lobbyId, join }); const result = await membership.mutateAsync({ lobbyId, join });
if (result) { if (result) {
if (!join) router.push("/"); toast.success(`Successfully ${labelText} the lobby.`);
else toast.success("Successfully joined the lobby."); if (join) router.push(appRoutes.currentlobby);
else router.push(appRoutes.lobby(lobbyId));
router.refresh();
} else toast.error("Something went wrong"); } else toast.error("Something went wrong");
setLoading(false); setLoading(false);
}; };
const labelText = join ? "join" : "leave";
return ( return (
<AlertDialog> <AlertDialog>
<Button asChild> <Button asChild variant={join ? "default" : "destructive"}>
<AlertDialogTrigger className="capitalize"> <AlertDialogTrigger className="capitalize">
{labelText} Lobby {labelText} Lobby
</AlertDialogTrigger> </AlertDialogTrigger>
@@ -62,7 +65,7 @@ function LobbyMembershipDialog({
disabled={loading} disabled={loading}
className="capitalize" className="capitalize"
> >
{labelText} {labelText} Lobby
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
+91
View File
@@ -0,0 +1,91 @@
"use client";
import React from "react";
import type { Lobby, Player } from "@/server/db/schema";
import LobbyPlayerCard from "@/app/_components/lobby/lobby-player/lobby-player-card";
import LobbyMembershipDialog from "@/app/_components/lobby/lobby-membership-dialog";
import { Badge } from "@/components/ui/badge";
import CopyToClip from "@/components/copy-to-clip";
import { appRoutes } from "@/config/app.routes";
import LobbySettingsDialog from "./lobby-settings-dialog";
import { useRealtimeLobby } from "@/hooks/use-lobby";
import { getBaseUrl } from "@/lib/utils";
import GameSelector from "./game-selector";
function LobbyPage({
sessionPlayer,
initialLobby,
}: {
sessionPlayer?: Player | null;
initialLobby: Lobby;
}) {
const { members, lobby } = useRealtimeLobby({ initialLobby, sessionPlayer });
const isJoined = members.find((m) => m.playerId === sessionPlayer?.id);
const isAdmin = isJoined?.role === "admin";
return (
<div className="grid max-w-4xl grid-cols-3 grid-rows-2 gap-4">
<div className="container-bg col-span-2 w-full space-y-4 p-6">
<div className="flex items-center gap-2">
{isJoined && (
<CopyToClip
target="invite link"
text={getBaseUrl() + appRoutes.lobby(lobby.id)}
buttonProps={{ variant: "outline" }}
>
Invite Players
</CopyToClip>
)}
<div className="ml-auto">
{sessionPlayer && (
<LobbyMembershipDialog lobbyId={lobby.id} join={!isJoined} />
)}
</div>
</div>
<div className="flex w-full items-center justify-between text-nowrap">
<h1 className="w-full overflow-hidden text-2xl font-bold text-ellipsis">
{lobby.name}
</h1>
{isAdmin && <LobbySettingsDialog lobby={lobby} />}
</div>
<div className="border-border/20 flex items-center justify-between border-b pb-4">
<h2 className="text-sm font-medium">
Players ({members?.length || 0}/{lobby.maxPlayers || 8})
</h2>
<span className="text-xs">
{members?.length === (lobby.maxPlayers || 8)
? "Lobby Full"
: "Waiting..."}
</span>
</div>
<ul className="space-y-2">
{members?.map((member, idx) => (
<li key={idx}>
<LobbyPlayerCard
lobbyId={lobby.id}
highlight={member?.player?.id === sessionPlayer?.id}
player={member?.player!}
className="relative"
showOptions={isAdmin && member?.playerId !== sessionPlayer?.id}
>
{member?.role === "admin" && (
<Badge className="absolute -top-2 right-2 z-20 p-px px-2 text-white">
Admin
</Badge>
)}
</LobbyPlayerCard>
</li>
))}
</ul>
</div>
<div className="container-bg col-span-1 size-full"></div>
<div className="col-span-3">
<GameSelector />
</div>
</div>
);
}
export default LobbyPage;
@@ -0,0 +1,66 @@
import type { Player } from "@/server/db/schema";
import React from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { ShieldQuestion, UserX } from "lucide-react";
import { api } from "@/trpc/react";
import { Button } from "@/components/ui/button";
function KickPlayerDialog({
player,
lobbyId,
}: {
player: Pick<Player, "id" | "displayName" | "avatar">;
lobbyId: string;
}) {
const kickPlayer = api.lobby.kick.useMutation();
const handleAction = () => {
kickPlayer.mutate({ lobbyId: lobbyId, playerId: player.id });
};
return (
<AlertDialog>
<AlertDialogTrigger className="flex items-center gap-1">
<UserX className="group-focus:text-destructive size-4 text-white" />
Kick Player
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-1">
Are you absolutely sure to
<span className="text-destructive font-bold">
kick {player.displayName}
</span>
<ShieldQuestion className="text-destructive size-4" />
</AlertDialogTitle>
<AlertDialogDescription>
This action cannot be undone. This will remove {player.displayName}
from the lobby.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<Button
asChild
variant={"destructive"}
disabled={kickPlayer.isPending}
>
<AlertDialogAction onClick={handleAction}>
{kickPlayer.isPending ? "Kicking..." : "Kick Player"}
</AlertDialogAction>
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
export default KickPlayerDialog;
@@ -0,0 +1,41 @@
import React from "react";
import Avatar from "@/components/avatar";
import { cn } from "@/lib/utils";
import type { Player } from "@/server/db/schema";
import LobbyPlayerOptions from "./lobby-player-options";
function LobbyPlayerCard({
lobbyId,
player,
children,
className,
highlight,
showOptions = false,
}: {
lobbyId: string;
player: Player;
children?: React.ReactNode;
className?: string;
highlight?: boolean;
showOptions?: boolean;
}) {
return (
<div
className={cn(
"container-bg flex items-center justify-between gap-2 p-4",
highlight &&
"before:bg-primary/40 before:absolute before:inset-0 before:-z-10 before:w-1/3 before:animate-pulse before:rounded-md before:blur-xl",
className,
)}
>
<div className="flex items-center gap-2">
<Avatar src={player.avatar!} fb={player.displayName!} />
<h4 className="font-meidum text-xl">{player.displayName}</h4>
</div>
{children}
{showOptions && <LobbyPlayerOptions lobbyId={lobbyId} player={player} />}
</div>
);
}
export default LobbyPlayerCard;
@@ -0,0 +1,86 @@
"use client";
import React from "react";
import { Button } from "@/components/ui/button";
import {
Ban,
Eye,
Handshake,
HeartHandshake,
MoreHorizontal,
UserCog,
UserX,
} from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import type { Player } from "@/server/db/schema";
import Link from "next/link";
import { appRoutes } from "@/config/app.routes";
import { cn } from "@/lib/utils";
import KickPlayerDialog from "./kick-player-dialog";
import { api } from "@/trpc/react";
function LobbyPlayerOptions({
player,
lobbyId,
}: {
player: Player;
lobbyId: string;
}) {
const [open, setOpen] = React.useState(false);
const dropdownItemClassName = " flex items-center gap-1 ";
const changeRole = api.lobby.changeRole.useMutation();
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size={"icon"}
variant={"ghost"}
className="hover:bg-border/10 hover:text-white"
>
<MoreHorizontal />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel className="w-32 truncate overflow-hidden font-bold">
{player.displayName}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem className={dropdownItemClassName} asChild>
<Link href={appRoutes.playerProfile(player.id)} target="_blank">
<Eye className="size-4 text-white" />
View Profile
</Link>
</DropdownMenuItem>
<DropdownMenuItem className={dropdownItemClassName} asChild>
<Link href={appRoutes.friend(player.id)} target="_blank">
<HeartHandshake className="size-4 text-white" />
My Friend
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className={dropdownItemClassName}>
<UserCog className="size-4 text-white" />
Change Role
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => e.preventDefault()}
className={cn(
dropdownItemClassName,
"group focus:border-destructive focus:text-destructive border border-transparent focus:font-bold",
)}
>
<KickPlayerDialog lobbyId={lobbyId} player={player} />
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
export default LobbyPlayerOptions;
@@ -0,0 +1,42 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import type { Lobby } from "@/server/db/schema";
import { DialogDescription } from "@radix-ui/react-dialog";
import React from "react";
import LobbyForm from "./lobby-form";
import DeleteLobbyDialog from "./delete-lobby-dialog";
import { Settings } from "lucide-react";
function LobbySettingsDialog({ lobby }: { lobby: Lobby }) {
const [open, setOpen] = React.useState(false);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant={"ghost"} size={"icon"}>
<Settings className="size-4" />
</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>Lobby Settings</DialogTitle>
<DialogDescription></DialogDescription>
<LobbyForm cb={() => setOpen(false)} server_lobby={lobby} />
<div className="mt-20 flex items-center">
<div className="bg-muted-foreground h-px w-12" />
<p className="relative z-10 px-2 text-xs">Danger Zone</p>
<div className="bg-muted-foreground h-px grow" />
</div>
<DeleteLobbyDialog lobbyId={lobby.id} />
</DialogContent>
</Dialog>
);
}
export default LobbySettingsDialog;
@@ -8,36 +8,36 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import type { User } from "next-auth";
import Avatar from "@/components/avatar"; import Avatar from "@/components/avatar";
import { Edit, Eye, LogOut } from "lucide-react"; import { Edit, Eye, LogOut } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { Player } from "@/server/db/schema";
import { appRoutes } from "@/config/app.routes";
function UserPopover({ user }: { user: User }) { function UserPopover({ player }: { player: Player }) {
const dropdownItemClassName = const dropdownItemClassName = " flex items-center gap-1 ";
"focus:bg-border/30 focus:text-white flex items-center gap-1 ";
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger> <DropdownMenuTrigger>
<Avatar <Avatar
src={user.image!} src={player.avatar!}
fb={user.name!} fb={player.displayName!}
className="border-border/20 size-10 cursor-pointer border-2" className="border-border/20 size-10 cursor-pointer border-2"
/> />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className="bg-border/30 container-bg text-white"> <DropdownMenuContent>
<DropdownMenuLabel className="w-32 truncate overflow-hidden font-bold"> <DropdownMenuLabel className="w-32 truncate overflow-hidden font-bold">
{user.name} {player.displayName}
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem className={dropdownItemClassName} asChild> <DropdownMenuItem className={dropdownItemClassName} asChild>
<Link href={`/profile/${user.id}`}> <Link href={appRoutes.me}>
<Eye className="size-4 text-white" /> <Eye className="size-4 text-white" />
View Profile View Profile
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem className={dropdownItemClassName} asChild> <DropdownMenuItem className={dropdownItemClassName} asChild>
<Link href={`/profile/${user.id}/edit`}> <Link href={appRoutes.editProfile}>
<Edit className="size-4 text-white" /> <Edit className="size-4 text-white" />
Edit Profile Edit Profile
</Link> </Link>
@@ -50,7 +50,7 @@ function UserPopover({ user }: { user: User }) {
)} )}
asChild asChild
> >
<Link href="/api/auth/signout"> <Link href={appRoutes.signOut}>
<LogOut className="group-focus:text-destructive size-4 text-white" /> <LogOut className="group-focus:text-destructive size-4 text-white" />
Log out Log out
</Link> </Link>
+29
View File
@@ -0,0 +1,29 @@
"use client";
import React from "react";
import { Button, type ButtonProps } from "./ui/button";
import { toast } from "sonner";
import type { title } from "process";
function CopyToClip({
text,
target,
buttonProps,
children,
}: {
text: string;
target?: string;
buttonProps?: ButtonProps;
children?: React.ReactNode;
}) {
const handleclick = () => {
navigator.clipboard.writeText(text);
toast(<p>Copied {target ? <b>{target}</b> : ""} to clipboard</p>);
};
return (
<Button {...buttonProps} onClick={handleclick}>
{children}
</Button>
);
}
export default CopyToClip;
+1 -1
View File
@@ -1,7 +1,7 @@
import Link from "next/link"; import Link from "next/link";
import React from "react"; import React from "react";
import { Icons } from "./icons"; import { Icons } from "./icons";
import { appConfig } from "@/app.config"; import { appConfig } from "@/config/app.config";
function Footer() { function Footer() {
return ( return (
+20 -7
View File
@@ -1,13 +1,16 @@
import React from "react"; import React from "react";
import { auth } from "@/server/auth";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import Link from "next/link"; import Link from "next/link";
import UserPopover from "@/app/_components/user-popover"; import UserPopover from "@/app/_components/profile-popover";
import { appConfig } from "@/app.config"; import { appConfig } from "@/config/app.config";
import NavLink from "./nav-link"; import NavLink from "./nav-link";
import { api } from "@/trpc/server";
import { Icons } from "./icons";
import { appRoutes } from "@/config/app.routes";
async function Navbar() { async function Navbar() {
const session = await auth(); const sessionPlayer = await api.player.getBySession();
const currentLobby = sessionPlayer ? await api.lobby.getCurrentLobby() : null;
return ( return (
<nav className="flex w-full items-center justify-center p-4"> <nav className="flex w-full items-center justify-center p-4">
@@ -19,15 +22,25 @@ async function Navbar() {
</li> </li>
))} ))}
</menu> </menu>
{/* Login Button or Profile Avatar */}
{session?.user ? ( <div className="bg-border/10 border-border/20 flex items-center rounded-full border-l-2">
<UserPopover user={session.user} /> {currentLobby ? (
<Link href={appRoutes.currentlobby}>
<div className="border-border/20 flex h-10 items-center justify-center gap-2 pr-2 pl-4 font-semibold">
<Icons.logo className="size-4" />
<span>Lobby</span>
</div>
</Link>
) : null}
{sessionPlayer ? (
<UserPopover player={sessionPlayer} />
) : ( ) : (
<Button asChild className="bg-primary font-black"> <Button asChild className="bg-primary font-black">
<Link href="/api/auth/signin">Sign In</Link> <Link href="/api/auth/signin">Sign In</Link>
</Button> </Button>
)} )}
</div> </div>
</div>
</nav> </nav>
); );
} }
+22 -22
View File
@@ -1,15 +1,15 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { buttonVariants } from "@/components/ui/button" import { buttonVariants } from "@/components/ui/button";
function AlertDialog({ function AlertDialog({
...props ...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) { }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} /> return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
} }
function AlertDialogTrigger({ function AlertDialogTrigger({
@@ -17,7 +17,7 @@ function AlertDialogTrigger({
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) { }: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return ( return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} /> <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
) );
} }
function AlertDialogPortal({ function AlertDialogPortal({
@@ -25,7 +25,7 @@ function AlertDialogPortal({
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) { }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return ( return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} /> <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
) );
} }
function AlertDialogOverlay({ function AlertDialogOverlay({
@@ -37,11 +37,11 @@ function AlertDialogOverlay({
data-slot="alert-dialog-overlay" data-slot="alert-dialog-overlay"
className={cn( 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", "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} {...props}
/> />
) );
} }
function AlertDialogContent({ function AlertDialogContent({
@@ -54,13 +54,13 @@ function AlertDialogContent({
<AlertDialogPrimitive.Content <AlertDialogPrimitive.Content
data-slot="alert-dialog-content" data-slot="alert-dialog-content"
className={cn( className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg", "bg-background/20 container-bg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className className,
)} )}
{...props} {...props}
/> />
</AlertDialogPortal> </AlertDialogPortal>
) );
} }
function AlertDialogHeader({ function AlertDialogHeader({
@@ -73,7 +73,7 @@ function AlertDialogHeader({
className={cn("flex flex-col gap-2 text-center sm:text-left", className)} className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props} {...props}
/> />
) );
} }
function AlertDialogFooter({ function AlertDialogFooter({
@@ -85,11 +85,11 @@ function AlertDialogFooter({
data-slot="alert-dialog-footer" data-slot="alert-dialog-footer"
className={cn( className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function AlertDialogTitle({ function AlertDialogTitle({
@@ -102,7 +102,7 @@ function AlertDialogTitle({
className={cn("text-lg font-semibold", className)} className={cn("text-lg font-semibold", className)}
{...props} {...props}
/> />
) );
} }
function AlertDialogDescription({ function AlertDialogDescription({
@@ -112,10 +112,10 @@ function AlertDialogDescription({
return ( return (
<AlertDialogPrimitive.Description <AlertDialogPrimitive.Description
data-slot="alert-dialog-description" data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)} className={cn("text-sm text-white/75", className)}
{...props} {...props}
/> />
) );
} }
function AlertDialogAction({ function AlertDialogAction({
@@ -127,7 +127,7 @@ function AlertDialogAction({
className={cn(buttonVariants(), className)} className={cn(buttonVariants(), className)}
{...props} {...props}
/> />
) );
} }
function AlertDialogCancel({ function AlertDialogCancel({
@@ -139,7 +139,7 @@ function AlertDialogCancel({
className={cn(buttonVariants({ variant: "outline" }), className)} className={cn(buttonVariants({ variant: "outline" }), className)}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -154,4 +154,4 @@ export {
AlertDialogDescription, AlertDialogDescription,
AlertDialogAction, AlertDialogAction,
AlertDialogCancel, AlertDialogCancel,
} };
+8 -7
View File
@@ -14,11 +14,10 @@ const buttonVariants = cva(
destructive: 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", "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: 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: secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: ghost: "hover:bg-background/10 dark:hover:bg-accent/50 rounded-full",
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50 rounded-full",
link: "text-primary underline-offset-4 hover:underline", link: "text-primary underline-offset-4 hover:underline",
party: 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 ",
@@ -38,16 +37,18 @@ const buttonVariants = cva(
}, },
); );
export type ButtonProps = React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
};
function Button({ function Button({
className, className,
variant, variant,
size, size,
asChild = false, asChild = false,
...props ...props
}: React.ComponentProps<"button"> & }: ButtonProps) {
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
}) {
const Comp = asChild ? Slot : "button"; const Comp = asChild ? Slot : "button";
return ( return (
+20 -20
View File
@@ -1,33 +1,33 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog" import * as DialogPrimitive from "@radix-ui/react-dialog";
import { XIcon } from "lucide-react" import { XIcon } from "lucide-react";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Dialog({ function Dialog({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) { }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} /> return <DialogPrimitive.Root data-slot="dialog" {...props} />;
} }
function DialogTrigger({ function DialogTrigger({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) { }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} /> return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
} }
function DialogPortal({ function DialogPortal({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) { }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} /> return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
} }
function DialogClose({ function DialogClose({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) { }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} /> return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
} }
function DialogOverlay({ function DialogOverlay({
@@ -39,11 +39,11 @@ function DialogOverlay({
data-slot="dialog-overlay" data-slot="dialog-overlay"
className={cn( 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", "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} {...props}
/> />
) );
} }
function DialogContent({ function DialogContent({
@@ -57,8 +57,8 @@ function DialogContent({
<DialogPrimitive.Content <DialogPrimitive.Content
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg", "bg-background/20 container-bg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className className,
)} )}
{...props} {...props}
> >
@@ -69,7 +69,7 @@ function DialogContent({
</DialogPrimitive.Close> </DialogPrimitive.Close>
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
) );
} }
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { 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)} className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props} {...props}
/> />
) );
} }
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) { function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
@@ -88,11 +88,11 @@ function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
data-slot="dialog-footer" data-slot="dialog-footer"
className={cn( className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", "flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function DialogTitle({ function DialogTitle({
@@ -105,7 +105,7 @@ function DialogTitle({
className={cn("text-lg leading-none font-semibold", className)} className={cn("text-lg leading-none font-semibold", className)}
{...props} {...props}
/> />
) );
} }
function DialogDescription({ function DialogDescription({
@@ -118,7 +118,7 @@ function DialogDescription({
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
) );
} }
export { export {
@@ -132,4 +132,4 @@ export {
DialogPortal, DialogPortal,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} };
+2 -2
View File
@@ -42,7 +42,7 @@ function DropdownMenuContent({
data-slot="dropdown-menu-content" data-slot="dropdown-menu-content"
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md", "bg-background/30 container-bg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 text-white shadow-md",
className, className,
)} )}
{...props} {...props}
@@ -74,7 +74,7 @@ function DropdownMenuItem({
data-inset={inset} data-inset={inset}
data-variant={variant} data-variant={variant}
className={cn( className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "focus:bg-border/30 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:text-white data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className, className,
)} )}
{...props} {...props}
+11 -11
View File
@@ -1,20 +1,20 @@
"use client" "use client";
import * as React from "react" import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover" import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
function Popover({ function Popover({
...props ...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) { }: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} /> return <PopoverPrimitive.Root data-slot="popover" {...props} />;
} }
function PopoverTrigger({ function PopoverTrigger({
...props ...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) { }: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} /> return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />;
} }
function PopoverContent({ function PopoverContent({
@@ -30,19 +30,19 @@ function PopoverContent({
align={align} align={align}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden", "bg-background/30 container-bg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className className,
)} )}
{...props} {...props}
/> />
</PopoverPrimitive.Portal> </PopoverPrimitive.Portal>
) );
} }
function PopoverAnchor({ function PopoverAnchor({
...props ...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) { }: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} /> return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />;
} }
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
+63
View File
@@ -0,0 +1,63 @@
"use client"
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max]
)
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary bg-background ring-ring/50 block size-4 shrink-0 rounded-full border shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
)
}
export { Slider }
+11 -9
View File
@@ -1,25 +1,27 @@
"use client" "use client";
import { useTheme } from "next-themes" import { useTheme } from "next-themes";
import { Toaster as Sonner, ToasterProps } from "sonner" import { Toaster as Sonner, type ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => { const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme() const { theme = "system" } = useTheme();
return ( return (
<Sonner <Sonner
theme={theme as ToasterProps["theme"]} theme={theme as ToasterProps["theme"]}
className="toaster group" className="toaster group"
richColors
style={ style={
{ {
"--normal-bg": "var(--popover)", "--normal-bg":
"--normal-text": "var(--popover-foreground)", "color-mix(in oklab, var(--background), transparent 20%)",
"--normal-text": "var(--foreground)",
"--normal-border": "var(--border)", "--normal-border": "var(--border)",
} as React.CSSProperties } as React.CSSProperties
} }
{...props} {...props}
/> />
) );
} };
export { Toaster } export { Toaster };
-41
View File
@@ -1,41 +0,0 @@
import React from "react";
import Avatar from "./avatar";
import { cn } from "@/lib/utils";
import { MoreHorizontal } from "lucide-react";
import { Button } from "./ui/button";
function UserCard({
name,
image,
children,
className,
}: {
name: string;
image: string;
children?: React.ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"container-bg flex items-center justify-between gap-2 p-4",
className,
)}
>
<div className="flex items-center gap-2">
<Avatar src={image} fb={name} />
<h4 className="font-meidum text-xl">{name}</h4>
</div>
{children}
<Button
size={"icon"}
variant={"ghost"}
className="hover:bg-border/10 hover:text-white"
>
<MoreHorizontal />
</Button>
</div>
);
}
export default UserCard;
@@ -1,3 +1,5 @@
import { appRoutes } from "./app.routes";
type AppConfig = { type AppConfig = {
name: string; name: string;
description: string; description: string;
@@ -10,15 +12,11 @@ export const appConfig: AppConfig = {
navigation: [ navigation: [
{ {
label: "Home", label: "Home",
path: "/", path: appRoutes.home,
},
{
label: "Lobbies",
path: "/lobby",
}, },
{ {
label: "Games", label: "Games",
path: "/game", path: appRoutes.game,
}, },
], ],
}; };
+17
View File
@@ -0,0 +1,17 @@
export const appRoutes = {
home: "/",
game: "/game",
currentlobby: "/lobby",
lobby: (id: string) => `/lobby/${id}`,
me: "/me",
editProfile: "/me/edit",
playerProfile: (id: string) => `/player/${id}`,
friend: (playerId: string) => `/me/friend/${playerId}`,
firendsOverview: "/me/friends",
signIn: "/api/auth/signin",
signOut: "/api/auth/signout",
};
+50
View File
@@ -0,0 +1,50 @@
"use client";
import { appRoutes } from "@/config/app.routes";
import type { Lobby, LobbyMember, Player } from "@/server/db/schema";
import type { LobbyMemberLeaveEventData } from "@/server/redis/events";
import { api } from "@/trpc/react";
import { useRouter } from "next/navigation";
import React from "react";
export function useRealtimeLobby({
initialLobby,
sessionPlayer,
}: {
initialLobby: Lobby;
sessionPlayer?: Player | null;
}) {
const [lobby, setLobby] = React.useState(initialLobby);
const [members, setMembers] = React.useState<Array<LobbyMember>>(
initialLobby?.members ?? [],
);
const router = useRouter();
api.lobby.onMemberUpdate.useSubscription(undefined, {
onData({ data: _data }) {
const joined = _data.joined;
if (joined) {
const data = _data.membership as LobbyMember;
setMembers((prev) => {
if (prev.find((m) => m.playerId === data.playerId)) return prev;
return [...prev, data];
});
} else {
const data = _data.membership as LobbyMemberLeaveEventData;
setMembers((prev) => prev.filter((m) => m.playerId !== data.playerId));
if (data?.kicked && data?.playerId === sessionPlayer?.id) {
router.push(appRoutes.lobby(lobby.id));
router.refresh();
}
}
},
});
api.lobby.onUpdate.useSubscription(undefined, {
onData({ data }) {
if (!data?.lobby) return;
setLobby((prev) => ({ ...data.lobby, members: prev.members }));
},
});
return { lobby, members };
}
+6
View File
@@ -13,3 +13,9 @@ export const formatDate = (date: Date) =>
hour: "numeric", hour: "numeric",
minute: "numeric", minute: "numeric",
}); });
export function getBaseUrl() {
if (typeof window !== "undefined") return window.location.origin;
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
return `http://localhost:${process.env.PORT ?? 3000}`;
}
+6 -3
View File
@@ -6,8 +6,11 @@ export const lobbyPatchSchema = z.object({
.min(2, { .min(2, {
message: "Name must contain at least 2 characters", message: "Name must contain at least 2 characters",
}) })
.max(255, { .max(64, {
message: "Name can only contain up to 255 characters", message: "Name can only contain up to 64 characters",
}), }),
maxPlayers: z.number().default(0), maxPlayers: z.number().min(2).max(12).default(2),
}); });
export const LobbyMemberRoleSchema = z.enum(["admin", "player"]);
export type LobbyMemberRole = z.infer<typeof LobbyMemberRoleSchema>;
+7
View File
@@ -0,0 +1,7 @@
import { z } from "zod";
export const trcpSubscriptionInput = z
.object({
lastEventId: z.string().nullish(),
})
.optional();
+11
View File
@@ -0,0 +1,11 @@
import { z } from "zod";
export const getMinMax = (schema: z.ZodNumber | z.ZodDefault<z.ZodNumber>) => {
const baseSchema =
schema instanceof z.ZodDefault ? schema.removeDefault() : schema;
const min =
baseSchema._def.checks.find((c) => c.kind === "min")?.value ?? null;
const max =
baseSchema._def.checks.find((c) => c.kind === "max")?.value ?? null;
return { min, max };
};
+2
View File
@@ -1,5 +1,6 @@
import { lobbyRouter } from "@/server/api/routers/lobby"; import { lobbyRouter } from "@/server/api/routers/lobby";
import { createCallerFactory, createTRPCRouter } from "@/server/api/trpc"; import { createCallerFactory, createTRPCRouter } from "@/server/api/trpc";
import { playerRouter } from "./routers/player";
/** /**
* This is the primary router for your server. * This is the primary router for your server.
@@ -8,6 +9,7 @@ import { createCallerFactory, createTRPCRouter } from "@/server/api/trpc";
*/ */
export const appRouter = createTRPCRouter({ export const appRouter = createTRPCRouter({
lobby: lobbyRouter, lobby: lobbyRouter,
player: playerRouter,
}); });
// export type definition of API // export type definition of API
+161 -44
View File
@@ -1,32 +1,43 @@
import { z } from "zod"; import { z } from "zod";
import { lobbies, lobbyMembers } from "@/server/db/schema"; import { lobbies, lobbyMembers, players, type Lobby } from "@/server/db/schema";
import { import {
createTRPCRouter, createTRPCRouter,
protectedProcedure, protectedProcedure,
publicProcedure, publicProcedure,
} from "@/server/api/trpc"; } from "@/server/api/trpc";
import { lobbyPatchSchema } from "@/lib/validations/lobby"; import {
LobbyMemberRoleSchema,
lobbyPatchSchema,
} from "@/lib/validations/lobby";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { time } from "console"; import {
combineRedisIterators,
redisAsyncIterator,
redisPublish,
} from "@/server/redis/sse-redis";
import { tracked } from "@trpc/server";
import { trcpSubscriptionInput } from "@/lib/validations/trcp";
import type { LobbyMemberLeaveEventData } from "@/server/redis/events";
export const lobbyRouter = createTRPCRouter({ export const lobbyRouter = createTRPCRouter({
// queries // queries
getAll: protectedProcedure.query(async ({ ctx }) => { getCurrentLobby: protectedProcedure.query(async ({ ctx }) => {
const ownedLobbies = await ctx.db.query.lobbies.findMany({ const reuslt = await ctx.db.query.lobbyMembers.findFirst({
where: eq(lobbies.createdById, ctx.session.user.id), where: eq(lobbyMembers.playerId, ctx.session.user.id),
});
const joinedLobbies = await ctx.db.query.lobbyMembers.findMany({
where: eq(lobbyMembers.userId, ctx.session.user.id),
with: { with: {
lobby: true, lobby: {
with: {
members: {
with: {
player: true,
},
},
},
},
}, },
}); });
return reuslt?.lobby!;
return [
...ownedLobbies,
...(joinedLobbies?.map(({ lobby }) => lobby) ?? []),
];
}), }),
get: publicProcedure get: publicProcedure
@@ -40,24 +51,9 @@ export const lobbyRouter = createTRPCRouter({
await ctx.db.query.lobbies.findFirst({ await ctx.db.query.lobbies.findFirst({
where: eq(lobbies.id, input.id), where: eq(lobbies.id, input.id),
with: { with: {
leader: {
columns: {
image: true,
name: true,
id: true,
joinedAt: true,
},
},
members: { members: {
with: { with: {
user: { player: true,
columns: {
image: true,
name: true,
id: true,
joinedAt: true,
},
},
}, },
}, },
}, },
@@ -79,6 +75,15 @@ export const lobbyRouter = createTRPCRouter({
createdById: ctx.session.user.id, createdById: ctx.session.user.id,
}) })
.returning({ id: lobbies.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; return lobby;
}), }),
update: protectedProcedure update: protectedProcedure
@@ -89,6 +94,8 @@ export const lobbyRouter = createTRPCRouter({
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
console.log("Check if user is admin");
const [lobby] = await ctx.db const [lobby] = await ctx.db
.update(lobbies) .update(lobbies)
.set(input.lobby) .set(input.lobby)
@@ -98,7 +105,9 @@ export const lobbyRouter = createTRPCRouter({
eq(lobbies.createdById, ctx.session.user.id), eq(lobbies.createdById, ctx.session.user.id),
), ),
) )
.returning({ id: lobbies.id }); .returning();
if (lobby) redisPublish("lobby:update", lobby);
return lobby; return lobby;
}), }),
delete: protectedProcedure delete: protectedProcedure
@@ -108,6 +117,8 @@ export const lobbyRouter = createTRPCRouter({
}), }),
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
console.log("Check if user is admin");
const [lobby] = await ctx.db const [lobby] = await ctx.db
.delete(lobbies) .delete(lobbies)
.where( .where(
@@ -129,30 +140,136 @@ export const lobbyRouter = createTRPCRouter({
) )
.mutation(async ({ ctx, input }) => { .mutation(async ({ ctx, input }) => {
if (input.join) { if (input.join) {
return ( const [member] = await ctx.db
await ctx.db
.insert(lobbyMembers) .insert(lobbyMembers)
.values({ .values({
lobbyId: input.lobbyId, lobbyId: input.lobbyId,
userId: ctx.session.user.id, playerId: ctx.session.user.id,
isReady: false, isReady: false,
joinedAt: new Date(), role: "player",
role: "member",
}) })
.returning() .returning();
)[0]; const player = member
? await ctx.db.query.players.findFirst({
where: eq(players.id, member.playerId),
})
: undefined;
if (member) redisPublish("lobby:member:join", { ...member, player });
return member;
} else { } else {
return ( const [member] = await ctx.db
await ctx.db
.delete(lobbyMembers) .delete(lobbyMembers)
.where( .where(
and( and(
eq(lobbyMembers.lobbyId, input.lobbyId), eq(lobbyMembers.lobbyId, input.lobbyId),
eq(lobbyMembers.userId, ctx.session.user.id), eq(lobbyMembers.playerId, ctx.session.user.id),
), ),
) )
.returning() .returning();
)[0]; if (member)
redisPublish("lobby:member:leave", { playerId: member.playerId });
return member;
}
}),
// admin mutaions
changeRole: protectedProcedure
.input(
z.object({
lobbyId: z.string(),
playerId: z.string(),
role: LobbyMemberRoleSchema,
}),
)
.mutation(async ({ ctx, input }) => {
console.log("Check if user is admin");
const [member] = await ctx.db
.update(lobbyMembers)
.set(input.role === "admin" ? { role: "admin" } : { role: "player" })
.where(
and(
eq(lobbyMembers.lobbyId, input.lobbyId),
eq(lobbyMembers.playerId, input.playerId),
),
)
.returning();
if (member) redisPublish("lobby:member:update", member);
return member;
}),
kick: protectedProcedure
.input(
z.object({
lobbyId: z.string(),
playerId: z.string(),
}),
)
.mutation(async ({ ctx, input }) => {
console.log("Check if user is admin");
const [member] = await ctx.db
.delete(lobbyMembers)
.where(
and(
eq(lobbyMembers.lobbyId, input.lobbyId),
eq(lobbyMembers.playerId, input.playerId),
),
)
.returning();
if (member)
redisPublish("lobby:member:leave", {
playerId: member.playerId,
kicked: true,
});
return member;
}),
// subscriptions
onUpdate: publicProcedure
.input(trcpSubscriptionInput)
.subscription(async function* (opts) {
if (opts.input?.lastEventId) {
// fetch posts from a database that were missed.
}
for await (const lobby of redisAsyncIterator("lobby:update")) {
yield tracked(lobby?.updatedAt?.toString(), {
lobby,
});
}
}),
onMemberUpdate: publicProcedure
.input(trcpSubscriptionInput)
.subscription(async function* (opts) {
if (opts.input?.lastEventId) {
// fetch posts from a database that were missed.
}
for await (const { event, data: membership } of combineRedisIterators([
"lobby:member:join",
"lobby:member:leave",
"lobby:member:update",
])) {
switch (event) {
case "lobby:member:join":
yield tracked(String(membership.playerId), {
joined: true,
membership,
});
break;
case "lobby:member:leave":
const data = membership as LobbyMemberLeaveEventData;
yield tracked(String(membership.playerId), {
joined: false,
membership: data,
});
break;
// case "lobby:member:update":
// yield tracked(String(membership.playerId), {
// membership,
// });
// break;
}
} }
}), }),
}); });
+13
View File
@@ -0,0 +1,13 @@
import { eq } from "drizzle-orm";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
import { players } from "@/server/db/schema";
export const playerRouter = createTRPCRouter({
getBySession: publicProcedure.query(async ({ ctx }) => {
return ctx?.session?.user
? await ctx.db.query.players.findFirst({
where: eq(players.id, ctx.session.user.id),
})
: null;
}),
});
+11 -42
View File
@@ -1,15 +1,6 @@
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import { type DefaultSession, type NextAuthConfig, type User } from "next-auth"; import { type DefaultSession, type NextAuthConfig, type User } from "next-auth";
import DiscordProvider from "next-auth/providers/discord"; import DiscordProvider from "next-auth/providers/discord";
import { CustomDrizzleAdapter } from "./custom-drizzle-adapter";
import { db } from "@/server/db";
import {
accounts,
sessions,
users,
verificationTokens,
} from "@/server/db/schema";
import type { Adapter } from "next-auth/adapters";
/** /**
* Module augmentation for `next-auth` types. Allows us to add custom properties to the `session` * 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 * @see https://next-auth.js.org/getting-started/typescript#module-augmentation
*/ */
declare module "next-auth" { declare module "next-auth" {
interface Session extends DefaultSession { interface Session extends DefaultSession {
user: { user: {
id: string; id: string;
joinedAt: Date; email: string;
// ...other properties name: string;
// role: UserRole; };
} & DefaultSession["user"];
}
interface User {
joinedAt: Date;
// ...other properties
// role: UserRole;
} }
} }
export type PublicUser = Pick<User, "name" | "image" | "id" | "joinedAt">;
/** /**
* Options for NextAuth.js used to configure adapters, providers, callbacks, etc. * Options for NextAuth.js used to configure adapters, providers, callbacks, etc.
@@ -41,33 +25,18 @@ export type PublicUser = Pick<User, "name" | "image" | "id" | "joinedAt">;
* @see https://next-auth.js.org/configuration/options * @see https://next-auth.js.org/configuration/options
*/ */
export const authConfig = { export const authConfig = {
providers: [ providers: [DiscordProvider],
DiscordProvider, adapter: CustomDrizzleAdapter,
/**
* ...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,
callbacks: { callbacks: {
session: ({ session, user }) => ({ session: async ({ session, user }) => {
return {
...session, ...session,
user: { user: {
...session.user, ...session.user,
id: user.id, id: user.id,
joinedAt: user.joinedAt,
}, },
}), };
},
}, },
} satisfies NextAuthConfig; } satisfies NextAuthConfig;
+39
View File
@@ -0,0 +1,39 @@
import { DrizzleAdapter } from "@auth/drizzle-adapter";
import { db } from "@/server/db";
import {
accounts,
players,
sessions,
users,
verificationTokens,
} from "@/server/db/schema";
import type { Adapter } from "next-auth/adapters";
export const CustomDrizzleAdapter: Adapter = {
...(DrizzleAdapter(db, {
usersTable: users as any,
accountsTable: accounts,
sessionsTable: sessions,
verificationTokensTable: verificationTokens,
}) as Adapter),
async createUser(rawUser) {
// Insert user **without** the image field
const [user] = await db
.insert(users)
.values({
name: rawUser.name,
email: rawUser.email,
emailVerified: rawUser.emailVerified,
})
.returning();
if (!user) throw new Error("Error creating user");
await db.insert(players).values({
id: user.id,
displayName: rawUser.name,
avatar: rawUser.image,
});
return user;
},
};
+53 -24
View File
@@ -2,6 +2,7 @@ import { relations, sql } from "drizzle-orm";
import { pgTableCreator, index, primaryKey } from "drizzle-orm/pg-core"; import { pgTableCreator, index, primaryKey } from "drizzle-orm/pg-core";
import { type AdapterAccount } from "next-auth/adapters"; import { type AdapterAccount } from "next-auth/adapters";
import { createId } from "@paralleldrive/cuid2"; import { createId } from "@paralleldrive/cuid2";
import type { LobbyMemberRole } from "@/lib/validations/lobby";
/** /**
* This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same * This is an example of how to use the multi-project schema feature of Drizzle ORM. Use the same
* database instance for multiple projects. * database instance for multiple projects.
@@ -10,6 +11,12 @@ import { createId } from "@paralleldrive/cuid2";
*/ */
export const createTable = pgTableCreator((name) => `game-master_${name}`); 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) => ({ export const lobbies = createTable("lobby", (d) => ({
id: d id: d
.varchar({ length: 255 }) .varchar({ length: 255 })
@@ -21,22 +28,22 @@ export const lobbies = createTable("lobby", (d) => ({
createdById: d createdById: d
.varchar({ length: 255 }) .varchar({ length: 255 })
.notNull() .notNull()
.references(() => users.id, { onDelete: "cascade" }), .references(() => players.id, { onDelete: "cascade" }),
createdAt: d createdAt: defaultTimeStamp("created_at", d),
.timestamp("created_at", { withTimezone: true })
.default(sql`CURRENT_TIMESTAMP`)
.notNull(),
updatedAt: d updatedAt: d
.timestamp("updated_at", { withTimezone: true }) .timestamp("updated_at", { withTimezone: true })
.$onUpdate(() => new Date()), .$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 }) => ({ export const lobbyRelations = relations(lobbies, ({ many, one }) => ({
leader: one(users, { leader: one(players, {
fields: [lobbies.createdById], fields: [lobbies.createdById],
references: [users.id], references: [players.id],
}), }),
members: many(lobbyMembers), members: many(lobbyMembers),
})); }));
@@ -44,38 +51,65 @@ export const lobbyRelations = relations(lobbies, ({ many, one }) => ({
export const lobbyMembers = createTable( export const lobbyMembers = createTable(
"lobby_member", "lobby_member",
(d) => ({ (d) => ({
userId: d playerId: d
.varchar({ length: 255 }) .varchar({ length: 255 })
.notNull() .notNull()
.references(() => users.id, { onDelete: "cascade" }), .references(() => players.id, { onDelete: "cascade" }),
lobbyId: d lobbyId: d
.varchar({ length: 255 }) .varchar({ length: 255 })
.notNull() .notNull()
.references(() => lobbies.id, { onDelete: "cascade" }), .references(() => lobbies.id, { onDelete: "cascade" }),
joinedAt: d.timestamp("created_at", { withTimezone: true }).notNull(), joinedAt: defaultTimeStamp("joined_at", d),
role: d.varchar({ length: 255 }).notNull(), role: d
.varchar({ length: 255 })
.notNull()
.$type<LobbyMemberRole>()
.default("player"),
isReady: d.boolean().notNull(), 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 }) => ({ export const lobbyMembersRelations = relations(lobbyMembers, ({ one }) => ({
lobby: one(lobbies, { lobby: one(lobbies, {
fields: [lobbyMembers.lobbyId], fields: [lobbyMembers.lobbyId],
references: [lobbies.id], references: [lobbies.id],
}), }),
user: one(users, { player: one(players, {
fields: [lobbyMembers.userId], fields: [lobbyMembers.playerId],
references: [users.id], 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) => ({ export const users = createTable("user", (d) => ({
id: d id: d
.varchar({ length: 255 }) .varchar({ length: 255 })
.notNull() .notNull()
.primaryKey() .primaryKey()
.$defaultFn(() => crypto.randomUUID()), .$defaultFn(() => createId()),
name: d.varchar({ length: 255 }), name: d.varchar({ length: 255 }),
email: d.varchar({ length: 255 }).notNull(), email: d.varchar({ length: 255 }).notNull(),
emailVerified: d emailVerified: d
@@ -84,11 +118,6 @@ export const users = createTable("user", (d) => ({
withTimezone: true, withTimezone: true,
}) })
.default(sql`CURRENT_TIMESTAMP`), .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 }) => ({ export const usersRelations = relations(users, ({ many }) => ({
@@ -102,7 +131,7 @@ export const accounts = createTable(
userId: d userId: d
.varchar({ length: 255 }) .varchar({ length: 255 })
.notNull() .notNull()
.references(() => users.id), .references(() => users.id, { onDelete: "cascade" }),
type: d.varchar({ length: 255 }).$type<AdapterAccount["type"]>().notNull(), type: d.varchar({ length: 255 }).$type<AdapterAccount["type"]>().notNull(),
provider: d.varchar({ length: 255 }).notNull(), provider: d.varchar({ length: 255 }).notNull(),
providerAccountId: d.varchar({ length: 255 }).notNull(), providerAccountId: d.varchar({ length: 255 }).notNull(),
@@ -133,7 +162,7 @@ export const sessions = createTable(
userId: d userId: d
.varchar({ length: 255 }) .varchar({ length: 255 })
.notNull() .notNull()
.references(() => users.id), .references(() => users.id, { onDelete: "cascade" }),
expires: d.timestamp({ mode: "date" }).notNull(), expires: d.timestamp({ mode: "date" }).notNull(),
}), }),
(t) => [index("session_user_id_idx").on(t.userId)], (t) => [index("session_user_id_idx").on(t.userId)],
+10
View File
@@ -0,0 +1,10 @@
import type { LobbyMember, Post } from "../db/schema";
export type LobbyMemberLeaveEventData = { playerId: string; kicked?: boolean };
export type PubSubEvents = {
"lobby:update": Lobby;
"lobby:member:update": LobbyMember;
"lobby:member:join": LobbyMember;
"lobby:member:leave": LobbyMemberLeaveEventData;
};
+73
View File
@@ -0,0 +1,73 @@
import Redis from "ioredis";
import type { PubSubEvents } from "./events";
const redisPub = new Redis();
const redisSub = new Redis();
export const redisPublish = <T extends keyof PubSubEvents>(
event: T,
data: PubSubEvents[T],
) => {
redisPub.publish(event, JSON.stringify(data));
};
const redisSubscribe = <T extends keyof PubSubEvents>(
event: T,
callback: (data: PubSubEvents[T]) => void,
) => {
redisSub.subscribe(event, (err) => {
if (err) console.error(`Redis subscription error for ${event}:`, err);
});
redisSub.on("message", (channel, message) => {
if (channel === event) {
callback(JSON.parse(message) as PubSubEvents[T]);
}
});
};
/**
* Creates an async iterator for a Redis subscription channel.
* @param event - The Redis event to subscribe to.
* @returns An async iterator that yields tracked events.
*/
export function redisAsyncIterator<K extends keyof PubSubEvents>(
event: K,
): AsyncIterableIterator<PubSubEvents[K]> {
return {
[Symbol.asyncIterator]() {
return this;
},
async next(): Promise<IteratorResult<PubSubEvents[K]>> {
return new Promise((resolve) => {
redisSubscribe(event, (data: PubSubEvents[K]) => {
resolve({ value: data, done: false });
});
});
},
};
}
export async function* combineRedisIterators<T extends keyof PubSubEvents>(
events: T[],
) {
const iterators = events.map((event) =>
redisAsyncIterator(event)[Symbol.asyncIterator](),
);
while (true) {
// Wait for the next event from any iterator
const result = await Promise.race(
iterators.map((iterator, index) =>
iterator.next().then((untypedRes) => {
const res = untypedRes as IteratorResult<PubSubEvents[T]>;
return { res, event: events[index] };
}),
),
);
if (result.res.done) break;
yield { event: result.event, data: result.res.value };
}
}
+4 -1
View File
@@ -6,6 +6,7 @@
:root { :root {
--radius: 0.625rem; --radius: 0.625rem;
--background: oklch(1 0 0); --background: oklch(1 0 0);
--background-transparent: oklch(1 0 0 / 20%);
--foreground: oklch(0.145 0 0); --foreground: oklch(0.145 0 0);
--card: oklch(1 0 0); --card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0); --card-foreground: oklch(0.145 0 0);
@@ -41,6 +42,7 @@
.dark { .dark {
--background: oklch(0.145 0 0); --background: oklch(0.145 0 0);
--background-transparent: oklch(0.145 0 0 / 20%);
--foreground: oklch(0.985 0 0); --foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0); --card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0); --card-foreground: oklch(0.985 0 0);
@@ -80,6 +82,7 @@
--radius-lg: var(--radius); --radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px); --radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background); --color-background: var(--background);
--color-background-transparent: var(--background-transparent);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-card: var(--card); --color-card: var(--card);
--color-card-foreground: var(--card-foreground); --color-card-foreground: var(--card-foreground);
@@ -126,7 +129,7 @@
} }
.container-bg { .container-bg {
@apply border-border/20 bg-border/10 rounded-2xl border text-white shadow-[0_0_15px_rgba(124,58,237,0.5)] backdrop-blur-md transition-all; @apply border-border/20 bg-background-transparent rounded-2xl border text-white shadow-[0_0_15px_rgba(124,58,237,0.5)] backdrop-blur-md transition-all;
} }
.gradient-bg { .gradient-bg {
@apply bg-gradient-to-br from-indigo-950 via-purple-900 to-indigo-900; @apply bg-gradient-to-br from-indigo-950 via-purple-900 to-indigo-900;
+17 -9
View File
@@ -1,7 +1,12 @@
"use client"; "use client";
import { QueryClientProvider, type QueryClient } from "@tanstack/react-query"; 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 { createTRPCReact } from "@trpc/react-query";
import { type inferRouterInputs, type inferRouterOutputs } from "@trpc/server"; import { type inferRouterInputs, type inferRouterOutputs } from "@trpc/server";
import { useState } from "react"; import { useState } from "react";
@@ -9,6 +14,7 @@ import SuperJSON from "superjson";
import { type AppRouter } from "@/server/api/root"; import { type AppRouter } from "@/server/api/root";
import { createQueryClient } from "./query-client"; import { createQueryClient } from "./query-client";
import { getBaseUrl } from "@/lib/utils";
let clientQueryClientSingleton: QueryClient | undefined = undefined; let clientQueryClientSingleton: QueryClient | undefined = undefined;
const getQueryClient = () => { const getQueryClient = () => {
@@ -50,15 +56,23 @@ export function TRPCReactProvider(props: { children: React.ReactNode }) {
process.env.NODE_ENV === "development" || process.env.NODE_ENV === "development" ||
(op.direction === "down" && op.result instanceof Error), (op.direction === "down" && op.result instanceof Error),
}), }),
httpBatchStreamLink({ splitLink({
// uses the httpSubscriptionLink for subscriptions
condition: (op) => op.type === "subscription",
true: httpSubscriptionLink({
transformer: SuperJSON, transformer: SuperJSON,
url: getBaseUrl() + "/api/trpc", url: `${getBaseUrl()}/api/trpc`,
}),
false: httpBatchStreamLink({
transformer: SuperJSON,
url: `${getBaseUrl()}/api/trpc`,
headers: () => { headers: () => {
const headers = new Headers(); const headers = new Headers();
headers.set("x-trpc-source", "nextjs-react"); headers.set("x-trpc-source", "nextjs-react");
return headers; return headers;
}, },
}), }),
}),
], ],
}), }),
); );
@@ -71,9 +85,3 @@ export function TRPCReactProvider(props: { children: React.ReactNode }) {
</QueryClientProvider> </QueryClientProvider>
); );
} }
function getBaseUrl() {
if (typeof window !== "undefined") return window.location.origin;
if (process.env.VERCEL_URL) return `https://${process.env.VERCEL_URL}`;
return `http://localhost:${process.env.PORT ?? 3000}`;
}
+66 -44
View File
@@ -1,60 +1,82 @@
#!/usr/bin/env bash #!/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 # import env variables from .env
set -a set -a
source .env source .env
DB_PASSWORD=$(echo "$DATABASE_URL" | awk -F':' '{print $3}' | awk -F'@' '{print $1}') 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 REDIS_PORT=${REDIS_PORT:-6379}
echo "You are using the default database password" REDIS_CONTAINER_NAME="$DB_NAME-redis"
read -p "Should we generate a random password for you? [y/N]: " -r REPLY
if ! [[ $REPLY =~ ^[Yy]$ ]]; then if ! [ -x "$(command -v docker)" ] && ! [ -x "$(command -v podman)" ]; then
echo "Please change the default password in the .env file and try again" 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 exit 1
fi
# Generate a random URL-safe password
DB_PASSWORD=$(openssl rand -base64 12 | tr '+/' '-_')
sed -i -e "s#:password@#:$DB_PASSWORD@#" .env
fi fi
docker run -d \ # 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 \ --name $DB_CONTAINER_NAME \
-e POSTGRES_USER="postgres" \ -e POSTGRES_USER="postgres" \
-e POSTGRES_PASSWORD="$DB_PASSWORD" \ -e POSTGRES_PASSWORD="$DB_PASSWORD" \
-e POSTGRES_DB=game-master \ -e POSTGRES_DB="$DB_NAME" \
-p "$DB_PORT":5432 \ -p "$DB_PORT":5432 \
docker.io/postgres && echo "Database container '$DB_CONTAINER_NAME' was successfully created" 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