Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1baf097fa | ||
|
|
f9ad545e45 | ||
|
|
a77bac304e | ||
|
|
f0672b9bc1 | ||
|
|
abc698998e | ||
|
|
4009c44841 | ||
|
|
681115e291 | ||
|
|
2db3af2dfc |
@@ -1 +0,0 @@
|
|||||||
{"version":"7","dialect":"postgresql","entries":[]}
|
|
||||||
@@ -9,6 +9,7 @@ const config = {
|
|||||||
eslint: {
|
eslint: {
|
||||||
ignoreDuringBuilds: true,
|
ignoreDuringBuilds: true,
|
||||||
},
|
},
|
||||||
|
reactStrictMode: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default config;
|
export default config;
|
||||||
|
|||||||
+6
-1
@@ -29,7 +29,9 @@
|
|||||||
"@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",
|
||||||
|
"@radix-ui/react-switch": "^1.1.3",
|
||||||
"@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",
|
||||||
"@trpc/client": "^11.0.0",
|
"@trpc/client": "^11.0.0",
|
||||||
@@ -38,9 +40,11 @@
|
|||||||
"@types/express": "^5.0.1",
|
"@types/express": "^5.0.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"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",
|
||||||
@@ -56,7 +60,8 @@
|
|||||||
"tailwind-merge": "^3.0.2",
|
"tailwind-merge": "^3.0.2",
|
||||||
"tsx": "^4.19.3",
|
"tsx": "^4.19.3",
|
||||||
"tw-animate-css": "^1.2.4",
|
"tw-animate-css": "^1.2.4",
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2",
|
||||||
|
"zustand": "^5.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
|
|||||||
Generated
+194
@@ -35,9 +35,15 @@ 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)
|
||||||
|
'@radix-ui/react-switch':
|
||||||
|
specifier: ^1.1.3
|
||||||
|
version: 1.1.3(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||||
'@t3-oss/env-nextjs':
|
'@t3-oss/env-nextjs':
|
||||||
specifier: ^0.12.0
|
specifier: ^0.12.0
|
||||||
version: 0.12.0(typescript@5.8.2)(zod@3.24.2)
|
version: 0.12.0(typescript@5.8.2)(zod@3.24.2)
|
||||||
@@ -62,6 +68,9 @@ importers:
|
|||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
|
cmdk:
|
||||||
|
specifier: ^1.1.1
|
||||||
|
version: 1.1.1(@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)
|
||||||
dotenv:
|
dotenv:
|
||||||
specifier: ^16.4.7
|
specifier: ^16.4.7
|
||||||
version: 16.4.7
|
version: 16.4.7
|
||||||
@@ -71,6 +80,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)
|
||||||
@@ -119,6 +131,9 @@ importers:
|
|||||||
zod:
|
zod:
|
||||||
specifier: ^3.24.2
|
specifier: ^3.24.2
|
||||||
version: 3.24.2
|
version: 3.24.2
|
||||||
|
zustand:
|
||||||
|
specifier: ^5.0.3
|
||||||
|
version: 5.0.3(@types/react@19.0.12)(react@19.0.0)
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@eslint/eslintrc':
|
'@eslint/eslintrc':
|
||||||
specifier: ^3.3.1
|
specifier: ^3.3.1
|
||||||
@@ -826,6 +841,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 +930,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 +1189,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:
|
||||||
@@ -1177,6 +1211,19 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@radix-ui/react-switch@1.1.3':
|
||||||
|
resolution: {integrity: sha512-1nc+vjEOQkJVsJtWPSiISGT6OKm4SiOdjMo+/icLxo2G4vxz1GntC5MzfL4v8ey9OEfw787QCD1y3mUv0NiFEQ==}
|
||||||
|
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-use-callback-ref@1.1.0':
|
'@radix-ui/react-use-callback-ref@1.1.0':
|
||||||
resolution: {integrity: sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==}
|
resolution: {integrity: sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -1213,6 +1260,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 +1747,16 @@ 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'}
|
||||||
|
|
||||||
|
cmdk@1.1.1:
|
||||||
|
resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==}
|
||||||
|
peerDependencies:
|
||||||
|
react: ^18 || ^19 || ^19.0.0-rc
|
||||||
|
react-dom: ^18 || ^19 || ^19.0.0-rc
|
||||||
|
|
||||||
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 +2374,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 +2621,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 +3026,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 +3186,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'}
|
||||||
@@ -3323,6 +3410,24 @@ packages:
|
|||||||
zod@3.24.2:
|
zod@3.24.2:
|
||||||
resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==}
|
resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==}
|
||||||
|
|
||||||
|
zustand@5.0.3:
|
||||||
|
resolution: {integrity: sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==}
|
||||||
|
engines: {node: '>=12.20.0'}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': '>=18.0.0'
|
||||||
|
immer: '>=9.0.6'
|
||||||
|
react: '>=18.0.0'
|
||||||
|
use-sync-external-store: '>=1.2.0'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
immer:
|
||||||
|
optional: true
|
||||||
|
react:
|
||||||
|
optional: true
|
||||||
|
use-sync-external-store:
|
||||||
|
optional: true
|
||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
'@alloc/quick-lru@5.2.0': {}
|
'@alloc/quick-lru@5.2.0': {}
|
||||||
@@ -3745,6 +3850,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 +3913,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 +4178,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)
|
||||||
@@ -4076,6 +4204,21 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.0.12
|
'@types/react': 19.0.12
|
||||||
|
|
||||||
|
'@radix-ui/react-switch@1.1.3(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/primitive': 1.1.1
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.12)(react@19.0.0)
|
||||||
|
'@radix-ui/react-context': 1.1.1(@types/react@19.0.12)(react@19.0.0)
|
||||||
|
'@radix-ui/react-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-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-use-callback-ref@1.1.0(@types/react@19.0.12)(react@19.0.0)':
|
'@radix-ui/react-use-callback-ref@1.1.0(@types/react@19.0.12)(react@19.0.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
react: 19.0.0
|
react: 19.0.0
|
||||||
@@ -4102,6 +4245,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 +4741,20 @@ snapshots:
|
|||||||
|
|
||||||
clsx@2.1.1: {}
|
clsx@2.1.1: {}
|
||||||
|
|
||||||
|
cluster-key-slot@1.1.2: {}
|
||||||
|
|
||||||
|
cmdk@1.1.1(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
|
||||||
|
dependencies:
|
||||||
|
'@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.12)(react@19.0.0)
|
||||||
|
'@radix-ui/react-dialog': 1.1.6(@types/react-dom@19.0.4(@types/react@19.0.12))(@types/react@19.0.12)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
|
||||||
|
'@radix-ui/react-id': 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)
|
||||||
|
react: 19.0.0
|
||||||
|
react-dom: 19.0.0(react@19.0.0)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/react'
|
||||||
|
- '@types/react-dom'
|
||||||
|
|
||||||
color-convert@2.0.1:
|
color-convert@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
color-name: 1.1.4
|
color-name: 1.1.4
|
||||||
@@ -5367,6 +5530,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 +5770,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 +6083,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 +6315,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: {}
|
||||||
@@ -6382,3 +6571,8 @@ snapshots:
|
|||||||
yocto-queue@0.1.0: {}
|
yocto-queue@0.1.0: {}
|
||||||
|
|
||||||
zod@3.24.2: {}
|
zod@3.24.2: {}
|
||||||
|
|
||||||
|
zustand@5.0.3(@types/react@19.0.12)(react@19.0.0):
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.0.12
|
||||||
|
react: 19.0.0
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.0 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 413 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -1,16 +1,22 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import Navbar from "@/components/navbar";
|
import Navbar from "@/components/navbar";
|
||||||
import Footer from "@/components/footer";
|
import Footer from "@/components/footer";
|
||||||
|
import { api } from "@/trpc/server";
|
||||||
|
import AppProvider from "../_components/app-provider";
|
||||||
|
|
||||||
|
async function Layout({ children }: { children: React.ReactNode }) {
|
||||||
|
const sessionPlayer = await api.player.getBySession();
|
||||||
|
|
||||||
function Layout({ children }: { children: React.ReactNode }) {
|
|
||||||
return (
|
return (
|
||||||
<div className="gradient-bg min-h-screen">
|
<AppProvider initialSessionPlayer={sessionPlayer}>
|
||||||
<Navbar />
|
<div className="gradient-bg min-h-screen">
|
||||||
<div className="container mb-8 flex size-full min-h-screen flex-col items-center space-y-6">
|
<Navbar />
|
||||||
{children}
|
<div className="container mb-8 flex size-full min-h-screen flex-col items-center space-y-6">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
<Footer />
|
</AppProvider>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,11 @@ async function Page({
|
|||||||
id: string;
|
id: string;
|
||||||
}>;
|
}>;
|
||||||
}) {
|
}) {
|
||||||
const session = await auth();
|
|
||||||
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 <div>Lobby not found</div>;
|
||||||
|
|
||||||
const members: Array<{ leader: boolean } & PublicUser> = [
|
return <LobbyPage initialLobby={lobby} />;
|
||||||
{ ...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;
|
||||||
|
|||||||
@@ -1,36 +1,30 @@
|
|||||||
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";
|
||||||
|
import { auth } from "@/server/auth";
|
||||||
|
|
||||||
async function Page() {
|
async function Page() {
|
||||||
const lobbies = await api.lobby.getAll();
|
const session = await auth();
|
||||||
|
if (!session?.user) return redirect(appRoutes.signIn);
|
||||||
return (
|
const lobby = await api.lobby.getCurrentLobby();
|
||||||
<>
|
if (!lobby)
|
||||||
<div className="w-full max-w-md space-y-4">
|
return (
|
||||||
<div className="flex w-full gap-4">
|
<div className="flex w-full gap-4">
|
||||||
<CreateLobbyDialog className="grow" />
|
<CreateLobbyDialog className="grow" />
|
||||||
<Button
|
<Button
|
||||||
variant={"party"}
|
variant={"party"}
|
||||||
size={"xxl"}
|
size={"xxl"}
|
||||||
className="container-bg bg-border/10"
|
className="container-bg bg-border/10"
|
||||||
>
|
>
|
||||||
Placeholder
|
Placeholder
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
|
|
||||||
<menu>
|
|
||||||
{lobbies.map((lobby) => (
|
|
||||||
<li key={lobby.id}>
|
|
||||||
<LobbyCard lobby={lobby} />
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</menu>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
);
|
||||||
);
|
return <LobbyPage initialLobby={lobby} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Page;
|
export default Page;
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
function page() {
|
||||||
|
return <div>Session player profile edit page</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default page;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import React from "react";
|
||||||
|
|
||||||
|
function Page() {
|
||||||
|
return <div>Session player profile </div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Page;
|
||||||
+40
-32
@@ -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,43 +25,47 @@ 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>
|
||||||
<div className="space-y-6">
|
<Link href={appRoutes.currentlobby}>Jump to Lobby</Link>
|
||||||
{session ? (
|
</Button>
|
||||||
<CreateLobbyDialog className="text-shadow-primary w-full" />
|
) : (
|
||||||
) : (
|
<div className="space-y-6">
|
||||||
|
{session ? (
|
||||||
|
<CreateLobbyDialog className="text-shadow-primary w-full" />
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
size={"xxl"}
|
||||||
|
variant={"party"}
|
||||||
|
asChild
|
||||||
|
className="text-shadow-primary w-full font-black uppercase"
|
||||||
|
>
|
||||||
|
<Link href={"/api/auth/signin"}>Sign-in to create a Lobby</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Join Lobby Button */}
|
||||||
<Button
|
<Button
|
||||||
size={"xxl"}
|
size={"xxl"}
|
||||||
variant={"party"}
|
variant={"party"}
|
||||||
asChild
|
className="w-full rounded-xl bg-gradient-to-r from-blue-500 via-blue-600 to-indigo-600 text-white hover:from-blue-600 hover:to-indigo-700"
|
||||||
className="text-shadow-primary w-full font-black uppercase"
|
|
||||||
>
|
>
|
||||||
<Link href={"/api/auth/signin"}>Sign-in to create a Lobby</Link>
|
<Users className="mr-2 h-6 w-6" />
|
||||||
|
Join Lobby
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Join Lobby Button */}
|
{/* Quick Play Option */}
|
||||||
<Button
|
<div className="border-t border-white/10 pt-4">
|
||||||
size={"xxl"}
|
<Button
|
||||||
variant={"party"}
|
variant="ghost"
|
||||||
className="w-full rounded-xl bg-gradient-to-r from-blue-500 via-blue-600 to-indigo-600 text-white hover:from-blue-600 hover:to-indigo-700"
|
className="w-full text-indigo-200 hover:bg-white/10 hover:text-white"
|
||||||
>
|
>
|
||||||
<Users className="mr-2 h-6 w-6" />
|
Quick Play
|
||||||
Join Lobby
|
</Button>
|
||||||
</Button>
|
</div>
|
||||||
|
|
||||||
{/* Quick Play Option */}
|
|
||||||
<div className="border-t border-white/10 pt-4">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className="w-full text-indigo-200 hover:bg-white/10 hover:text-white"
|
|
||||||
>
|
|
||||||
Quick Play
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
|
|
||||||
function page() {
|
|
||||||
return (
|
|
||||||
<div>page</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default page
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"use client";
|
||||||
|
import { useSessionPlayerStore } from "@/lib/store/current-player-store";
|
||||||
|
import type { Player } from "@/server/db/schema";
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
function AppProvider({
|
||||||
|
initialSessionPlayer,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
initialSessionPlayer?: Player | null;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const setSessionPlayer = useSessionPlayerStore(
|
||||||
|
(state) => state.setSessionPlayer,
|
||||||
|
);
|
||||||
|
React.useEffect(() => {
|
||||||
|
setSessionPlayer(initialSessionPlayer);
|
||||||
|
}, [initialSessionPlayer]);
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AppProvider;
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
|
import { gameConfigPatchSchema } from "@/lib/validations/game";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { useGameStore } from "@/lib/store/game-store";
|
||||||
|
import { api } from "@/trpc/react";
|
||||||
|
import { useLobbyStore } from "@/lib/store/lobby-store";
|
||||||
|
import { Slider } from "@/components/ui/slider";
|
||||||
|
import { getMinMax } from "@/lib/validations/utils";
|
||||||
|
import { debounce } from "@/lib/utils";
|
||||||
|
import React from "react";
|
||||||
|
import { useSessionPlayerStore } from "@/lib/store/current-player-store";
|
||||||
|
|
||||||
|
const formSchema = gameConfigPatchSchema.pick({
|
||||||
|
allowHints: true,
|
||||||
|
// scoreMultiplier: true,
|
||||||
|
timeLimit: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export default function GameConfigForm() {
|
||||||
|
const updateGameConfig = api.gameConfig.update.useMutation();
|
||||||
|
const gameConfig = useGameStore((state) => state.gameConfig);
|
||||||
|
const lobbyId = useLobbyStore((state) => state.lobby.id);
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
allowHints: gameConfig.allowHints,
|
||||||
|
timeLimit: gameConfig.timeLimit,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function onSubmit(values: z.infer<typeof formSchema>) {
|
||||||
|
updateGameConfig.mutate({
|
||||||
|
lobbyId,
|
||||||
|
config: {
|
||||||
|
...gameConfig,
|
||||||
|
...values,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const save = form.handleSubmit(onSubmit);
|
||||||
|
React.useEffect(() => {
|
||||||
|
form.reset({
|
||||||
|
allowHints: gameConfig.allowHints,
|
||||||
|
timeLimit: gameConfig.timeLimit,
|
||||||
|
});
|
||||||
|
}, [gameConfig]);
|
||||||
|
const isLobbyAdmin = useSessionPlayerStore((state) => state.isLobbyAdmin);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="allowHints"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="container-bg bg-background/10 flex flex-row items-center justify-between p-3">
|
||||||
|
<FormLabel>Allow hints</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Switch
|
||||||
|
className="cursor-pointer"
|
||||||
|
checked={field.value}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
field.onChange(checked);
|
||||||
|
save();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="timeLimit"
|
||||||
|
render={({ field }) => {
|
||||||
|
const { min, max } = getMinMax(formSchema.shape.timeLimit);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItem>
|
||||||
|
<div className="container-bg bg-background/10 space-y-2 p-3">
|
||||||
|
<FormLabel>
|
||||||
|
Time Limit
|
||||||
|
<span className="text-xl font-bold">
|
||||||
|
{`${field.value}`} min
|
||||||
|
</span>
|
||||||
|
</FormLabel>
|
||||||
|
{isLobbyAdmin && (
|
||||||
|
<FormControl>
|
||||||
|
<Slider
|
||||||
|
className="w-full max-w-xs"
|
||||||
|
value={[field.value]}
|
||||||
|
max={max ?? 100}
|
||||||
|
min={min ?? 1}
|
||||||
|
step={1}
|
||||||
|
onValueChange={([value]) => {
|
||||||
|
field.onChange(value);
|
||||||
|
debounce(save, 1000)();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"use client";
|
||||||
|
import React from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useGameStore } from "@/lib/store/game-store";
|
||||||
|
import { InfoIcon, XIcon } from "lucide-react";
|
||||||
|
import GameConfigForm from "./game-config-form";
|
||||||
|
import GameVariantSelector from "./game-variant-selector";
|
||||||
|
import { api } from "@/trpc/react";
|
||||||
|
import { useLobbyStore } from "@/lib/store/lobby-store";
|
||||||
|
import { isCallOrNewExpression } from "typescript";
|
||||||
|
import { useSessionPlayerStore } from "@/lib/store/current-player-store";
|
||||||
|
|
||||||
|
function GameConfigurator() {
|
||||||
|
const updateGameConfig = api.gameConfig.update.useMutation();
|
||||||
|
const selectGame = useGameStore((state) => state.setSelectedGame);
|
||||||
|
const selectedGame = useGameStore((state) => state.selectedGame);
|
||||||
|
const gameConfig = useGameStore((state) => state.gameConfig);
|
||||||
|
const lobbyId = useLobbyStore((state) => state.lobby.id);
|
||||||
|
const isLobbyAdmin = useSessionPlayerStore((state) => state.isLobbyAdmin);
|
||||||
|
const handleUnselectGame = () => {
|
||||||
|
selectGame(undefined);
|
||||||
|
updateGameConfig.mutate({
|
||||||
|
lobbyId,
|
||||||
|
config: {
|
||||||
|
...gameConfig,
|
||||||
|
gameId: undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVariantSelect = (gameVariantId?: string) => {
|
||||||
|
updateGameConfig.mutate({
|
||||||
|
lobbyId,
|
||||||
|
config: {
|
||||||
|
...gameConfig,
|
||||||
|
gameVariantId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex size-full flex-col justify-between">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<h2 className="text-xl font-semibold">
|
||||||
|
{selectedGame?.name ?? "Select a game to get started"}
|
||||||
|
</h2>
|
||||||
|
{selectedGame && (
|
||||||
|
<Button size={"icon"} variant={"ghost"} className="ml-auto">
|
||||||
|
<InfoIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedGame && (
|
||||||
|
<>
|
||||||
|
<GameVariantSelector onSelect={handleVariantSelect} />
|
||||||
|
<GameConfigForm />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedGame && (
|
||||||
|
<div className="mt-4 flex items-center gap-2">
|
||||||
|
<Button>Start Game</Button>
|
||||||
|
<Button
|
||||||
|
variant={"ghost"}
|
||||||
|
size={"icon"}
|
||||||
|
className="ml-auto"
|
||||||
|
onClick={handleUnselectGame}
|
||||||
|
>
|
||||||
|
<XIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GameConfigurator;
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"use client";
|
||||||
|
import React from "react";
|
||||||
|
import { Combobox } from "@/components/combobox";
|
||||||
|
import { useGameStore } from "@/lib/store/game-store";
|
||||||
|
|
||||||
|
const getGameVariants = (id: string) => {
|
||||||
|
switch (id) {
|
||||||
|
case "quiz":
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: "Airplains",
|
||||||
|
value: "variant_id_1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Cars",
|
||||||
|
value: "variant_id_0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Trains",
|
||||||
|
value: "variant_id_2",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
function GameVariantSelector({
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
onSelect: (gameVariantId?: string) => void;
|
||||||
|
}) {
|
||||||
|
const gameId = useGameStore((state) => state.gameConfig.gameId);
|
||||||
|
const selectedVariant = useGameStore(
|
||||||
|
(state) => state.gameConfig.gameVariantId,
|
||||||
|
);
|
||||||
|
const setSelectedVariant = useGameStore((state) => state.updateGameConfig);
|
||||||
|
const variants = getGameVariants(gameId!);
|
||||||
|
if (!variants.length) return null;
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!selectedVariant)
|
||||||
|
setSelectedVariant({ gameVariantId: variants[0]?.value });
|
||||||
|
}, [selectedVariant]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Combobox
|
||||||
|
className="bg-background/10 w-full rounded-2xl"
|
||||||
|
onSelect={(value) => {
|
||||||
|
if (!value?.length) return;
|
||||||
|
onSelect(value);
|
||||||
|
setSelectedVariant({
|
||||||
|
gameVariantId: value,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
initialValue={selectedVariant}
|
||||||
|
data={variants}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GameVariantSelector;
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import type { IMinigame } from "@/game-engien";
|
||||||
|
import { gameLibary } from "@/game-engien/games";
|
||||||
|
import { useSessionPlayerStore } from "@/lib/store/current-player-store";
|
||||||
|
import { useGameStore } from "@/lib/store/game-store";
|
||||||
|
import { useLobbyStore } from "@/lib/store/lobby-store";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { api } from "@/trpc/react";
|
||||||
|
import Image from "next/image";
|
||||||
|
import React from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
function GameSelector() {
|
||||||
|
const updateGameConfig = api.gameConfig.update.useMutation();
|
||||||
|
const setGameConfig = useGameStore((state) => state.updateGameConfig);
|
||||||
|
const selectedGame = useGameStore((state) => state.selectedGame);
|
||||||
|
const setSelectedGame = useGameStore((state) => state.setSelectedGame);
|
||||||
|
const gameConfig = useGameStore((state) => state.gameConfig);
|
||||||
|
const lobbyId = useLobbyStore((state) => state.lobby.id);
|
||||||
|
const isLobbyAdmin = useSessionPlayerStore((state) => state.isLobbyAdmin);
|
||||||
|
|
||||||
|
const handleGameSelect = (game: IMinigame) => {
|
||||||
|
if (!isLobbyAdmin) return toast.error("Only admins can change games");
|
||||||
|
setSelectedGame(game);
|
||||||
|
const newConfig = { ...gameConfig, gameId: game.id };
|
||||||
|
setGameConfig(newConfig);
|
||||||
|
updateGameConfig.mutate({
|
||||||
|
lobbyId,
|
||||||
|
config: newConfig,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="grid w-full grid-cols-4 gap-4">
|
||||||
|
{gameLibary.map((game) => (
|
||||||
|
<GameCard
|
||||||
|
key={game.id}
|
||||||
|
game={game}
|
||||||
|
selected={selectedGame?.id === game.id}
|
||||||
|
onClick={() => handleGameSelect(game)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const GameCard = ({
|
||||||
|
game,
|
||||||
|
selected,
|
||||||
|
onClick,
|
||||||
|
}: {
|
||||||
|
game: IMinigame;
|
||||||
|
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.thumbnail}
|
||||||
|
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;
|
||||||
@@ -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;
|
|
||||||
+1
-1
@@ -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);
|
||||||
+4
-2
@@ -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>
|
||||||
@@ -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">
|
||||||
@@ -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 ?? "Random 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,44 @@ function LobbyForm({
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Button type="submit" disabled={loading || !form.formState.isDirty}>
|
<FormField
|
||||||
Create Lobby
|
control={form.control}
|
||||||
</Button>
|
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 ||
|
||||||
|
(Boolean(server_lobby?.id?.length) && !form.formState.isDirty)
|
||||||
|
}
|
||||||
|
className="ml-auto"
|
||||||
|
>
|
||||||
|
{server_lobby?.id?.length ? "Update" : "Create"} Lobby
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
|
|||||||
+33
-10
@@ -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,45 @@ 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 labelText = join ? "join" : "leave";
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const membership = api.lobby.membership.useMutation({
|
||||||
|
onSuccess(data) {
|
||||||
|
if (data?.success) {
|
||||||
|
toast.success(`Successfully ${labelText} the lobby.`);
|
||||||
|
if (join) router.push(appRoutes.currentlobby);
|
||||||
|
else router.push(appRoutes.lobby(lobbyId));
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
if (data?.knownError) {
|
||||||
|
toast.message(data.error, {
|
||||||
|
action: {
|
||||||
|
label: "Jump to Lobby",
|
||||||
|
onClick: () => router.push(appRoutes.currentlobby),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
const handleConfirm = async () => {
|
const handleConfirm = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const result = await mutateAsync({ lobbyId, join });
|
try {
|
||||||
if (result) {
|
await membership.mutateAsync({
|
||||||
if (!join) router.push("/");
|
lobbyId,
|
||||||
else toast.success("Successfully joined the lobby.");
|
join,
|
||||||
} else toast.error("Something went wrong");
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Catching any errors here will prevent them from reaching Next.js's error boundary
|
||||||
|
console.error("Handled mutation error:", e);
|
||||||
|
}
|
||||||
|
|
||||||
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 +85,7 @@ function LobbyMembershipDialog({
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="capitalize"
|
className="capitalize"
|
||||||
>
|
>
|
||||||
{labelText}
|
{labelText} Lobby
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import type { Lobby } from "@/server/db/schema";
|
||||||
|
import LobbyPlayerCard from "@/app/_components/lobby/lobby-player/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 { getBaseUrl } from "@/lib/utils";
|
||||||
|
import GameSelector from "../game/game-selector";
|
||||||
|
import LobbyProvider from "./lobby-provider";
|
||||||
|
import { useSessionPlayerStore } from "@/lib/store/current-player-store";
|
||||||
|
import { useLobbyStore } from "@/lib/store/lobby-store";
|
||||||
|
import { Share2, UserPlus } from "lucide-react";
|
||||||
|
import GameConfigurator from "../game/configurator/game-configurator";
|
||||||
|
import { useGameStore } from "@/lib/store/game-store";
|
||||||
|
|
||||||
|
function LobbyPage({ initialLobby }: { initialLobby: Lobby }) {
|
||||||
|
const sessionPlayer = useSessionPlayerStore((state) => state.sessionPlayer);
|
||||||
|
const lobby = useLobbyStore((state) => state.lobby);
|
||||||
|
const members = useLobbyStore((state) => state.members);
|
||||||
|
const isJoined = sessionPlayer
|
||||||
|
? members.find((m) => m.playerId === sessionPlayer.id)
|
||||||
|
: null;
|
||||||
|
const isAdmin = useSessionPlayerStore((state) => state.isLobbyAdmin);
|
||||||
|
const gameConfig = useGameStore((state) => state.gameConfig);
|
||||||
|
return (
|
||||||
|
<div className="size-full h-screen pb-40">
|
||||||
|
<LobbyProvider initialLobby={initialLobby}>
|
||||||
|
<div className="mx-auto grid size-full max-w-4xl grid-cols-5 grid-rows-4 gap-4">
|
||||||
|
<div className="container-bg col-span-3 row-span-3 size-full space-y-4 overflow-y-auto p-6">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<CopyToClip
|
||||||
|
target="invite link"
|
||||||
|
text={getBaseUrl() + appRoutes.lobby(lobby.id)}
|
||||||
|
buttonProps={{ variant: "outline" }}
|
||||||
|
>
|
||||||
|
{isJoined ? (
|
||||||
|
<>
|
||||||
|
<UserPlus className="size-4" />
|
||||||
|
<span>Invite Players</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Share2 className="size-4" />
|
||||||
|
<span>Share Lobby</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</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-2 row-span-3 size-full overflow-y-auto p-6">
|
||||||
|
<GameConfigurator />
|
||||||
|
</div>
|
||||||
|
<div className="container-bg col-span-full h-max w-full p-4">
|
||||||
|
<GameSelector />
|
||||||
|
{/* {JSON.stringify(gameConfig)} */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</LobbyProvider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LobbyPage;
|
||||||
@@ -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 "./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,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,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 "./player-kick-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,112 @@
|
|||||||
|
import { appRoutes } from "@/config/app.routes";
|
||||||
|
import { getGame } from "@/game-engien/games";
|
||||||
|
import { useSessionPlayerStore } from "@/lib/store/current-player-store";
|
||||||
|
import { useGameStore } from "@/lib/store/game-store";
|
||||||
|
import { useLobbyStore } from "@/lib/store/lobby-store";
|
||||||
|
import type { GameConfig } from "@/lib/validations/game";
|
||||||
|
import type { Lobby, LobbyMember } from "@/server/db/schema";
|
||||||
|
import { api } from "@/trpc/react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import React from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
function LobbyProvider({
|
||||||
|
children,
|
||||||
|
initialLobby,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
initialLobby: Lobby;
|
||||||
|
}) {
|
||||||
|
const sessionPlayer = useSessionPlayerStore((state) => state.sessionPlayer);
|
||||||
|
const lobby = useLobbyStore((state) => state.lobby);
|
||||||
|
const updateLobby = useLobbyStore((state) => state.updateLobby);
|
||||||
|
const resetLobby = useLobbyStore((state) => state.resetLobby);
|
||||||
|
const setMembers = useLobbyStore((state) => state.setMembers);
|
||||||
|
const addMember = useLobbyStore((state) => state.addMember);
|
||||||
|
const removeMember = useLobbyStore((state) => state.removeMember);
|
||||||
|
const setGameConfig = useGameStore((state) => state.updateGameConfig);
|
||||||
|
const setSelectedGame = useGameStore((state) => state.setSelectedGame);
|
||||||
|
const setIsLobbyAdmin = useSessionPlayerStore(
|
||||||
|
(state) => state.setIsLobbyAdmin,
|
||||||
|
);
|
||||||
|
const selectedGame = useGameStore((state) => state.selectedGame);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const lobbyId = lobby.id ?? "";
|
||||||
|
|
||||||
|
api.lobby.onMemberUpdate.useSubscription(
|
||||||
|
{ lobbyId },
|
||||||
|
{
|
||||||
|
onData({ data }) {
|
||||||
|
if (data.joined) {
|
||||||
|
const member = data.member as LobbyMember;
|
||||||
|
addMember(member);
|
||||||
|
} else {
|
||||||
|
const memberId = data.member as string;
|
||||||
|
removeMember(memberId);
|
||||||
|
if (data?.kicked && memberId === sessionPlayer?.id) {
|
||||||
|
router.push(appRoutes.lobby(lobby.id));
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
api.lobby.onUpdate.useSubscription(
|
||||||
|
{ lobbyId },
|
||||||
|
{
|
||||||
|
onData({ data }) {
|
||||||
|
if (data.deleted) {
|
||||||
|
resetLobby(undefined);
|
||||||
|
router.push(appRoutes.home);
|
||||||
|
router.refresh();
|
||||||
|
toast.error("Lobby got deleted");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.lobby) updateLobby(data.lobby);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
api.gameConfig.onUpdate.useSubscription(
|
||||||
|
{ lobbyId },
|
||||||
|
{
|
||||||
|
onData({ data: config }) {
|
||||||
|
if (config) {
|
||||||
|
console.log("Game Config Update", config);
|
||||||
|
|
||||||
|
setGameConfig(config);
|
||||||
|
if (config.gameId !== selectedGame?.id) {
|
||||||
|
const game = getGame(config.gameId!);
|
||||||
|
setSelectedGame(game);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
React.useEffect(() => {
|
||||||
|
const isAdmin =
|
||||||
|
initialLobby?.members?.find((m) => m.playerId === sessionPlayer?.id)
|
||||||
|
?.role === "admin";
|
||||||
|
setIsLobbyAdmin(isAdmin);
|
||||||
|
}, [initialLobby, sessionPlayer, setIsLobbyAdmin]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const setConfigWithGame = () => {
|
||||||
|
const initialConfig = initialLobby?.gameConfig?.config;
|
||||||
|
setGameConfig(initialConfig ?? ({} as GameConfig));
|
||||||
|
const game = initialConfig?.gameId?.length
|
||||||
|
? getGame(initialConfig?.gameId!)
|
||||||
|
: undefined;
|
||||||
|
setSelectedGame(game);
|
||||||
|
};
|
||||||
|
if (initialLobby?.gameConfig?.config) setConfigWithGame();
|
||||||
|
resetLobby(initialLobby);
|
||||||
|
setMembers(initialLobby?.members ?? []);
|
||||||
|
}, [initialLobby, setGameConfig, setSelectedGame, getGame]);
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LobbyProvider;
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Check, ChevronsUpDown, type LucideIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Button, type ButtonProps } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Command,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandInput,
|
||||||
|
CommandItem,
|
||||||
|
CommandList,
|
||||||
|
} from "@/components/ui/command";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
|
||||||
|
export type ComboboxProps = {
|
||||||
|
data: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
Icon?: LucideIcon;
|
||||||
|
}[];
|
||||||
|
onSelect: (value?: string) => void;
|
||||||
|
messageUi?: {
|
||||||
|
select?: string;
|
||||||
|
selectIcon?: LucideIcon;
|
||||||
|
empty?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
};
|
||||||
|
initialValue?: string;
|
||||||
|
className?: string;
|
||||||
|
hideSearch?: boolean;
|
||||||
|
buttonProps?: ButtonProps;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Combobox({
|
||||||
|
data,
|
||||||
|
initialValue,
|
||||||
|
messageUi,
|
||||||
|
className,
|
||||||
|
hideSearch,
|
||||||
|
buttonProps,
|
||||||
|
onSelect,
|
||||||
|
}: ComboboxProps) {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const [value, setValue] = React.useState(initialValue ?? "");
|
||||||
|
const selectedItem = data.find((item) => item.value === initialValue)!;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={open}
|
||||||
|
className={cn(
|
||||||
|
"bg-background/20 hover:bg-background/30 container-bg w-[200px] justify-between text-white shadow-none",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...buttonProps}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-white/75",
|
||||||
|
selectedItem && "text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{selectedItem?.Icon ? (
|
||||||
|
<selectedItem.Icon className="size-4" />
|
||||||
|
) : messageUi?.selectIcon ? (
|
||||||
|
<messageUi.selectIcon className="size-4" />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<span className="text-white">
|
||||||
|
{selectedItem?.label
|
||||||
|
? selectedItem.label
|
||||||
|
: (messageUi?.select ?? "Select...")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ChevronsUpDown className="opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="popover-content-width-same-as-its-trigger w-full bg-transparent p-0">
|
||||||
|
<Command
|
||||||
|
filter={(value, search) => {
|
||||||
|
if (!search.trim()) return 1; // Show all when no search input
|
||||||
|
|
||||||
|
const entry = data.find((item) => item.value === value); // Assuming dataset is an array of objects with id and name
|
||||||
|
if (!entry) return 0; // If no matching entry is found, exclude it
|
||||||
|
|
||||||
|
return entry.label.toLowerCase().includes(search.toLowerCase())
|
||||||
|
? 1
|
||||||
|
: 0;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{!hideSearch && (
|
||||||
|
<CommandInput
|
||||||
|
placeholder={messageUi?.placeholder ?? "Search..."}
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<CommandList className="bg-transparent">
|
||||||
|
<CommandEmpty>{messageUi?.empty ?? "Nothing found."}</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{data.map((item) => (
|
||||||
|
<CommandItem
|
||||||
|
key={item.value}
|
||||||
|
value={item.value}
|
||||||
|
onSelect={(currentValue) => {
|
||||||
|
const newValue = currentValue === value ? "" : currentValue;
|
||||||
|
setValue(newValue);
|
||||||
|
onSelect(newValue);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{item?.Icon && <item.Icon className="ml-auto opacity-50" />}
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</div>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"ml-auto",
|
||||||
|
value === item.value ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,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 (
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import React from "react";
|
|
||||||
import { Button } from "./ui/button";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
function NavLink({ label, path, className }: NavLink & { className?: string }) {
|
|
||||||
const active = usePathname() === path;
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
variant={"ghost"}
|
|
||||||
className={cn(
|
|
||||||
"group hover:bg-border/20 h-10 rounded-none font-bold text-white/50 hover:text-white/50",
|
|
||||||
active && "bg-border/20 text-white hover:text-white",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Link href={path}>
|
|
||||||
<span
|
|
||||||
className={"transition-transform duration-150 group-hover:scale-105"}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default NavLink;
|
|
||||||
+66
-15
@@ -1,13 +1,22 @@
|
|||||||
|
"use client";
|
||||||
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";
|
|
||||||
|
|
||||||
async function Navbar() {
|
import { Icons } from "./icons";
|
||||||
const session = await auth();
|
import { appRoutes } from "@/config/app.routes";
|
||||||
|
import { useSessionPlayerStore } from "@/lib/store/current-player-store";
|
||||||
|
import { useLobbyStore } from "@/lib/store/lobby-store";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Navbar() {
|
||||||
|
const sessionPlayer = useSessionPlayerStore((state) => state.sessionPlayer);
|
||||||
|
const currentLobby = useLobbyStore((state) => state.lobby);
|
||||||
|
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="flex w-full items-center justify-center p-4">
|
<nav className="flex w-full items-center justify-center p-4">
|
||||||
@@ -15,21 +24,63 @@ async function Navbar() {
|
|||||||
<menu className="flex items-center">
|
<menu className="flex items-center">
|
||||||
{appConfig.navigation.map((navLink, idx) => (
|
{appConfig.navigation.map((navLink, idx) => (
|
||||||
<li key={idx}>
|
<li key={idx}>
|
||||||
<NavLink {...navLink} />
|
<NavLink {...navLink} active={pathname === navLink.path} />
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</menu>
|
</menu>
|
||||||
{/* Login Button or Profile Avatar */}
|
|
||||||
{session?.user ? (
|
<div
|
||||||
<UserPopover user={session.user} />
|
className={cn(
|
||||||
) : (
|
"bg-border/10 border-border/20 hover:bg-background/20 flex items-center rounded-full border-l-2",
|
||||||
<Button asChild className="bg-primary font-black">
|
pathname === appRoutes.currentlobby && "bg-background/20",
|
||||||
<Link href="/api/auth/signin">Sign In</Link>
|
)}
|
||||||
</Button>
|
>
|
||||||
)}
|
{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">
|
||||||
|
<Link href="/api/auth/signin">Sign In</Link>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const NavLink = ({
|
||||||
|
label,
|
||||||
|
path,
|
||||||
|
active,
|
||||||
|
className,
|
||||||
|
}: NavLink & { className?: string; active?: boolean }) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
variant={"ghost"}
|
||||||
|
className={cn(
|
||||||
|
"group hover:bg-background/20 h-10 rounded-none font-bold text-white/50 hover:text-white/50",
|
||||||
|
active && "bg-background/20 text-white hover:text-white",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Link href={path}>
|
||||||
|
<span
|
||||||
|
className={"transition-transform duration-150 group-hover:scale-105"}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default Navbar;
|
export default Navbar;
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import { Command as CommandPrimitive } from "cmdk"
|
||||||
|
import { SearchIcon } from "lucide-react"
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
|
||||||
|
function Command({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive
|
||||||
|
data-slot="command"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandDialog({
|
||||||
|
title = "Command Palette",
|
||||||
|
description = "Search for a command to run...",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof Dialog> & {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Dialog {...props}>
|
||||||
|
<DialogHeader className="sr-only">
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogContent className="overflow-hidden p-0">
|
||||||
|
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||||
|
{children}
|
||||||
|
</Command>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandInput({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="command-input-wrapper"
|
||||||
|
className="flex h-9 items-center gap-2 border-b px-3"
|
||||||
|
>
|
||||||
|
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
data-slot="command-input"
|
||||||
|
className={cn(
|
||||||
|
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandList({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.List
|
||||||
|
data-slot="command-list"
|
||||||
|
className={cn(
|
||||||
|
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandEmpty({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Empty
|
||||||
|
data-slot="command-empty"
|
||||||
|
className="py-6 text-center text-sm"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Group
|
||||||
|
data-slot="command-group"
|
||||||
|
className={cn(
|
||||||
|
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Separator
|
||||||
|
data-slot="command-separator"
|
||||||
|
className={cn("bg-border -mx-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
data-slot="command-item"
|
||||||
|
className={cn(
|
||||||
|
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_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=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommandShortcut({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"span">) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-slot="command-shortcut"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Command,
|
||||||
|
CommandDialog,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandShortcut,
|
||||||
|
CommandSeparator,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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 }
|
||||||
@@ -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 };
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SwitchPrimitive from "@radix-ui/react-switch";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Switch({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SwitchPrimitive.Root
|
||||||
|
data-slot="switch"
|
||||||
|
className={cn(
|
||||||
|
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input/20 focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SwitchPrimitive.Thumb
|
||||||
|
data-slot="switch-thumb"
|
||||||
|
className={cn(
|
||||||
|
"bg-background/40 dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SwitchPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Switch };
|
||||||
@@ -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,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -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",
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { IMinigame } from "..";
|
||||||
|
|
||||||
|
export const getGame = (id: string) => {
|
||||||
|
return gameLibary.find((g) => g.id === id);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const gameLibary: Array<IMinigame> = [
|
||||||
|
{
|
||||||
|
id: "quiz",
|
||||||
|
name: "Quiz",
|
||||||
|
description: "Quiz minigame",
|
||||||
|
thumbnail: "/games/quiz.jpg",
|
||||||
|
minPlayers: 1,
|
||||||
|
maxPlayers: 12,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "reaction",
|
||||||
|
name: "Reaction",
|
||||||
|
description: "Reaction minigame",
|
||||||
|
thumbnail: "/games/reaction.jpg",
|
||||||
|
minPlayers: 1,
|
||||||
|
maxPlayers: 12,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "trivia",
|
||||||
|
name: "Trivia",
|
||||||
|
description: "Trivia minigame",
|
||||||
|
thumbnail: "/games/trivia.jpg",
|
||||||
|
minPlayers: 1,
|
||||||
|
maxPlayers: 12,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "place",
|
||||||
|
name: "Placeholder",
|
||||||
|
description: "Placeholder Lorem Ipsum",
|
||||||
|
thumbnail: "/games/trivia.jpg",
|
||||||
|
minPlayers: 1,
|
||||||
|
maxPlayers: 12,
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,414 @@
|
|||||||
|
import {
|
||||||
|
Game,
|
||||||
|
type GamePlayer,
|
||||||
|
type IGameConfig,
|
||||||
|
type IGameState,
|
||||||
|
} from "@/game-engien";
|
||||||
|
|
||||||
|
interface QuizQuestion {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
options: string[];
|
||||||
|
correctOptionIndex: number;
|
||||||
|
imageUrl?: string;
|
||||||
|
timeLimit?: number;
|
||||||
|
points: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuizData {
|
||||||
|
version: string;
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
author: string;
|
||||||
|
questions: Array<QuizQuestion>;
|
||||||
|
category: string;
|
||||||
|
difficulty: "easy" | "medium" | "hard";
|
||||||
|
tags: Array<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuizGameConfig extends IGameConfig {
|
||||||
|
randomizeQuestions?: boolean;
|
||||||
|
revealAnswersImmediately?: boolean;
|
||||||
|
pointsPerQuestion?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuizGameState extends IGameState {
|
||||||
|
currentQuestion?: QuizQuestion;
|
||||||
|
answeredQuestions: Set<string>;
|
||||||
|
playerAnswers: Record<string, Record<string, number>>;
|
||||||
|
leaderboard: Array<GamePlayer>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PlayerQuizAction =
|
||||||
|
| {
|
||||||
|
type: "answer";
|
||||||
|
questionId: string;
|
||||||
|
selectedOptionIndex: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "ready";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "requestHint";
|
||||||
|
questionId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class QuizGame extends Game {
|
||||||
|
private quizData: QuizData;
|
||||||
|
private questions: QuizQuestion[];
|
||||||
|
private quizState: QuizGameState;
|
||||||
|
private timers: Map<string, NodeJS.Timeout> = new Map();
|
||||||
|
|
||||||
|
constructor(quizData: QuizData, config: QuizGameConfig = {}) {
|
||||||
|
super(
|
||||||
|
{
|
||||||
|
name: "quiz",
|
||||||
|
id: quizData.id,
|
||||||
|
title: quizData.title,
|
||||||
|
description: quizData.description,
|
||||||
|
version: quizData.version,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timeLimit: 15, // Default 15 seconds per question
|
||||||
|
scoreMultiplier: 1,
|
||||||
|
allowHints: false,
|
||||||
|
...config,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
this.quizData = quizData;
|
||||||
|
this.questions = [...quizData.questions];
|
||||||
|
this.quizState = {
|
||||||
|
...this.state,
|
||||||
|
answeredQuestions: new Set<string>(),
|
||||||
|
playerAnswers: {},
|
||||||
|
leaderboard: [],
|
||||||
|
totalRounds: this.questions.length,
|
||||||
|
};
|
||||||
|
this.state = this.quizState;
|
||||||
|
}
|
||||||
|
|
||||||
|
public initialize(config?: QuizGameConfig): void {
|
||||||
|
// Override the default config with any provided config
|
||||||
|
if (config) {
|
||||||
|
this.config = {
|
||||||
|
...this.config,
|
||||||
|
...config,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize player answers tracking
|
||||||
|
this.players.forEach((player) => {
|
||||||
|
this.quizState.playerAnswers[player.id] = {};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Randomize questions if configured
|
||||||
|
if ((this.config as QuizGameConfig).randomizeQuestions) {
|
||||||
|
this.questions = this.shuffleArray([...this.questions]);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state.totalRounds = this.questions.length;
|
||||||
|
this.state.status = "waiting";
|
||||||
|
}
|
||||||
|
|
||||||
|
public start(): void {
|
||||||
|
if (this.state.status !== "waiting") {
|
||||||
|
throw new Error("Game must be in waiting state to start");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state.status = "active";
|
||||||
|
this.nextQuestion();
|
||||||
|
}
|
||||||
|
|
||||||
|
public end(): void {
|
||||||
|
this.clearTimers();
|
||||||
|
this.state.status = "completed";
|
||||||
|
this.quizState.leaderboard = [...this.players].sort(
|
||||||
|
(a, b) => b.score - a.score,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Notify players of game end
|
||||||
|
this.broadcastGameEnd();
|
||||||
|
}
|
||||||
|
|
||||||
|
public handlePlayerAction(playerId: string, action: PlayerQuizAction): void {
|
||||||
|
const player = this.players.find((p) => p.id === playerId);
|
||||||
|
|
||||||
|
if (!player) {
|
||||||
|
throw new Error(`Player ${playerId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (action.type) {
|
||||||
|
case "answer":
|
||||||
|
this.handlePlayerAnswer(
|
||||||
|
player,
|
||||||
|
action.questionId,
|
||||||
|
action.selectedOptionIndex,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "ready":
|
||||||
|
this.handlePlayerReady(player);
|
||||||
|
break;
|
||||||
|
case "requestHint":
|
||||||
|
this.handleRequestHint(player, action.questionId);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error(`Unknown action type: ${(action as any).type}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handlePlayerAnswer(
|
||||||
|
player: GamePlayer,
|
||||||
|
questionId: string,
|
||||||
|
selectedOptionIndex: number,
|
||||||
|
): void {
|
||||||
|
const currentQuestion = this.quizState.currentQuestion;
|
||||||
|
|
||||||
|
if (!currentQuestion || currentQuestion.id !== questionId) {
|
||||||
|
throw new Error("Invalid question ID");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record the answer
|
||||||
|
this.quizState.playerAnswers[player.id][questionId] = selectedOptionIndex;
|
||||||
|
|
||||||
|
// Check if answer is correct and update score
|
||||||
|
if (selectedOptionIndex === currentQuestion.correctOptionIndex) {
|
||||||
|
const points =
|
||||||
|
currentQuestion.points * (this.config.scoreMultiplier || 1);
|
||||||
|
player.score += points;
|
||||||
|
|
||||||
|
// Broadcast correct answer event
|
||||||
|
this.broadcastPlayerCorrectAnswer(player, questionId, points);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if all players have answered
|
||||||
|
const allAnswered = this.players.every(
|
||||||
|
(p) => questionId in (this.quizState.playerAnswers[p.id] || {}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (allAnswered) {
|
||||||
|
this.clearTimers();
|
||||||
|
|
||||||
|
// If configured to reveal answers immediately, wait briefly before next question
|
||||||
|
if ((this.config as QuizGameConfig).revealAnswersImmediately) {
|
||||||
|
this.broadcastQuestionResults(currentQuestion);
|
||||||
|
setTimeout(() => this.nextQuestion(), 3000);
|
||||||
|
} else {
|
||||||
|
this.nextQuestion();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private handlePlayerReady(player: GamePlayer): void {
|
||||||
|
// Could implement a ready-up system before starting each question
|
||||||
|
// For now, just acknowledging the ready state
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleRequestHint(player: GamePlayer, questionId: string): void {
|
||||||
|
if (!this.config.allowHints) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentQuestion = this.quizState.currentQuestion;
|
||||||
|
|
||||||
|
if (!currentQuestion || currentQuestion.id !== questionId) {
|
||||||
|
throw new Error("Invalid question ID");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Implementation of hint system
|
||||||
|
// For example, eliminate one wrong option
|
||||||
|
this.provideHint(player, currentQuestion);
|
||||||
|
}
|
||||||
|
|
||||||
|
private nextQuestion(): void {
|
||||||
|
if (this.state.status !== "active") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.clearTimers();
|
||||||
|
|
||||||
|
// Check if we've gone through all questions
|
||||||
|
if (this.state.currentRound >= this.questions.length) {
|
||||||
|
this.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextQuestion = this.questions[this.state.currentRound];
|
||||||
|
this.quizState.currentQuestion = nextQuestion;
|
||||||
|
this.state.currentRound++;
|
||||||
|
this.quizState.answeredQuestions.add(nextQuestion.id);
|
||||||
|
|
||||||
|
// Set timer for question
|
||||||
|
const timeLimit = nextQuestion.timeLimit || this.config.timeLimit || 15;
|
||||||
|
this.state.timeRemaining = timeLimit;
|
||||||
|
|
||||||
|
// Broadcast the new question to all players
|
||||||
|
this.broadcastQuestion(nextQuestion);
|
||||||
|
|
||||||
|
// Start the timer
|
||||||
|
const timer = setInterval(() => {
|
||||||
|
if (this.state.timeRemaining && this.state.timeRemaining > 0) {
|
||||||
|
this.state.timeRemaining--;
|
||||||
|
} else {
|
||||||
|
this.clearTimers();
|
||||||
|
this.timeExpired(nextQuestion);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
this.timers.set("questionTimer", timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private timeExpired(question: QuizQuestion): void {
|
||||||
|
// Handle when time expires for a question
|
||||||
|
// Auto-submit blank answers for players who didn't answer
|
||||||
|
this.players.forEach((player) => {
|
||||||
|
if (!(question.id in (this.quizState.playerAnswers[player.id] || {}))) {
|
||||||
|
this.quizState.playerAnswers[player.id][question.id] = -1; // -1 means no answer
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if ((this.config as QuizGameConfig).revealAnswersImmediately) {
|
||||||
|
this.broadcastQuestionResults(question);
|
||||||
|
setTimeout(() => this.nextQuestion(), 3000);
|
||||||
|
} else {
|
||||||
|
this.nextQuestion();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearTimers(): void {
|
||||||
|
this.timers.forEach((timer) => clearInterval(timer));
|
||||||
|
this.timers.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private shuffleArray<T>(array: T[]): T[] {
|
||||||
|
const newArray = [...array];
|
||||||
|
for (let i = newArray.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[newArray[i], newArray[j]] = [newArray[j], newArray[i]];
|
||||||
|
}
|
||||||
|
return newArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
private provideHint(player: GamePlayer, question: QuizQuestion): void {
|
||||||
|
// Implementation of hint system
|
||||||
|
// This is a placeholder for the hint logic
|
||||||
|
|
||||||
|
// Example: Find a wrong answer to eliminate
|
||||||
|
const wrongOptions = question.options
|
||||||
|
.map((option, index) => ({ option, index }))
|
||||||
|
.filter((item) => item.index !== question.correctOptionIndex);
|
||||||
|
|
||||||
|
if (wrongOptions.length > 0) {
|
||||||
|
const randomWrongOption =
|
||||||
|
wrongOptions[Math.floor(Math.random() * wrongOptions.length)];
|
||||||
|
this.broadcastHint(player, question.id, randomWrongOption.index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcasting methods (these would connect to your websocket/realtime system)
|
||||||
|
private broadcastQuestion(question: QuizQuestion): void {
|
||||||
|
// In a real implementation, this would send the question to all players
|
||||||
|
console.log("Broadcasting question:", question.text);
|
||||||
|
|
||||||
|
// Example of what to broadcast (would be sent via websocket)
|
||||||
|
const broadcastData = {
|
||||||
|
type: "question",
|
||||||
|
questionId: question.id,
|
||||||
|
text: question.text,
|
||||||
|
options: question.options,
|
||||||
|
imageUrl: question.imageUrl,
|
||||||
|
timeLimit: question.timeLimit || this.config.timeLimit,
|
||||||
|
points: question.points,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send to all players
|
||||||
|
// this.broadcastToPlayers(broadcastData);
|
||||||
|
}
|
||||||
|
|
||||||
|
private broadcastQuestionResults(question: QuizQuestion): void {
|
||||||
|
// In a real implementation, this would send the results to all players
|
||||||
|
console.log("Broadcasting question results for:", question.text);
|
||||||
|
|
||||||
|
// Example of what to broadcast
|
||||||
|
const broadcastData = {
|
||||||
|
type: "questionResults",
|
||||||
|
questionId: question.id,
|
||||||
|
correctOptionIndex: question.correctOptionIndex,
|
||||||
|
playerAnswers: this.quizState.playerAnswers,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send to all players
|
||||||
|
// this.broadcastToPlayers(broadcastData);
|
||||||
|
}
|
||||||
|
|
||||||
|
private broadcastPlayerCorrectAnswer(
|
||||||
|
player: GamePlayer,
|
||||||
|
questionId: string,
|
||||||
|
points: number,
|
||||||
|
): void {
|
||||||
|
// Example of what to broadcast when a player answers correctly
|
||||||
|
const broadcastData = {
|
||||||
|
type: "correctAnswer",
|
||||||
|
playerId: player.id,
|
||||||
|
playerName: player.displayName,
|
||||||
|
questionId: questionId,
|
||||||
|
points: points,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send to all players
|
||||||
|
// this.broadcastToPlayers(broadcastData);
|
||||||
|
}
|
||||||
|
|
||||||
|
private broadcastGameEnd(): void {
|
||||||
|
// Example of what to broadcast when the game ends
|
||||||
|
const broadcastData = {
|
||||||
|
type: "gameEnd",
|
||||||
|
leaderboard: this.quizState.leaderboard,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send to all players
|
||||||
|
// this.broadcastToPlayers(broadcastData);
|
||||||
|
}
|
||||||
|
|
||||||
|
private broadcastHint(
|
||||||
|
player: GamePlayer,
|
||||||
|
questionId: string,
|
||||||
|
eliminatedOptionIndex: number,
|
||||||
|
): void {
|
||||||
|
// Example of what to broadcast when providing a hint
|
||||||
|
const broadcastData = {
|
||||||
|
type: "hint",
|
||||||
|
playerId: player.id,
|
||||||
|
questionId: questionId,
|
||||||
|
eliminatedOptionIndex: eliminatedOptionIndex,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send to specific player
|
||||||
|
// this.sendToPlayer(player.id, broadcastData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Game Registry to manage different games
|
||||||
|
class GameRegistry {
|
||||||
|
private games: Map<string, any> = new Map();
|
||||||
|
|
||||||
|
registerGame(gameClass: any): void {
|
||||||
|
const tempInstance = new gameClass({} as any);
|
||||||
|
this.games.set(tempInstance.getGameId(), gameClass);
|
||||||
|
}
|
||||||
|
|
||||||
|
createGameInstance(gameId: string, ...args: any[]): Game {
|
||||||
|
const GameClass = this.games.get(gameId);
|
||||||
|
|
||||||
|
if (!GameClass) {
|
||||||
|
throw new Error(`Game ${gameId} not found in registry`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new GameClass(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
listAvailableGames(): string[] {
|
||||||
|
return Array.from(this.games.keys());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import type { QuizData } from ".";
|
||||||
|
|
||||||
|
export const exampleQuizData: QuizData = {
|
||||||
|
version: "1.0.0",
|
||||||
|
id: "sample-quiz-001",
|
||||||
|
title: "General Knowledge Quiz",
|
||||||
|
description: "Test your knowledge on various topics",
|
||||||
|
author: "user123",
|
||||||
|
category: "general",
|
||||||
|
difficulty: "medium",
|
||||||
|
tags: ["general", "knowledge", "trivia"],
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
id: "q1",
|
||||||
|
text: "What is the capital of France?",
|
||||||
|
options: ["London", "Berlin", "Paris", "Madrid"],
|
||||||
|
correctOptionIndex: 2,
|
||||||
|
points: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "q2",
|
||||||
|
text: "Which planet is known as the Red Planet?",
|
||||||
|
options: ["Venus", "Mars", "Jupiter", "Saturn"],
|
||||||
|
correctOptionIndex: 1,
|
||||||
|
points: 10,
|
||||||
|
},
|
||||||
|
// More questions...
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { GameConfig } from "@/lib/validations/game";
|
||||||
|
import type { Player } from "@/server/db/schema";
|
||||||
|
|
||||||
|
export type GamePlayer = Pick<Player, "id" | "displayName" | "avatar"> & {
|
||||||
|
score: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface IMinigame {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
thumbnail: string;
|
||||||
|
maxPlayers: number;
|
||||||
|
minPlayers: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IGameState {
|
||||||
|
status: "waiting" | "active" | "paused" | "completed";
|
||||||
|
players: Array<GamePlayer>;
|
||||||
|
currentRound: number;
|
||||||
|
totalRounds: number;
|
||||||
|
timeRemaining?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base Game abstract class
|
||||||
|
export abstract class Game {
|
||||||
|
protected meta: IMinigame;
|
||||||
|
protected players: Array<GamePlayer> = [];
|
||||||
|
protected config: GameConfig;
|
||||||
|
protected state: IGameState;
|
||||||
|
|
||||||
|
constructor(meta: IMinigame, config: GameConfig) {
|
||||||
|
this.meta = meta;
|
||||||
|
this.config = config;
|
||||||
|
this.state = {
|
||||||
|
status: "waiting",
|
||||||
|
players: [],
|
||||||
|
currentRound: 0,
|
||||||
|
totalRounds: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public getGameId(): string {
|
||||||
|
return this.meta.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getState(): IGameState {
|
||||||
|
return { ...this.state };
|
||||||
|
}
|
||||||
|
|
||||||
|
public addPlayer(player: GamePlayer): void {
|
||||||
|
this.players.push(player);
|
||||||
|
this.state.players = [...this.players];
|
||||||
|
}
|
||||||
|
|
||||||
|
public removePlayer(playerId: string): void {
|
||||||
|
this.players = this.players.filter((p) => p.id !== playerId);
|
||||||
|
this.state.players = [...this.players];
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract initialize(config?: GameConfig): void;
|
||||||
|
abstract start(): void;
|
||||||
|
abstract end(): void;
|
||||||
|
abstract handlePlayerAction(playerId: string, action: any): void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"use client";
|
||||||
|
import { create } from "zustand";
|
||||||
|
import type { Player } from "@/server/db/schema";
|
||||||
|
|
||||||
|
type SessionPlayerStore = {
|
||||||
|
sessionPlayer?: Player | null;
|
||||||
|
setSessionPlayer: (player?: Player | null) => void;
|
||||||
|
isLobbyAdmin: boolean;
|
||||||
|
setIsLobbyAdmin: (isAdmin: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useSessionPlayerStore = create<SessionPlayerStore>((set) => ({
|
||||||
|
sessionPlayer: undefined,
|
||||||
|
setSessionPlayer: (player) => set({ sessionPlayer: player }),
|
||||||
|
isLobbyAdmin: false,
|
||||||
|
setIsLobbyAdmin: (isAdmin) => set({ isLobbyAdmin: isAdmin }),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"use client";
|
||||||
|
import { create } from "zustand";
|
||||||
|
import type { IMinigame } from "@/game-engien";
|
||||||
|
import type { GameConfig } from "../validations/game";
|
||||||
|
|
||||||
|
type GameStore = {
|
||||||
|
selectedGame?: IMinigame;
|
||||||
|
setSelectedGame: (game?: IMinigame) => void;
|
||||||
|
gameConfig: GameConfig;
|
||||||
|
setGameConfig: (config: GameConfig) => void;
|
||||||
|
updateGameConfig: (config: Partial<GameConfig>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useGameStore = create<GameStore>((set, get) => ({
|
||||||
|
selectedGame: undefined,
|
||||||
|
setSelectedGame: (game) => set({ selectedGame: game }),
|
||||||
|
gameConfig: {} as GameConfig,
|
||||||
|
setGameConfig: (config) => set({ gameConfig: config }),
|
||||||
|
updateGameConfig: (config) =>
|
||||||
|
set({ gameConfig: { ...get().gameConfig, ...config } }),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { create } from "zustand";
|
||||||
|
import type { Lobby, LobbyMember } from "@/server/db/schema";
|
||||||
|
|
||||||
|
type LobbyStore = {
|
||||||
|
lobby: Lobby;
|
||||||
|
updateLobby: (lobby: Lobby) => void;
|
||||||
|
resetLobby: (lobby?: Lobby) => void;
|
||||||
|
members: Array<LobbyMember>;
|
||||||
|
setMembers: (members: Array<LobbyMember>) => void;
|
||||||
|
findMember: (playerId: string) => LobbyMember | null | undefined;
|
||||||
|
addMember: (member: LobbyMember) => void;
|
||||||
|
removeMember: (playerId: string, kicked?: boolean) => void;
|
||||||
|
selectedGame: number;
|
||||||
|
setSelectedGame: (gameId: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useLobbyStore = create<LobbyStore>((set, get) => ({
|
||||||
|
lobby: {} as Lobby,
|
||||||
|
members: [],
|
||||||
|
selectedGame: 0,
|
||||||
|
setSelectedGame: (gameId) => set({ selectedGame: gameId }),
|
||||||
|
updateLobby: (lobby) =>
|
||||||
|
set((state) => ({ lobby: { ...state, ...lobby, members: state.members } })),
|
||||||
|
setMembers: (members) => set({ members }),
|
||||||
|
resetLobby: (lobby) => set({ lobby: lobby ?? ({} as Lobby) }),
|
||||||
|
findMember: (playerId) => get().members.find((m) => m.playerId === playerId),
|
||||||
|
addMember: (member) =>
|
||||||
|
set((state) => {
|
||||||
|
if (state.members.find((m) => m.playerId === member.playerId))
|
||||||
|
return state;
|
||||||
|
return { members: [...state.members, member] };
|
||||||
|
}),
|
||||||
|
removeMember: (playerId) =>
|
||||||
|
set((state) => ({
|
||||||
|
members: state.members.filter((m) => m.playerId !== playerId),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
@@ -13,3 +13,24 @@ export const formatDate = (date: Date) =>
|
|||||||
hour: "numeric",
|
hour: "numeric",
|
||||||
minute: "numeric",
|
minute: "numeric",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const 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}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const debounce = <T extends (...args: any[]) => any>(
|
||||||
|
callback: T,
|
||||||
|
waitFor: number,
|
||||||
|
) => {
|
||||||
|
let timeout: ReturnType<typeof setTimeout>;
|
||||||
|
return (...args: Parameters<T>): ReturnType<T> => {
|
||||||
|
let result: any;
|
||||||
|
timeout && clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
result = callback(...args);
|
||||||
|
}, waitFor);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const gameConfigPatchSchema = z.object({
|
||||||
|
gameId: z.string().optional(),
|
||||||
|
gameVariantId: z.string().optional(),
|
||||||
|
timeLimit: z.number().min(2).max(120).default(2),
|
||||||
|
scoreMultiplier: z.number().optional().optional(),
|
||||||
|
allowHints: z.boolean().default(false).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type GameConfig = z.infer<typeof gameConfigPatchSchema>;
|
||||||
@@ -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>;
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const trcpSubscriptionInput = z
|
||||||
|
.object({
|
||||||
|
lastEventId: z.string().nullish(),
|
||||||
|
})
|
||||||
|
.optional();
|
||||||
@@ -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 };
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { DBType } from "@/server/db";
|
||||||
|
import { gameConfigurations } from "@/server/db/schema";
|
||||||
|
|
||||||
|
export async function createGameConfig(lobbyId: string, db: DBType) {
|
||||||
|
await db
|
||||||
|
.insert(gameConfigurations)
|
||||||
|
.values({
|
||||||
|
lobbyId,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { db } from "@/server/db";
|
||||||
|
import { redis } from "@/server/redis";
|
||||||
|
import { lobbies, lobbyMembers } from "@/server/db/schema";
|
||||||
|
import { count, eq } from "drizzle-orm";
|
||||||
|
import { ee } from "@/server/sse";
|
||||||
|
|
||||||
|
export async function getLobbyMemberCount(lobbyId: string) {
|
||||||
|
// Check if the count is in the cache
|
||||||
|
const cachedCount = await redis.get(`lobby:${lobbyId}:memberCount`);
|
||||||
|
if (cachedCount !== null) {
|
||||||
|
return parseInt(cachedCount, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not in cache, query the database
|
||||||
|
const [rawMemberCount] = await db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(lobbyMembers)
|
||||||
|
.where(eq(lobbyMembers.lobbyId, lobbyId));
|
||||||
|
|
||||||
|
const memberCount = rawMemberCount?.count ?? 0;
|
||||||
|
// Cache the result
|
||||||
|
await redis.set(`lobby:${lobbyId}:memberCount`, memberCount);
|
||||||
|
return memberCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteLobbyIfEmpty(lobbyId: string) {
|
||||||
|
const activeMemberCount = await getLobbyMemberCount(lobbyId);
|
||||||
|
if (activeMemberCount > 0) return;
|
||||||
|
|
||||||
|
const [realMemberCount] = await db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(lobbyMembers)
|
||||||
|
.where(eq(lobbyMembers.lobbyId, lobbyId));
|
||||||
|
if (realMemberCount?.count === 0) {
|
||||||
|
await db.delete(lobbies).where(eq(lobbies.id, lobbyId));
|
||||||
|
console.log(`Lobby with ID ${lobbyId} has been deleted. (EMPTY_LOBBY)`);
|
||||||
|
// Optionally, remove the cached count
|
||||||
|
await redis.del(`lobby:${lobbyId}:memberCount`);
|
||||||
|
ee.emit("lobby:update", { id: lobbyId }, true);
|
||||||
|
} else {
|
||||||
|
console.error(
|
||||||
|
"!!!!== Redis Cache is out of sync with the database. ==!!!!",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addAdminToLobby(lobbyId: string, playerId: string) {}
|
||||||
|
|
||||||
|
export async function handleLobbyAfterLeave(lobbyId: string) {
|
||||||
|
await deleteLobbyIfEmpty(lobbyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function decreaseLobbyMemberCount(lobbyId: string) {
|
||||||
|
await redis.decr(`lobby:${lobbyId}:memberCount`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function increaseLobbyMemberCount(lobbyId: string) {
|
||||||
|
await redis.incr(`lobby:${lobbyId}:memberCount`);
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
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";
|
||||||
|
import { gameRouter } from "./routers/game";
|
||||||
|
import { gameConfigRouter } from "./routers/game-config";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This is the primary router for your server.
|
* This is the primary router for your server.
|
||||||
@@ -8,6 +11,9 @@ import { createCallerFactory, createTRPCRouter } from "@/server/api/trpc";
|
|||||||
*/
|
*/
|
||||||
export const appRouter = createTRPCRouter({
|
export const appRouter = createTRPCRouter({
|
||||||
lobby: lobbyRouter,
|
lobby: lobbyRouter,
|
||||||
|
player: playerRouter,
|
||||||
|
game: gameRouter,
|
||||||
|
gameConfig: gameConfigRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { gameConfigurations } from "@/server/db/schema";
|
||||||
|
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { gameConfigPatchSchema } from "@/lib/validations/game";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { ee } from "@/server/sse";
|
||||||
|
import { tracked } from "@trpc/server";
|
||||||
|
import type { EventArgs } from "@/server/sse/events";
|
||||||
|
|
||||||
|
export const gameConfigRouter = createTRPCRouter({
|
||||||
|
// game configurator
|
||||||
|
update: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
lobbyId: z.string(),
|
||||||
|
config: gameConfigPatchSchema,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
console.log("Check if user is admin");
|
||||||
|
|
||||||
|
const [config] = await ctx.db
|
||||||
|
.update(gameConfigurations)
|
||||||
|
.set({ config: input.config })
|
||||||
|
.where(eq(gameConfigurations.lobbyId, input.lobbyId))
|
||||||
|
.returning({
|
||||||
|
config: gameConfigurations.config,
|
||||||
|
});
|
||||||
|
if (config?.config)
|
||||||
|
ee.emit("game:config:update", {
|
||||||
|
lobbyId: input.lobbyId,
|
||||||
|
config: config.config,
|
||||||
|
});
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}),
|
||||||
|
|
||||||
|
// subscriptions
|
||||||
|
onUpdate: publicProcedure
|
||||||
|
.input(z.object({ lobbyId: z.string() }))
|
||||||
|
.subscription(async function* (opts) {
|
||||||
|
const iterable = ee.toIterable("game:config:update", {
|
||||||
|
signal: opts.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
function* maybeYield([
|
||||||
|
{ config, lobbyId },
|
||||||
|
]: EventArgs["game:config:update"]) {
|
||||||
|
if (lobbyId !== opts.input.lobbyId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
yield tracked(lobbyId, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
for await (const args of iterable) {
|
||||||
|
yield* maybeYield(args);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { gameConfigurations } from "@/server/db/schema";
|
||||||
|
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { gameConfigPatchSchema } from "@/lib/validations/game";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { ee } from "@/server/sse";
|
||||||
|
|
||||||
|
export const gameRouter = createTRPCRouter({
|
||||||
|
// game sessions
|
||||||
|
createGameSession: publicProcedure
|
||||||
|
.input(z.object({ lobbyId: z.string() }))
|
||||||
|
.query(async ({ ctx }) => {
|
||||||
|
return null;
|
||||||
|
}),
|
||||||
|
});
|
||||||
+211
-50
@@ -1,32 +1,45 @@
|
|||||||
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 { tracked } from "@trpc/server";
|
||||||
|
import type { EventArgs } from "@/server/sse/events";
|
||||||
|
import {
|
||||||
|
decreaseLobbyMemberCount,
|
||||||
|
increaseLobbyMemberCount,
|
||||||
|
handleLobbyAfterLeave,
|
||||||
|
} from "@/server/api/mutation-utils/lobby-utils";
|
||||||
|
import { createGameConfig } from "../mutation-utils/game-config";
|
||||||
|
import { ee } from "@/server/sse";
|
||||||
|
|
||||||
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: {
|
||||||
|
gameConfig: true,
|
||||||
|
members: {
|
||||||
|
with: {
|
||||||
|
player: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
return reuslt?.lobby!;
|
||||||
return [
|
|
||||||
...ownedLobbies,
|
|
||||||
...(joinedLobbies?.map(({ lobby }) => lobby) ?? []),
|
|
||||||
];
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
get: publicProcedure
|
get: publicProcedure
|
||||||
@@ -40,24 +53,10 @@ 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: {
|
gameConfig: true,
|
||||||
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 +78,22 @@ 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");
|
||||||
|
const [member] = await ctx.db
|
||||||
|
.insert(lobbyMembers)
|
||||||
|
.values({
|
||||||
|
lobbyId: lobby.id,
|
||||||
|
playerId: ctx.session.user.id,
|
||||||
|
isReady: false,
|
||||||
|
role: "admin",
|
||||||
|
})
|
||||||
|
.returning({
|
||||||
|
id: lobbyMembers.playerId,
|
||||||
|
});
|
||||||
|
if (member && lobby) {
|
||||||
|
increaseLobbyMemberCount(lobby.id);
|
||||||
|
createGameConfig(lobby.id, ctx.db);
|
||||||
|
}
|
||||||
return lobby;
|
return lobby;
|
||||||
}),
|
}),
|
||||||
update: protectedProcedure
|
update: protectedProcedure
|
||||||
@@ -89,6 +104,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 +115,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) ee.emit("lobby:update", lobby);
|
||||||
return lobby;
|
return lobby;
|
||||||
}),
|
}),
|
||||||
delete: protectedProcedure
|
delete: protectedProcedure
|
||||||
@@ -108,6 +127,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 +150,170 @@ export const lobbyRouter = createTRPCRouter({
|
|||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
if (input.join) {
|
if (input.join) {
|
||||||
return (
|
try {
|
||||||
await ctx.db
|
const [member] = 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
|
||||||
} else {
|
? await ctx.db.query.players.findFirst({
|
||||||
return (
|
where: eq(players.id, member.playerId),
|
||||||
await ctx.db
|
})
|
||||||
.delete(lobbyMembers)
|
: undefined;
|
||||||
.where(
|
if (member) {
|
||||||
and(
|
ee.emit("lobby:member:membership", input.lobbyId, true, {
|
||||||
eq(lobbyMembers.lobbyId, input.lobbyId),
|
...member,
|
||||||
eq(lobbyMembers.userId, ctx.session.user.id),
|
player,
|
||||||
),
|
});
|
||||||
|
increaseLobbyMemberCount(input.lobbyId);
|
||||||
|
return { success: true, member };
|
||||||
|
}
|
||||||
|
} catch (e: unknown) {
|
||||||
|
if (e instanceof Error) {
|
||||||
|
if (
|
||||||
|
e.message.includes(
|
||||||
|
"duplicate key value violates unique constraint",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
.returning()
|
return {
|
||||||
)[0];
|
knownError: true,
|
||||||
|
succes: false,
|
||||||
|
error: "You can only be in one lobby at a time.",
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
knownError: true,
|
||||||
|
succes: false,
|
||||||
|
error: "Error joining lobby",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const [member] = await ctx.db
|
||||||
|
.delete(lobbyMembers)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(lobbyMembers.lobbyId, input.lobbyId),
|
||||||
|
eq(lobbyMembers.playerId, ctx.session.user.id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning();
|
||||||
|
if (member) {
|
||||||
|
ee.emit(
|
||||||
|
"lobby:member:membership",
|
||||||
|
input.lobbyId,
|
||||||
|
false,
|
||||||
|
member.playerId,
|
||||||
|
);
|
||||||
|
decreaseLobbyMemberCount(input.lobbyId);
|
||||||
|
handleLobbyAfterLeave(input.lobbyId);
|
||||||
|
return { success: true, 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) ee.emit("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)
|
||||||
|
ee.emit(
|
||||||
|
"lobby:member:membership",
|
||||||
|
input.lobbyId,
|
||||||
|
false,
|
||||||
|
member.playerId,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
decreaseLobbyMemberCount(input.lobbyId);
|
||||||
|
return member;
|
||||||
|
}),
|
||||||
|
|
||||||
|
// subscriptions
|
||||||
|
onUpdate: publicProcedure
|
||||||
|
.input(z.object({ lobbyId: z.string() }))
|
||||||
|
.subscription(async function* (opts) {
|
||||||
|
const iterable = ee.toIterable("lobby:update", {
|
||||||
|
signal: opts.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
function* maybeYield([lobby, deleted]: EventArgs["lobby:update"]) {
|
||||||
|
if (lobby.id !== opts.input.lobbyId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
yield tracked(lobby.id, { lobby, deleted });
|
||||||
|
}
|
||||||
|
|
||||||
|
for await (const args of iterable) {
|
||||||
|
yield* maybeYield(args);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
|
||||||
|
onMemberUpdate: publicProcedure
|
||||||
|
.input(z.object({ lobbyId: z.string() }))
|
||||||
|
.subscription(async function* (opts) {
|
||||||
|
const iterable = ee.toIterable("lobby:member:membership", {
|
||||||
|
signal: opts.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
function* maybeYield([
|
||||||
|
lobbyId,
|
||||||
|
joined,
|
||||||
|
member,
|
||||||
|
kicked,
|
||||||
|
]: EventArgs["lobby:member:membership"]) {
|
||||||
|
if (lobbyId !== opts.input.lobbyId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
yield tracked(lobbyId, { member, joined, kicked });
|
||||||
|
}
|
||||||
|
|
||||||
|
for await (const args of iterable) {
|
||||||
|
yield* maybeYield(args);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||||
|
import { players, type Player } 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),
|
||||||
|
})) as Player)
|
||||||
|
: null;
|
||||||
|
}),
|
||||||
|
});
|
||||||
+16
-47
@@ -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 }) => {
|
||||||
...session,
|
return {
|
||||||
user: {
|
...session,
|
||||||
...session.user,
|
user: {
|
||||||
id: user.id,
|
...session.user,
|
||||||
joinedAt: user.joinedAt,
|
id: user.id,
|
||||||
},
|
},
|
||||||
}),
|
};
|
||||||
|
},
|
||||||
},
|
},
|
||||||
} satisfies NextAuthConfig;
|
} satisfies NextAuthConfig;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
},
|
||||||
|
};
|
||||||
+78
-31
@@ -2,14 +2,34 @@ 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
|
import type { GameConfig } from "@/lib/validations/game";
|
||||||
* database instance for multiple projects.
|
|
||||||
*
|
|
||||||
* @see https://orm.drizzle.team/docs/goodies#multi-project-schema
|
|
||||||
*/
|
|
||||||
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 gameConfigurations = createTable(
|
||||||
|
"game_configuration",
|
||||||
|
(d) => ({
|
||||||
|
lobbyId: d
|
||||||
|
.varchar({ length: 255 })
|
||||||
|
.notNull()
|
||||||
|
.references(() => lobbies.id, { onDelete: "cascade" }),
|
||||||
|
config: d.jsonb().$type<GameConfig>(),
|
||||||
|
}),
|
||||||
|
(t) => [
|
||||||
|
primaryKey({ columns: [t.lobbyId] }),
|
||||||
|
index("game_configuration_lobby_id_idx").on(t.lobbyId),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
export type GameConfigurationTable = typeof gameConfigurations.$inferSelect;
|
||||||
|
|
||||||
export const lobbies = createTable("lobby", (d) => ({
|
export const lobbies = createTable("lobby", (d) => ({
|
||||||
id: d
|
id: d
|
||||||
.varchar({ length: 255 })
|
.varchar({ length: 255 })
|
||||||
@@ -21,61 +41,93 @@ 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 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),
|
||||||
|
gameConfig: one(gameConfigurations, {
|
||||||
|
fields: [lobbies.id],
|
||||||
|
references: [gameConfigurations.lobbyId],
|
||||||
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export type Lobby = typeof lobbies.$inferSelect & {
|
||||||
|
members?: LobbyMember[];
|
||||||
|
leader?: Player;
|
||||||
|
gameConfig?: GameConfigurationTable;
|
||||||
|
};
|
||||||
|
|
||||||
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 +136,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 +149,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 +180,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)],
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { env } from "@/env";
|
||||||
|
import Redis, { type Redis as RedisType } from "ioredis";
|
||||||
|
|
||||||
|
const globalForRedis = globalThis as unknown as {
|
||||||
|
redis: RedisType | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const redisInstance = globalForRedis.redis ?? new Redis();
|
||||||
|
if (env.NODE_ENV !== "production") globalForRedis.redis = redisInstance;
|
||||||
|
|
||||||
|
export const redis = redisInstance;
|
||||||
Vendored
+37
@@ -0,0 +1,37 @@
|
|||||||
|
import type { GameConfig } from "@/lib/validations/game";
|
||||||
|
import type { LobbyMember, Post } from "../db/schema";
|
||||||
|
|
||||||
|
export type LobbyMemberLeaveEventData = { playerId: string; kicked?: boolean };
|
||||||
|
|
||||||
|
export type EventArgs = {
|
||||||
|
"lobby:member:membership": [
|
||||||
|
// lobbyId
|
||||||
|
string,
|
||||||
|
// joined
|
||||||
|
boolean,
|
||||||
|
// member
|
||||||
|
LobbyMember | string,
|
||||||
|
// kicked
|
||||||
|
boolean?,
|
||||||
|
];
|
||||||
|
"lobby:update": [
|
||||||
|
//updated Lobby
|
||||||
|
Lobby,
|
||||||
|
// deleted
|
||||||
|
boolean?,
|
||||||
|
];
|
||||||
|
"game:config:update": [
|
||||||
|
{
|
||||||
|
lobbyId: string;
|
||||||
|
config: GameConfig;
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SSE_EVENTS = {
|
||||||
|
"lobby:update": EventArgs["lobby:update"];
|
||||||
|
"lobby:member:update": [LobbyMember];
|
||||||
|
"lobby:member:membership": EventArgs["lobby:member:membership"];
|
||||||
|
|
||||||
|
"game:config:update": EventArgs["game:config:update"];
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import EventEmitter, { on } from "node:events";
|
||||||
|
import type { SSE_EVENTS } from "./events";
|
||||||
|
|
||||||
|
type EventMap<T> = Record<keyof T, any[]>;
|
||||||
|
class IterableEventEmitter<T extends EventMap<T>> extends EventEmitter<T> {
|
||||||
|
toIterable<TEventName extends keyof T & string>(
|
||||||
|
eventName: TEventName,
|
||||||
|
opts?: NonNullable<Parameters<typeof on>[2]>,
|
||||||
|
): AsyncIterable<T[TEventName]> {
|
||||||
|
return on(this as any, eventName, opts) as any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ee = new IterableEventEmitter<SSE_EVENTS>();
|
||||||
@@ -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;
|
||||||
@@ -134,4 +137,8 @@
|
|||||||
.text-shadow-primary {
|
.text-shadow-primary {
|
||||||
text-shadow: 1px 1px 2px var(--primary);
|
text-shadow: 1px 1px 2px var(--primary);
|
||||||
}
|
}
|
||||||
|
.popover-content-width-same-as-its-trigger {
|
||||||
|
width: var(--radix-popover-trigger-width);
|
||||||
|
max-height: var(--radix-popover-content-available-height);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-15
@@ -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,14 +56,22 @@ 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({
|
||||||
transformer: SuperJSON,
|
// uses the httpSubscriptionLink for subscriptions
|
||||||
url: getBaseUrl() + "/api/trpc",
|
condition: (op) => op.type === "subscription",
|
||||||
headers: () => {
|
true: httpSubscriptionLink({
|
||||||
const headers = new Headers();
|
transformer: SuperJSON,
|
||||||
headers.set("x-trpc-source", "nextjs-react");
|
url: `${getBaseUrl()}/api/trpc`,
|
||||||
return headers;
|
}),
|
||||||
},
|
false: httpBatchStreamLink({
|
||||||
|
transformer: SuperJSON,
|
||||||
|
url: `${getBaseUrl()}/api/trpc`,
|
||||||
|
headers: () => {
|
||||||
|
const headers = new Headers();
|
||||||
|
headers.set("x-trpc-source", "nextjs-react");
|
||||||
|
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}`;
|
|
||||||
}
|
|
||||||
|
|||||||
+72
-50
@@ -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
|
||||||
--name $DB_CONTAINER_NAME \
|
if [ -x "$(command -v docker)" ]; then
|
||||||
-e POSTGRES_USER="postgres" \
|
DOCKER_CMD="docker"
|
||||||
-e POSTGRES_PASSWORD="$DB_PASSWORD" \
|
elif [ -x "$(command -v podman)" ]; then
|
||||||
-e POSTGRES_DB=game-master \
|
DOCKER_CMD="podman"
|
||||||
-p "$DB_PORT":5432 \
|
fi
|
||||||
docker.io/postgres && echo "Database container '$DB_CONTAINER_NAME' was successfully created"
|
|
||||||
|
if ! $DOCKER_CMD info > /dev/null 2>&1; then
|
||||||
|
echo "$DOCKER_CMD daemon is not running. Please start $DOCKER_CMD and try again."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# # Check if ports are in use
|
||||||
|
# if command -v nc >/dev/null 2>&1; then
|
||||||
|
# if nc -z localhost "$DB_PORT" 2>/dev/null; then
|
||||||
|
# echo "Port $DB_PORT is already in use."
|
||||||
|
# exit 1
|
||||||
|
# fi
|
||||||
|
# if nc -z localhost "$REDIS_PORT" 2>/dev/null; then
|
||||||
|
# echo "Port $REDIS_PORT is already in use."
|
||||||
|
# exit 1
|
||||||
|
# fi
|
||||||
|
# else
|
||||||
|
# echo "Warning: Unable to check if ports are in use (netcat not installed)"
|
||||||
|
# read -p "Do you want to continue anyway? [y/N]: " -r REPLY
|
||||||
|
# if ! [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||||
|
# echo "Aborting."
|
||||||
|
# exit 1
|
||||||
|
# fi
|
||||||
|
# fi
|
||||||
|
|
||||||
|
# Start PostgreSQL if not running
|
||||||
|
if [ "$($DOCKER_CMD ps -q -f name=$DB_CONTAINER_NAME)" ]; then
|
||||||
|
echo "Database container '$DB_CONTAINER_NAME' already running"
|
||||||
|
else
|
||||||
|
if [ "$($DOCKER_CMD ps -q -a -f name=$DB_CONTAINER_NAME)" ]; then
|
||||||
|
$DOCKER_CMD start "$DB_CONTAINER_NAME"
|
||||||
|
echo "Existing database container '$DB_CONTAINER_NAME' started"
|
||||||
|
else
|
||||||
|
$DOCKER_CMD run -d \
|
||||||
|
--name $DB_CONTAINER_NAME \
|
||||||
|
-e POSTGRES_USER="postgres" \
|
||||||
|
-e POSTGRES_PASSWORD="$DB_PASSWORD" \
|
||||||
|
-e POSTGRES_DB="$DB_NAME" \
|
||||||
|
-p "$DB_PORT":5432 \
|
||||||
|
docker.io/postgres && echo "Database container '$DB_CONTAINER_NAME' was successfully created"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start Redis if not running
|
||||||
|
if [ "$($DOCKER_CMD ps -q -f name=$REDIS_CONTAINER_NAME)" ]; then
|
||||||
|
echo "Redis container '$REDIS_CONTAINER_NAME' already running"
|
||||||
|
else
|
||||||
|
if [ "$($DOCKER_CMD ps -q -a -f name=$REDIS_CONTAINER_NAME)" ]; then
|
||||||
|
$DOCKER_CMD start "$REDIS_CONTAINER_NAME"
|
||||||
|
echo "Existing Redis container '$REDIS_CONTAINER_NAME' started"
|
||||||
|
else
|
||||||
|
$DOCKER_CMD run -d \
|
||||||
|
--name $REDIS_CONTAINER_NAME \
|
||||||
|
-p "$REDIS_PORT":6379 \
|
||||||
|
docker.io/redis && echo "Redis container '$REDIS_CONTAINER_NAME' was successfully created"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|||||||
+2
-1
@@ -27,7 +27,8 @@
|
|||||||
/* Path Aliases */
|
/* Path Aliases */
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"],
|
||||||
|
"@game/*": ["./src/game-engien/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
|
|||||||
Reference in New Issue
Block a user