This commit is contained in:
Vico 2025-03-26 17:08:31 +01:00
parent bc80fe14bf
commit 03137d0d5d
25 changed files with 2342 additions and 176 deletions

6
nodemon.json Normal file
View File

@ -0,0 +1,6 @@
{
"watch": ["src/server/socket"],
"ext": "ts,json",
"ignore": ["src/**/*.spec.ts"],
"exec": "pnpm tsx ./src/server/socket/server.ts"
}

View File

@ -4,19 +4,21 @@
"private": true,
"type": "module",
"scripts": {
"dev": "./start-database.sh && concurrently \"pnpm next dev --turbo\" \"pnpm dev:socket\"",
"dev:socket": "pnpm nodemon ./src/server/socket/server.ts",
"start:socket": "node ./dist/src/server/socket/server.js",
"start": "next start",
"build": "next build",
"check": "next lint && tsc --noEmit",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"dev": "next dev --turbo",
"format:check": "prettier --check \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"format:write": "prettier --write \"**/*.{ts,tsx,js,jsx,mdx}\" --cache",
"lint": "next lint",
"lint:fix": "next lint --fix",
"preview": "next build && next start",
"start": "next start",
"typecheck": "tsc --noEmit"
},
"dependencies": {
@ -26,29 +28,39 @@
"@radix-ui/react-alert-dialog": "^1.1.6",
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-popover": "^1.1.6",
"@radix-ui/react-slot": "^1.1.2",
"@t3-oss/env-nextjs": "^0.12.0",
"@tanstack/react-query": "^5.69.0",
"@trpc/client": "^11.0.0",
"@trpc/react-query": "^11.0.0",
"@trpc/server": "^11.0.0",
"@types/express": "^5.0.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"concurrently": "^9.1.2",
"dotenv": "^16.4.7",
"drizzle-orm": "^0.41.0",
"express": "^4.21.2",
"lucide-react": "^0.483.0",
"mysql2": "^3.11.0",
"next": "^15.2.3",
"next-auth": "5.0.0-beta.25",
"next-themes": "^0.4.6",
"nodemon": "^3.1.9",
"postgres": "^3.4.5",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
"server-only": "^0.0.1",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",
"sonner": "^2.0.1",
"superjson": "^2.2.1",
"tailwind-merge": "^3.0.2",
"tsx": "^4.19.3",
"tw-animate-css": "^1.2.4",
"zod": "^3.24.2"
},

1516
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

22
src/app.config.ts Normal file
View File

@ -0,0 +1,22 @@
type AppConfig = {
name: string;
navigation: Array<NavLink>;
};
export const appConfig: AppConfig = {
name: "Game Master",
navigation: [
{
label: "Home",
path: "/",
},
{
label: "Lobbies",
path: "/lobby",
},
{
label: "Games",
path: "/#games",
},
],
};

View File

@ -2,12 +2,9 @@ import { api } from "@/trpc/server";
import type { User } from "next-auth";
import { notFound } from "next/navigation";
import React from "react";
import UserCard from "@/components/user-card";
import type { PublicUser } from "@/server/auth/config";
import { Button } from "@/components/ui/button";
import { auth } from "@/server/auth";
import DeleteLobbyDialog from "@/app/_components/delete-lobby-dialog";
import LobbyMembershipDialog from "@/app/_components/lobby-membership-dialog";
import LobbyPage from "@/app/_components/lobby-page";
async function Page({
params,
@ -26,27 +23,7 @@ async function Page({
...(lobby?.members?.map(({ user }) => ({ ...user, leader: false })) ?? []),
];
const isJoined = members.find((member) => member.id === session?.user.id);
const isOwner = lobby.createdById === session?.user.id;
return (
<div>
<h1 className="text-2xl font-bold capitalize">{lobby.name}</h1>
<ul>
{members?.map((member, idx) => (
<li key={idx}>
<UserCard name={member.name!} image={member.image!}>
{member?.leader && <label>Leader</label>}
</UserCard>
</li>
))}
</ul>
{isOwner && <DeleteLobbyDialog lobbyId={lobby.id} />}
{!isOwner && (
<LobbyMembershipDialog lobbyId={lobby.id} join={!isJoined} />
)}
</div>
);
return <LobbyPage lobby={lobby} initialMembers={members} session={session} />;
}
export default Page;

View File

@ -3,6 +3,7 @@ import { Button } from "@/components/ui/button";
import { Sparkles, Users, Plus } from "lucide-react";
import CreateLobbyDialog from "../_components/create-lobby-dialog";
import { auth } from "@/server/auth";
import { Icons } from "@/components/icons";
export default async function QuizGameStartPage() {
const session = await auth();
@ -10,7 +11,7 @@ export default async function QuizGameStartPage() {
<div className="relative z-10 w-full max-w-md px-4">
<div className="mb-8 text-center">
<div className="mb-2 flex justify-center">
<Sparkles className="h-10 w-10 animate-pulse text-yellow-300" />
<Icons.logo className="animate-puls h-10 w-10 text-purple-700" />
</div>
<h1 className="mb-2 bg-gradient-to-r from-yellow-300 via-pink-400 to-yellow-300 bg-clip-text text-5xl font-extrabold tracking-tight text-transparent">
QUIZ MASTER

View File

@ -0,0 +1,9 @@
import React from 'react'
function page() {
return (
<div>page</div>
)
}
export default page

View File

@ -1,7 +1,19 @@
import Link from "next/link"
import Image from "next/image"
import { Button } from "@/components/ui/button"
import { Trophy, Brain, Star, Award, Medal, Clock, ArrowLeft, Settings, BarChart3, Users, Sparkles } from "lucide-react"
import Link from "next/link";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import {
Trophy,
Brain,
Star,
Award,
Medal,
Clock,
ArrowLeft,
Settings,
BarChart3,
Users,
Sparkles,
} from "lucide-react";
import { auth } from "@/server/auth";
export default async function ProfilePage() {
@ -23,40 +35,44 @@ export default async function ProfilePage() {
{ name: "Team Player", icon: Users, color: "text-purple-400" },
],
recentGames: [
{ id: 1, topic: "Science", position: "1st", score: 950, date: "2 hours ago" },
{ id: 2, topic: "Movies", position: "3rd", score: 720, date: "Yesterday" },
{ id: 3, topic: "History", position: "1st", score: 880, date: "3 days ago" },
{
id: 1,
topic: "Science",
position: "1st",
score: 950,
date: "2 hours ago",
},
{
id: 2,
topic: "Movies",
position: "3rd",
score: 720,
date: "Yesterday",
},
{
id: 3,
topic: "History",
position: "1st",
score: 880,
date: "3 days ago",
},
],
}
};
// Calculate XP progress percentage
const xpProgress = (user.xp / user.xpToNextLevel) * 100
const xpProgress = (user.xp / user.xpToNextLevel) * 100;
return (
<div className="min-h-screen flex flex-col relative overflow-hidden">
{/* Header with back button */}
<header className="relative z-10 w-full p-4 flex items-center">
<Link href="/" className="flex items-center text-indigo-200 hover:text-white transition-colors">
<ArrowLeft className="mr-2 h-5 w-5" />
<span>Back to Home</span>
</Link>
<div className="ml-auto">
<Button variant="ghost" className="text-indigo-200 hover:text-white hover:bg-white/10">
<Settings className="h-5 w-5" />
<span className="sr-only">Settings</span>
</Button>
</div>
</header>
<div className="relative flex min-h-screen flex-col overflow-hidden">
{/* Main content */}
<main className="relative z-10 flex-1 container max-w-4xl mx-auto px-4 py-8">
<main className="relative z-10 container mx-auto max-w-4xl flex-1 px-4 py-8">
<div className="grid gap-6 md:grid-cols-3">
{/* Profile Card */}
<div className="md:col-span-1">
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-6 shadow-[0_0_15px_rgba(124,58,237,0.5)] border border-white/20 flex flex-col items-center">
<div className="flex flex-col items-center rounded-2xl border border-white/20 bg-white/10 p-6 shadow-[0_0_15px_rgba(124,58,237,0.5)] backdrop-blur-md">
<div className="relative">
<div className="w-32 h-32 rounded-full bg-gradient-to-r from-pink-500 via-purple-500 to-indigo-500 p-1">
<div className="w-full h-full rounded-full overflow-hidden bg-indigo-900 flex items-center justify-center">
<div className="h-32 w-32 rounded-full bg-gradient-to-r from-pink-500 via-purple-500 to-indigo-500 p-1">
<div className="flex h-full w-full items-center justify-center overflow-hidden rounded-full bg-indigo-900">
<Image
src="/placeholder.svg?height=120&width=120"
alt="Profile"
@ -66,65 +82,84 @@ export default async function ProfilePage() {
/>
</div>
</div>
<div className="absolute -bottom-2 -right-2 bg-yellow-400 text-indigo-900 rounded-full w-10 h-10 flex items-center justify-center font-bold border-2 border-indigo-900">
<div className="absolute -right-2 -bottom-2 flex h-10 w-10 items-center justify-center rounded-full border-2 border-indigo-900 bg-yellow-400 font-bold text-indigo-900">
{user.level}
</div>
</div>
<h1 className="mt-4 text-2xl font-bold text-white">{user.username}</h1>
<p className="text-indigo-200 text-sm">Member since {user.joinDate}</p>
<h1 className="mt-4 text-2xl font-bold text-white">
{user.username}
</h1>
<p className="text-sm text-indigo-200">
Member since {user.joinDate}
</p>
{/* XP Progress */}
<div className="w-full mt-6">
<div className="flex justify-between text-xs text-indigo-200 mb-1">
<div className="mt-6 w-full">
<div className="mb-1 flex justify-between text-xs text-indigo-200">
<span>XP: {user.xp.toLocaleString()}</span>
<span>{user.xpToNextLevel.toLocaleString()}</span>
</div>
<div className="w-full h-3 bg-indigo-900/50 rounded-full overflow-hidden">
<div className="h-3 w-full overflow-hidden rounded-full bg-indigo-900/50">
<div
className="h-full bg-gradient-to-r from-pink-500 to-yellow-400 rounded-full"
className="h-full rounded-full bg-gradient-to-r from-pink-500 to-yellow-400"
style={{ width: `${xpProgress}%` }}
></div>
</div>
<div className="text-center text-xs text-indigo-200 mt-1">
{Math.floor(user.xpToNextLevel - user.xp).toLocaleString()} XP to level {user.level + 1}
<div className="mt-1 text-center text-xs text-indigo-200">
{Math.floor(user.xpToNextLevel - user.xp).toLocaleString()} XP
to level {user.level + 1}
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-2 w-full mt-6">
<div className="bg-white/5 rounded-lg p-2 text-center">
<p className="text-2xl font-bold text-white">{user.gamesPlayed}</p>
<div className="mt-6 grid w-full grid-cols-3 gap-2">
<div className="rounded-lg bg-white/5 p-2 text-center">
<p className="text-2xl font-bold text-white">
{user.gamesPlayed}
</p>
<p className="text-xs text-indigo-200">Games</p>
</div>
<div className="bg-white/5 rounded-lg p-2 text-center">
<p className="text-2xl font-bold text-white">{user.gamesWon}</p>
<div className="rounded-lg bg-white/5 p-2 text-center">
<p className="text-2xl font-bold text-white">
{user.gamesWon}
</p>
<p className="text-xs text-indigo-200">Wins</p>
</div>
<div className="bg-white/5 rounded-lg p-2 text-center">
<p className="text-2xl font-bold text-white">{user.winRate}%</p>
<div className="rounded-lg bg-white/5 p-2 text-center">
<p className="text-2xl font-bold text-white">
{user.winRate}%
</p>
<p className="text-xs text-indigo-200">Win Rate</p>
</div>
</div>
<Button className="mt-6 w-full bg-gradient-to-r from-pink-500 to-purple-600 hover:from-pink-600 hover:to-purple-700 text-white rounded-xl shadow-lg shadow-purple-600/30 border-b-4 border-purple-700 hover:translate-y-1 hover:border-b-2 transition-all duration-200">
Edit Profile
</Button>
{session ? (
<Link href={`/profile/${session.user.id}/edit`}>
<Button className="mt-6 w-full rounded-xl border-b-4 border-purple-700 bg-gradient-to-r from-pink-500 to-purple-600 text-white shadow-lg shadow-purple-600/30 transition-all duration-200 hover:translate-y-1 hover:border-b-2 hover:from-pink-600 hover:to-purple-700">
Edit Profile
</Button>
</Link>
) : (
<div></div>
)}
</div>
</div>
{/* Achievements and Recent Games */}
<div className="md:col-span-2 space-y-6">
<div className="space-y-6 md:col-span-2">
{/* Achievements */}
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-6 shadow-[0_0_15px_rgba(124,58,237,0.5)] border border-white/20">
<div className="flex items-center mb-4">
<Award className="text-yellow-400 mr-2 h-6 w-6" />
<div className="rounded-2xl border border-white/20 bg-white/10 p-6 shadow-[0_0_15px_rgba(124,58,237,0.5)] backdrop-blur-md">
<div className="mb-4 flex items-center">
<Award className="mr-2 h-6 w-6 text-yellow-400" />
<h2 className="text-xl font-bold text-white">Achievements</h2>
</div>
<div className="grid grid-cols-2 gap-4">
{user.badges.map((badge, index) => (
<div key={index} className="flex items-center bg-white/5 rounded-xl p-3 border border-white/10">
<div
key={index}
className="flex items-center rounded-xl border border-white/10 bg-white/5 p-3"
>
<div className={`${badge.color} mr-3`}>
<badge.icon size={24} />
</div>
@ -134,65 +169,84 @@ export default async function ProfilePage() {
</div>
))}
<div className="flex items-center bg-white/5 rounded-xl p-3 border border-white/10 border-dashed">
<div className="text-indigo-300/50 mr-3">
<div className="flex items-center rounded-xl border border-dashed border-white/10 bg-white/5 p-3">
<div className="mr-3 text-indigo-300/50">
<Medal size={24} />
</div>
<div>
<p className="font-medium text-indigo-300/50">Locked Achievement</p>
<p className="font-medium text-indigo-300/50">
Locked Achievement
</p>
</div>
</div>
</div>
</div>
{/* Stats Overview */}
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-6 shadow-[0_0_15px_rgba(124,58,237,0.5)] border border-white/20">
<div className="flex items-center mb-4">
<BarChart3 className="text-blue-400 mr-2 h-6 w-6" />
<div className="rounded-2xl border border-white/20 bg-white/10 p-6 shadow-[0_0_15px_rgba(124,58,237,0.5)] backdrop-blur-md">
<div className="mb-4 flex items-center">
<BarChart3 className="mr-2 h-6 w-6 text-blue-400" />
<h2 className="text-xl font-bold text-white">Stats Overview</h2>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-white/5 rounded-xl p-4 border border-white/10">
<h3 className="text-indigo-200 text-sm mb-2">Top Categories</h3>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
<h3 className="mb-2 text-sm text-indigo-200">
Top Categories
</h3>
<div className="space-y-2">
<div>
<div className="flex justify-between text-xs mb-1">
<div className="mb-1 flex justify-between text-xs">
<span className="text-white">Science</span>
<span className="text-indigo-200">92%</span>
</div>
<div className="w-full h-2 bg-indigo-900/50 rounded-full overflow-hidden">
<div className="h-full bg-green-500 rounded-full" style={{ width: "92%" }}></div>
<div className="h-2 w-full overflow-hidden rounded-full bg-indigo-900/50">
<div
className="h-full rounded-full bg-green-500"
style={{ width: "92%" }}
></div>
</div>
</div>
<div>
<div className="flex justify-between text-xs mb-1">
<div className="mb-1 flex justify-between text-xs">
<span className="text-white">History</span>
<span className="text-indigo-200">78%</span>
</div>
<div className="w-full h-2 bg-indigo-900/50 rounded-full overflow-hidden">
<div className="h-full bg-blue-500 rounded-full" style={{ width: "78%" }}></div>
<div className="h-2 w-full overflow-hidden rounded-full bg-indigo-900/50">
<div
className="h-full rounded-full bg-blue-500"
style={{ width: "78%" }}
></div>
</div>
</div>
<div>
<div className="flex justify-between text-xs mb-1">
<div className="mb-1 flex justify-between text-xs">
<span className="text-white">Movies</span>
<span className="text-indigo-200">65%</span>
</div>
<div className="w-full h-2 bg-indigo-900/50 rounded-full overflow-hidden">
<div className="h-full bg-purple-500 rounded-full" style={{ width: "65%" }}></div>
<div className="h-2 w-full overflow-hidden rounded-full bg-indigo-900/50">
<div
className="h-full rounded-full bg-purple-500"
style={{ width: "65%" }}
></div>
</div>
</div>
</div>
</div>
<div className="bg-white/5 rounded-xl p-4 border border-white/10">
<h3 className="text-indigo-200 text-sm mb-2">Response Time</h3>
<div className="flex items-center justify-center h-24">
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
<h3 className="mb-2 text-sm text-indigo-200">
Response Time
</h3>
<div className="flex h-24 items-center justify-center">
<div className="text-center">
<p className="text-3xl font-bold text-white">3.2s</p>
<p className="text-xs text-indigo-200">Average response time</p>
<p className="text-xs text-green-400 mt-1">Faster than 75% of players</p>
<p className="text-xs text-indigo-200">
Average response time
</p>
<p className="mt-1 text-xs text-green-400">
Faster than 75% of players
</p>
</div>
</div>
</div>
@ -200,42 +254,52 @@ export default async function ProfilePage() {
</div>
{/* Recent Games */}
<div className="bg-white/10 backdrop-blur-md rounded-2xl p-6 shadow-[0_0_15px_rgba(124,58,237,0.5)] border border-white/20">
<div className="flex items-center mb-4">
<Clock className="text-green-400 mr-2 h-6 w-6" />
<div className="rounded-2xl border border-white/20 bg-white/10 p-6 shadow-[0_0_15px_rgba(124,58,237,0.5)] backdrop-blur-md">
<div className="mb-4 flex items-center">
<Clock className="mr-2 h-6 w-6 text-green-400" />
<h2 className="text-xl font-bold text-white">Recent Games</h2>
</div>
<div className="space-y-3">
{user.recentGames.map((game) => (
<div key={game.id} className="bg-white/5 rounded-xl p-4 border border-white/10 flex items-center">
<div className="flex-shrink-0 w-12 h-12 rounded-full bg-indigo-800/50 flex items-center justify-center mr-4">
<div
key={game.id}
className="flex items-center rounded-xl border border-white/10 bg-white/5 p-4"
>
<div className="mr-4 flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-indigo-800/50">
{game.position === "1st" ? (
<Trophy className="text-yellow-400 h-6 w-6" />
<Trophy className="h-6 w-6 text-yellow-400" />
) : game.position === "2nd" ? (
<Medal className="text-gray-300 h-6 w-6" />
<Medal className="h-6 w-6 text-gray-300" />
) : (
<Medal className="text-amber-700 h-6 w-6" />
<Medal className="h-6 w-6 text-amber-700" />
)}
</div>
<div className="flex-1">
<div className="flex justify-between">
<h3 className="font-medium text-white">{game.topic}</h3>
<span className="text-sm text-indigo-200">{game.date}</span>
<span className="text-sm text-indigo-200">
{game.date}
</span>
</div>
<div className="flex justify-between mt-1">
<div className="mt-1 flex justify-between">
<span className="text-sm text-indigo-300">
Position: <span className="text-white">{game.position}</span>
Position:{" "}
<span className="text-white">{game.position}</span>
</span>
<span className="text-sm text-indigo-300">
Score: <span className="text-white">{game.score}</span>
Score:{" "}
<span className="text-white">{game.score}</span>
</span>
</div>
</div>
</div>
))}
<Button variant="ghost" className="w-full text-indigo-200 hover:text-white hover:bg-white/10 mt-2">
<Button
variant="ghost"
className="mt-2 w-full text-indigo-200 hover:bg-white/10 hover:text-white"
>
View All Games
</Button>
</div>
@ -245,26 +309,34 @@ export default async function ProfilePage() {
</main>
{/* Footer */}
<footer className="relative z-10 w-full py-4 border-t border-white/10">
<div className="container mx-auto px-4 flex justify-between items-center">
<footer className="relative z-10 w-full border-t border-white/10 py-4">
<div className="container mx-auto flex items-center justify-between px-4">
<div className="flex items-center">
<Sparkles className="text-yellow-400 h-5 w-5 mr-2" />
<span className="text-indigo-200 text-sm">QUIZ MASTER</span>
<Sparkles className="mr-2 h-5 w-5 text-yellow-400" />
<span className="text-sm text-indigo-200">QUIZ MASTER</span>
</div>
<div className="flex space-x-4">
<Link href="#" className="text-xs text-indigo-200 hover:text-white transition-colors">
<Link
href="#"
className="text-xs text-indigo-200 transition-colors hover:text-white"
>
Help
</Link>
<Link href="#" className="text-xs text-indigo-200 hover:text-white transition-colors">
<Link
href="#"
className="text-xs text-indigo-200 transition-colors hover:text-white"
>
Privacy
</Link>
<Link href="#" className="text-xs text-indigo-200 hover:text-white transition-colors">
<Link
href="#"
className="text-xs text-indigo-200 transition-colors hover:text-white"
>
Terms
</Link>
</div>
</div>
</footer>
</div>
)
);
}

View File

@ -29,6 +29,7 @@ import { api } from "@/trpc/react";
import { useRouter } from "next/navigation";
function CreateLobbyDialog({ children }: { children: React.ReactNode }) {
const [open, setOpen] = React.useState(false);
const [loading, setLoading] = React.useState(false);
const { mutateAsync } = api.lobby.create.useMutation();
const router = useRouter();
@ -42,13 +43,14 @@ function CreateLobbyDialog({ children }: { children: React.ReactNode }) {
async function onSubmit(lobby: z.infer<typeof lobbyPatchSchema>) {
setLoading(true);
const result = await mutateAsync({ lobby });
setOpen(false);
if (result) router.push(`/lobby/${result.id}`);
else toast.error("Something went wrong.");
setLoading(false);
}
return (
<Dialog>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent>

View File

@ -0,0 +1,58 @@
"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 { getSocket } from "@/lib/hooks/use-socket";
import { LOBBY_USER_PRESENCE_EVENT } from "@/server/socket/event-const";
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 isJoined = members.find((member) => member.id === session?.user.id);
const isOwner = lobby.createdById === session?.user.id;
const socket = session ? getSocket() : undefined;
React.useEffect(() => {
if (!session || !isJoined || !socket) return;
socket.emit(LOBBY_USER_PRESENCE_EVENT, lobby.id, session.user.id, true);
return () => {
if (!session || !isJoined || !socket) return;
socket.emit(LOBBY_USER_PRESENCE_EVENT, lobby.id, session.user.id, false);
socket.disconnect();
};
}, [socket]);
return (
<div>
<h1 className="text-2xl font-bold capitalize">{lobby.name}</h1>
<ul>
{members?.map((member, idx) => (
<li key={idx}>
<UserCard name={member.name!} image={member.image!}>
{member?.leader && <label>Leader</label>}
</UserCard>
</li>
))}
</ul>
{isOwner && <DeleteLobbyDialog lobbyId={lobby.id} />}
{!isOwner && (
<LobbyMembershipDialog lobbyId={lobby.id} join={!isJoined} />
)}
</div>
);
}
export default LobbyPage;

View File

@ -0,0 +1,48 @@
import { Button, buttonVariants } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import Link from "next/link";
import React from "react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
import type { User } from "next-auth";
function UserPopover({
children,
user,
}: {
children: React.ReactNode;
user: User;
}) {
return (
<DropdownMenu>
<DropdownMenuTrigger>{children}</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel className="font-bold">{user.name}</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>
<Link href={`/profile/${user.id}`}>View Profile</Link>
</DropdownMenuItem>
<DropdownMenuItem>
<Link href={`/profile/${user.id}/edit`}>Edit Profile</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem className="bg-destructive text-destructive-foreground hover:bg-destructive/75 hover:text-destructive-foreground">
<Link href="/api/auth/signout">Log out</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
export default UserPopover;

46
src/components/icons.tsx Normal file
View File

@ -0,0 +1,46 @@
import type { LucideProps } from "lucide-react";
export type IconProps = LucideProps;
export const Icons = {
logo(props: IconProps) {
return (
<svg
width="43"
height="41"
viewBox="0 0 43 41"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M23.8287 8.94266C22.4425 8.94266 21.0699 9.21493 19.7892 9.74393C18.5086 10.2729 17.3449 11.0483 16.3648 12.0257C15.3846 13.0032 14.6071 14.1636 14.0766 15.4407C13.7048 16.3358 13.4595 17.276 13.346 18.2342C13.2568 18.9873 12.8442 19.6828 12.1656 20.0212L10.1584 21.022C9.47979 21.3604 9.03456 22.0559 9.12376 22.809C9.23726 23.7673 9.48259 24.7074 9.85438 25.6025C10.3849 26.8796 11.1624 28.04 12.1425 29.0175C13.1227 29.9949 14.2864 30.7703 15.567 31.2993C16.8477 31.8283 18.2203 32.1006 19.6064 32.1006C20.9926 32.1006 22.3652 31.8283 23.6459 31.2993C24.9265 30.7703 26.0902 29.9949 27.0704 29.0175C28.0505 28.04 28.828 26.8796 29.3585 25.6025C29.7303 24.7074 29.9756 23.7673 30.0891 22.809C30.1783 22.0559 30.5909 21.3604 31.2696 21.022L33.2767 20.0212C33.9553 19.6828 34.4006 18.9873 34.3114 18.2342C34.1979 17.276 33.9525 16.3358 33.5807 15.4407C33.0503 14.1636 32.2727 13.0032 31.2926 12.0257C30.3124 11.0483 29.1488 10.2729 27.8681 9.74393C26.5874 9.21493 25.2148 8.94266 23.8287 8.94266ZM28.7047 17.4549C28.8111 17.711 28.8968 17.9745 28.9612 18.2428C29.1384 18.9802 28.6775 19.6828 27.9989 20.0212L25.9918 21.022C25.3131 21.3604 24.9161 22.063 24.739 22.8004C24.6745 23.0688 24.5889 23.3322 24.4825 23.5884C24.2172 24.2269 23.8285 24.8071 23.3384 25.2959C22.8483 25.7846 22.2665 26.1723 21.6262 26.4368C20.9858 26.7013 20.2995 26.8374 19.6064 26.8374C18.9134 26.8374 18.2271 26.7013 17.5867 26.4368C16.9464 26.1723 16.3646 25.7846 15.8745 25.2959C15.3844 24.8071 14.9956 24.2269 14.7304 23.5884C14.624 23.3322 14.5384 23.0688 14.4739 22.8004C14.2968 22.063 14.7576 21.3604 15.4362 21.022L17.4433 20.0212C18.122 19.6828 18.519 18.9802 18.6961 18.2428C18.7606 17.9745 18.8463 17.711 18.9526 17.4549C19.2179 16.8163 19.6066 16.2361 20.0967 15.7474C20.5868 15.2586 21.1686 14.871 21.8089 14.6065C22.4493 14.342 23.1356 14.2058 23.8287 14.2058C24.5218 14.2058 25.2081 14.342 25.8484 14.6065C26.4887 14.871 27.0705 15.2586 27.5606 15.7474C28.0507 16.2361 28.4395 16.8163 28.7047 17.4549Z"
fill="currentColor"
/>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M23.8287 0.521606C21.3336 0.521606 18.8629 1.01169 16.5577 1.96389C14.2525 2.91608 12.158 4.31174 10.3936 6.07116C8.62933 7.83059 7.2298 9.91933 6.27496 12.2181C5.47865 14.1352 5.00464 16.1672 4.86918 18.2322C4.81955 18.989 4.39977 19.6828 3.72111 20.0212L1.714 21.022C1.03534 21.3604 0.597326 22.0543 0.646962 22.811C0.782417 24.8761 1.25643 26.908 2.05273 28.8251C3.00757 31.1239 4.4071 33.2126 6.17142 34.9721C7.93573 36.7315 10.0303 38.1271 12.3355 39.0793C14.6406 40.0315 17.1113 40.5216 19.6064 40.5216C22.1016 40.5216 24.5722 40.0315 26.8774 39.0793C29.1826 38.1271 31.2772 36.7315 33.0415 34.9721C34.8058 33.2126 36.2053 31.1239 37.1602 28.8251C37.9565 26.908 38.4305 24.8761 38.5659 22.811C38.6156 22.0543 39.0353 21.3604 39.714 21.022L41.7211 20.0212C42.3998 19.6828 42.8378 18.989 42.7882 18.2322C42.6527 16.1672 42.1787 14.1352 41.3824 12.2181C40.4275 9.91933 39.028 7.83059 37.2637 6.07116C35.4994 4.31174 33.4048 2.91608 31.0997 1.96389C28.7945 1.01169 26.3238 0.521606 23.8287 0.521606ZM33.2726 22.8102C33.3413 22.0549 33.7576 21.3604 34.4362 21.022L36.4433 20.0212C37.122 19.6828 37.5635 18.9883 37.4948 18.2331C37.3699 16.8597 37.0373 15.5106 36.5064 14.2323C35.8167 12.572 34.806 11.0635 33.5317 9.79278C32.2575 8.52208 30.7448 7.51411 29.0799 6.82641C27.4151 6.13872 25.6307 5.78476 23.8287 5.78476C22.0266 5.78476 20.2423 6.13872 18.5774 6.82641C16.9125 7.51411 15.3998 8.52208 14.1256 9.79278C12.8514 11.0635 11.8406 12.572 11.151 14.2323C10.62 15.5106 10.2874 16.8597 10.1625 18.2331C10.0938 18.9883 9.67755 19.6828 8.99889 20.0212L6.99178 21.022C6.31312 21.3604 5.87162 22.0549 5.9403 22.8102C6.0652 24.1835 6.39777 25.5326 6.92876 26.811C7.61837 28.4712 8.62914 29.9797 9.90337 31.2504C11.1776 32.5211 12.6903 33.5291 14.3552 34.2168C16.02 34.9045 17.8044 35.2584 19.6064 35.2584C21.4085 35.2584 23.1929 34.9045 24.8577 34.2168C26.5226 33.5291 28.0353 32.5211 29.3095 31.2504C30.5838 29.9797 31.5945 28.4712 32.2841 26.811C32.8151 25.5326 33.1477 24.1835 33.2726 22.8102Z"
fill="currentColor"
/>
<path
d="M42.8234 19.0401H37.5437C37.5414 19.3092 37.3834 19.5526 37.1408 19.6735L33.739 21.3697C33.4965 21.4906 33.3417 21.734 33.3227 22.0031H38.6023C38.619 21.7339 38.7743 21.4906 39.0168 21.3697L42.4186 19.6735C42.6611 19.5526 42.8187 19.3093 42.8234 19.0401Z"
fill="currentColor"
/>
<path
d="M0.612061 22.0031H5.89171C5.89403 21.734 6.05205 21.4906 6.29459 21.3697L9.6964 19.6735C9.93894 19.5526 10.0937 19.3092 10.1128 19.0401H4.83311C4.81639 19.3093 4.66115 19.5526 4.41862 19.6735L1.01681 21.3697C0.774281 21.4906 0.616707 21.7339 0.612061 22.0031Z"
fill="currentColor"
/>
<path
d="M13.2815 19.0401C13.2599 19.3092 13.1056 19.5526 12.8631 19.6735L9.46126 21.3697C9.2187 21.4906 9.06019 21.7341 9.0604 22.0031H14.347C14.3357 21.7346 14.4964 21.4907 14.739 21.3697L18.1408 19.6735C18.3835 19.5525 18.5358 19.3086 18.568 19.0401H13.2815Z"
fill="currentColor"
/>
<path
d="M29.0885 19.0401C29.0997 19.3086 28.9391 19.5525 28.6964 19.6735L25.2946 21.3697C25.0519 21.4907 24.8997 21.7346 24.8674 22.0031H30.154C30.1755 21.7341 30.3298 21.4906 30.5724 21.3697L33.9742 19.6735C34.2167 19.5526 34.3752 19.3092 34.375 19.0401H29.0885Z"
fill="currentColor"
/>
</svg>
);
},
};

View File

@ -0,0 +1,20 @@
"use client";
import React from "react";
import { Button } from "./ui/button";
import Link from "next/link";
import { Home } from "lucide-react";
import { usePathname } from "next/navigation";
function NavLink({ label, path }: NavLink) {
const active = usePathname() === path;
return (
<Button asChild variant={active ? "default" : "ghost"}>
<Link href={path}>
<span>{label}</span>
</Link>
</Button>
);
}
export default NavLink;

View File

@ -1,28 +0,0 @@
"use client";
import React from 'react'
import { Button } from './ui/button'
import Link from 'next/link'
import { Home } from 'lucide-react'
import { usePathname } from 'next/navigation';
function NavbarLinks() {
const pathname = usePathname()
return (
<><Button asChild {...(pathname === '/' ? { variant: 'rounded' } : { variant: 'ghost' })}>
<Link href="/">
<Home className="size-4" />
<span>Home</span>
</Link>
</Button>
<Button asChild {...(pathname === '/lobby' ? { variant: 'rounded' } : { variant: 'ghost' })}>
<Link href="/lobby">Lobbies</Link>
</Button>
<Button asChild {...(pathname === '/games' ? { variant: 'rounded' } : { variant: 'ghost' })}>
<Link href="/games">Games</Link>
</Button>
</>
)
}
export default NavbarLinks

View File

@ -4,22 +4,34 @@ import Avatar from "./avatar";
import { Button } from "./ui/button";
import Link from "next/link";
import { Home } from "lucide-react";
import NavbarLinks from "./navbar-links";
import UserPopover from "@/app/_components/user-popover";
import { appConfig } from "@/app.config";
import NavLink from "./nav-link";
async function Navbar() {
const session = await auth();
return (
<nav className="absolute top-0 left-0 flex h-max w-full items-center justify-center p-4">
<nav className="absolute top-0 left-0 z-50 flex h-max w-full items-center justify-center p-4">
<div className="bg-background flex w-max items-center gap-2 rounded-full border-2">
<NavbarLinks />
<menu className="flex items-center">
{appConfig.navigation.map((navLink, idx) => (
<li key={idx}>
<NavLink {...navLink} />
</li>
))}
</menu>
{/* Login Button or Profile Avatar */}
{session ? (
<Link href={`/profile/${session.user.id}`}>
<Avatar src={session.user.image!} fb={session.user.name!} />
</Link>
<UserPopover user={session.user}>
<Avatar
src={session.user.image!}
fb={session.user.name!}
className="cursor-pointer"
/>
</UserPopover>
) : (
<Button asChild variant={"rounded"} className="bg-red-800">
<Button asChild className="bg-red-800">
<Link href="/api/auth/signin">Sign In</Link>
</Button>
)}

View File

@ -5,14 +5,12 @@ import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex cursor-pointer items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"inline-flex cursor-pointer rounded-full items-center justify-center gap-2 whitespace-nowrap text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
rounded:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90 rounded-full",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:

View File

@ -0,0 +1,257 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
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",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
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",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
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 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View File

@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
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",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@ -25,6 +25,8 @@ export const env = createEnv({
* `NEXT_PUBLIC_`.
*/
client: {
NEXT_PUBLIC_SOCKET_URL: z.string().url(),
// NEXT_PUBLIC_CLIENTVAR: z.string(),
},
@ -37,6 +39,7 @@ export const env = createEnv({
AUTH_DISCORD_ID: process.env.AUTH_DISCORD_ID,
AUTH_DISCORD_SECRET: process.env.AUTH_DISCORD_SECRET,
DATABASE_URL: process.env.DATABASE_URL,
NEXT_PUBLIC_SOCKET_URL: process.env.NEXT_PUBLIC_SOCKET_URL,
NODE_ENV: process.env.NODE_ENV,
},
/**

4
src/index.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
type NavLink = {
label: string;
path: string;
};

View File

@ -0,0 +1,13 @@
"use client";
import { env } from "@/env";
import io, { Socket } from "socket.io-client";
let socket: Socket | null = null;
export const getSocket = () => {
if (!socket) {
socket = io(env.NEXT_PUBLIC_SOCKET_URL);
}
return socket;
};

View File

@ -0,0 +1,6 @@
export const JOIN_LOBBY_EVENT = "joinLobby";
export const LEAVE_LOBBY_EVENT = "leaveLobby";
export const LOBBY_USER_PRESENCE_EVENT = "userPresence";
// export const PLAYER_LOBBY_STATUS = "playerLobbyStatus";

View File

@ -0,0 +1,44 @@
import { type Server as SocketIOServer } from "socket.io";
import {
LOBBY_USER_PRESENCE_EVENT,
JOIN_LOBBY_EVENT,
LEAVE_LOBBY_EVENT,
} from "./event-const";
import { env } from "@/env";
const log = (message?: any, ...optionalParams: any[]) => {
if (env.NODE_ENV === "development") {
console.log(message, ...optionalParams);
}
};
export const initSocketEvents = (io: SocketIOServer) => {
io.on("connection", (socket) => {
log("New client connected:", socket.id);
socket.on(
LOBBY_USER_PRESENCE_EVENT,
(lobbyId: string, userId: string, present: boolean) => {
socket.join(lobbyId);
log(
`user ${userId} is (present: ${present}) in lobby room: ${lobbyId}`,
);
},
);
// Comments
// socket.on(DELETE_COMMENT_EVENT, (commentId: string, articleId: string) => {
// log(`Comment deleted: ${commentId} for article: ${articleId}`);
// io.to(articleId).emit(DELETE_COMMENT_EVENT, commentId);
// });
// socket.on(ADD_COMMENT_EVENT, (comment: Comment) => {
// log(`Comment added: ${comment.id} for article: ${comment.articleId}`);
// io.to(comment.articleId).emit(ADD_COMMENT_EVENT, comment);
// });
socket.on("disconnect", () => {
log("Client disconnected:", socket.id);
});
});
};

View File

@ -0,0 +1,37 @@
import "dotenv/config";
import express from "express";
import http from "http";
import { Server } from "socket.io";
import { initSocketEvents } from "./events";
import { env } from "@/env";
declare global {
var io: Server | undefined;
}
const createSocketServer = () => {
const app = express();
const server = http.createServer(app);
const PORT = 4000;
const origin = "http://localhost:" + PORT;
if (!global.io) {
global.io = new Server(server, {
cors: {
origin,
methods: ["GET", "POST"],
},
});
initSocketEvents(global.io);
server.listen(PORT, () => {
console.log(`Socket.IO server running on port ${PORT}`);
});
}
return global.io;
};
export const ioServer = createSocketServer();

View File

@ -20,6 +20,7 @@
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(1 0 0);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
@ -54,6 +55,7 @@
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.145 0 0);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
@ -92,6 +94,7 @@
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-destructive-foreground: var(--destructive-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
@ -117,4 +120,4 @@
body {
@apply bg-background text-foreground;
}
}
}