feat: add real-time notification system and fix admin login bug
- Fix login: role field was missing from session (admin page inaccessible)
- Add onlineUsers map in socket/state.ts to track connected users
- Join personal user:${id} socket room on connect for targeted notifications
- Emit friendRequestReceived/friendRequestAccepted socket events from friends controller
- Add sendGameInvite socket event handler on server
- New NotificationContext: global socket provider for registered users
- New NotificationPanel: bell icon with unread badge and dropdown in header
- Friends page: inline game invite input per friend with sendGameInvite
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { API_URL } from "@/config";
|
||||
import { useNotifications } from "@/context/NotificationContext";
|
||||
import { useSession } from "@/context/session";
|
||||
|
||||
interface Friend {
|
||||
@@ -29,10 +30,13 @@ interface FriendRequest {
|
||||
export default function FriendsPage() {
|
||||
const { user } = useSession();
|
||||
const router = useRouter();
|
||||
const { sendGameInvite } = useNotifications();
|
||||
const [friends, setFriends] = useState<Friend[]>([]);
|
||||
const [requests, setRequests] = useState<{ incoming: FriendRequest[]; outgoing: FriendRequest[] }>({ incoming: [], outgoing: [] });
|
||||
const [addUsername, setAddUsername] = useState("");
|
||||
const [addMsg, setAddMsg] = useState("");
|
||||
const [inviteCodes, setInviteCodes] = useState<Record<number, string>>({});
|
||||
const [inviteSent, setInviteSent] = useState<Record<number, boolean>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (user === null) router.push("/");
|
||||
@@ -146,7 +150,8 @@ export default function FriendsPage() {
|
||||
<h2 className="text-lg font-semibold">Meine Freunde ({friends.length})</h2>
|
||||
{friends.length === 0 && <p className="opacity-60">Noch keine Freunde. Füge jemanden hinzu!</p>}
|
||||
{friends.map((f) => (
|
||||
<div key={f.id} className="flex items-center justify-between gap-2">
|
||||
<div key={f.id} className="flex flex-col gap-1 py-1 border-b border-base-300 last:border-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Link href={`/user/${f.friend_name}`} className="font-medium link link-hover">
|
||||
{f.friend_name}
|
||||
</Link>
|
||||
@@ -155,6 +160,28 @@ export default function FriendsPage() {
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Spielcode eingeben…"
|
||||
className="input input-bordered input-xs flex-1"
|
||||
value={inviteCodes[f.friend_id] ?? ""}
|
||||
onChange={(e) => setInviteCodes((prev) => ({ ...prev, [f.friend_id]: e.target.value }))}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-xs btn-secondary"
|
||||
disabled={!inviteCodes[f.friend_id]?.trim()}
|
||||
onClick={() => {
|
||||
sendGameInvite(f.friend_id, inviteCodes[f.friend_id].trim());
|
||||
setInviteSent((prev) => ({ ...prev, [f.friend_id]: true }));
|
||||
setInviteCodes((prev) => ({ ...prev, [f.friend_id]: "" }));
|
||||
setTimeout(() => setInviteSent((prev) => ({ ...prev, [f.friend_id]: false })), 3000);
|
||||
}}
|
||||
>
|
||||
{inviteSent[f.friend_id] ? "✓ Gesendet" : "Einladen"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { IconUser, IconShield } from "@tabler/icons-react";
|
||||
import Link from "next/link";
|
||||
import NotificationPanel from "./NotificationPanel";
|
||||
import ThemeToggle from "./ThemeToggle";
|
||||
import { useSession } from "@/context/session";
|
||||
|
||||
@@ -35,6 +36,7 @@ export default function Header() {
|
||||
Admin
|
||||
</Link>
|
||||
)}
|
||||
{user?.id && typeof user.id === "number" && <NotificationPanel />}
|
||||
<ThemeToggle />
|
||||
<label tabIndex={0} htmlFor="auth-modal" className="btn btn-ghost btn-circle avatar">
|
||||
<div className="w-10 rounded-full">
|
||||
|
||||
80
client/src/components/NotificationPanel.tsx
Normal file
80
client/src/components/NotificationPanel.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import type { AppNotification } from "@michess/types";
|
||||
import Link from "next/link";
|
||||
|
||||
import { useNotifications } from "@/context/NotificationContext";
|
||||
|
||||
function NotificationItem({ n, onDismiss }: { n: AppNotification; onDismiss: () => void }) {
|
||||
let text = "";
|
||||
if (n.type === "friendRequest") text = `${n.fromName} hat dir eine Freundschaftsanfrage gesendet.`;
|
||||
if (n.type === "friendAccepted") text = `${n.fromName} hat deine Freundschaftsanfrage angenommen.`;
|
||||
if (n.type === "gameInvite") text = `${n.fromName} lädt dich zu einem Spiel ein.`;
|
||||
|
||||
return (
|
||||
<div className={`flex items-start gap-2 p-2 rounded-lg ${!n.read ? "bg-base-300" : ""}`}>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm leading-snug">{text}</p>
|
||||
</div>
|
||||
{n.type === "gameInvite" && n.gameCode && (
|
||||
<Link href={`/${n.gameCode}`} className="btn btn-xs btn-primary shrink-0" onClick={onDismiss}>
|
||||
Beitreten
|
||||
</Link>
|
||||
)}
|
||||
{n.type === "friendRequest" && (
|
||||
<Link href="/friends" className="btn btn-xs btn-outline shrink-0" onClick={onDismiss}>
|
||||
Ansehen
|
||||
</Link>
|
||||
)}
|
||||
<button className="btn btn-xs btn-ghost shrink-0" onClick={onDismiss} aria-label="Schließen">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NotificationPanel() {
|
||||
const { notifications, unreadCount, markAllRead, dismiss } = useNotifications();
|
||||
|
||||
return (
|
||||
<div className="dropdown dropdown-end">
|
||||
<button
|
||||
tabIndex={0}
|
||||
className="btn btn-ghost btn-circle relative"
|
||||
onClick={markAllRead}
|
||||
aria-label="Benachrichtigungen"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-5 w-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
|
||||
</svg>
|
||||
{unreadCount > 0 && (
|
||||
<span className="badge badge-xs badge-primary absolute top-1 right-1">
|
||||
{unreadCount > 9 ? "9+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<div
|
||||
tabIndex={0}
|
||||
className="dropdown-content shadow bg-base-200 rounded-box w-80 max-h-96 overflow-y-auto z-50 p-2 flex flex-col gap-1 mt-1"
|
||||
>
|
||||
<p className="text-xs font-semibold opacity-60 px-1 pb-1">Benachrichtigungen</p>
|
||||
{notifications.length === 0 && (
|
||||
<p className="text-sm opacity-50 px-2 py-3 text-center">Keine Benachrichtigungen</p>
|
||||
)}
|
||||
{notifications.map((n) => (
|
||||
<NotificationItem key={n.id} n={n} onDismiss={() => dismiss(n.id)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { fetchSession } from "@/lib/auth";
|
||||
import NotificationProvider from "./NotificationContext";
|
||||
import { SessionContext } from "./session";
|
||||
|
||||
export default function ContextProvider({ children }: { children: ReactNode }) {
|
||||
@@ -19,5 +20,9 @@ export default function ContextProvider({ children }: { children: ReactNode }) {
|
||||
getSession();
|
||||
}, []);
|
||||
|
||||
return <SessionContext.Provider value={{ user, setUser }}>{children}</SessionContext.Provider>;
|
||||
return (
|
||||
<SessionContext.Provider value={{ user, setUser }}>
|
||||
<NotificationProvider>{children}</NotificationProvider>
|
||||
</SessionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
87
client/src/context/NotificationContext.tsx
Normal file
87
client/src/context/NotificationContext.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import type { AppNotification } from "@michess/types";
|
||||
import type { ReactNode } from "react";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
import { io, type Socket } from "socket.io-client";
|
||||
|
||||
import { API_URL } from "@/config";
|
||||
import { useSession } from "@/context/session";
|
||||
|
||||
interface NotificationContextValue {
|
||||
notifications: AppNotification[];
|
||||
unreadCount: number;
|
||||
markAllRead: () => void;
|
||||
dismiss: (id: string) => void;
|
||||
sendGameInvite: (toId: number, gameCode: string) => void;
|
||||
}
|
||||
|
||||
export const NotificationContext = createContext<NotificationContextValue | null>(null);
|
||||
|
||||
export const useNotifications = () => {
|
||||
const ctx = useContext(NotificationContext);
|
||||
if (!ctx) throw new Error("useNotifications must be used within NotificationProvider");
|
||||
return ctx;
|
||||
};
|
||||
|
||||
const notifSocket: Socket = io(API_URL, { withCredentials: true, autoConnect: false });
|
||||
|
||||
export default function NotificationProvider({ children }: { children: ReactNode }) {
|
||||
const { user } = useSession();
|
||||
const [notifications, setNotifications] = useState<AppNotification[]>([]);
|
||||
|
||||
const unreadCount = notifications.filter((n) => !n.read).length;
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.id || typeof user.id === "string") {
|
||||
if (notifSocket.connected) notifSocket.disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
notifSocket.connect();
|
||||
|
||||
notifSocket.on("friendRequestReceived", ({ fromId, fromName }: { fromId: number; fromName: string }) => {
|
||||
setNotifications((prev) => [
|
||||
{ id: crypto.randomUUID(), type: "friendRequest", fromId, fromName, createdAt: Date.now(), read: false },
|
||||
...prev
|
||||
]);
|
||||
});
|
||||
|
||||
notifSocket.on("friendRequestAccepted", ({ fromId, fromName }: { fromId: number; fromName: string }) => {
|
||||
setNotifications((prev) => [
|
||||
{ id: crypto.randomUUID(), type: "friendAccepted", fromId, fromName, createdAt: Date.now(), read: false },
|
||||
...prev
|
||||
]);
|
||||
});
|
||||
|
||||
notifSocket.on("gameInviteReceived", ({ fromId, fromName, gameCode }: { fromId: number; fromName: string; gameCode: string }) => {
|
||||
setNotifications((prev) => [
|
||||
{ id: crypto.randomUUID(), type: "gameInvite", fromId, fromName, gameCode, createdAt: Date.now(), read: false },
|
||||
...prev
|
||||
]);
|
||||
});
|
||||
|
||||
return () => {
|
||||
notifSocket.off("friendRequestReceived");
|
||||
notifSocket.off("friendRequestAccepted");
|
||||
notifSocket.off("gameInviteReceived");
|
||||
notifSocket.disconnect();
|
||||
};
|
||||
}, [user?.id]);
|
||||
|
||||
const markAllRead = () => setNotifications((prev) => prev.map((n) => ({ ...n, read: true })));
|
||||
|
||||
const dismiss = (id: string) => setNotifications((prev) => prev.filter((n) => n.id !== id));
|
||||
|
||||
const sendGameInvite = (toId: number, gameCode: string) => {
|
||||
if (notifSocket.connected) {
|
||||
notifSocket.emit("sendGameInvite", { toId, gameCode });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<NotificationContext.Provider value={{ notifications, unreadCount, markAllRead, dismiss, sendGameInvite }}>
|
||||
{children}
|
||||
</NotificationContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -222,7 +222,8 @@ export const loginUser = async (req: Request, res: Response) => {
|
||||
email: users[0].email,
|
||||
wins: users[0].wins,
|
||||
losses: users[0].losses,
|
||||
draws: users[0].draws
|
||||
draws: users[0].draws,
|
||||
role: users[0].role
|
||||
};
|
||||
req.session.save(() => {
|
||||
res.status(200).json(req.session.user);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Request, Response } from "express";
|
||||
import { db } from "../db/index.js";
|
||||
import UserModel from "../db/models/user.model.js";
|
||||
import { io } from "../server.js";
|
||||
import { onlineUsers } from "../socket/state.js";
|
||||
|
||||
export const sendRequest = async (req: Request, res: Response) => {
|
||||
try {
|
||||
@@ -49,6 +51,15 @@ export const sendRequest = async (req: Request, res: Response) => {
|
||||
[fromId, toId]
|
||||
);
|
||||
|
||||
// Notify recipient if online
|
||||
const fromUser = req.session.user!;
|
||||
if (onlineUsers.has(toId)) {
|
||||
io.to(`user:${toId}`).emit("friendRequestReceived", {
|
||||
fromId: fromUser.id,
|
||||
fromName: fromUser.name
|
||||
});
|
||||
}
|
||||
|
||||
res.status(201).json({ id: result.rows[0].id, toName: targets[0].name });
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
@@ -124,6 +135,13 @@ export const respondToRequest = async (req: Request, res: Response) => {
|
||||
`INSERT INTO "friendship"(user_id_1, user_id_2) VALUES($1, $2) ON CONFLICT DO NOTHING`,
|
||||
[a, b]
|
||||
);
|
||||
// Notify original sender that request was accepted
|
||||
if (onlineUsers.has(from_id)) {
|
||||
io.to(`user:${from_id}`).emit("friendRequestAccepted", {
|
||||
fromId: userId,
|
||||
fromName: req.session.user!.name
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).json({ message: action === "accept" ? "Friend added!" : "Request rejected." });
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
leaveLobby,
|
||||
sendMove
|
||||
} from "./game.socket.js";
|
||||
import { onlineUsers } from "./state.js";
|
||||
|
||||
const socketConnect = (socket: Socket) => {
|
||||
const req = socket.request;
|
||||
@@ -25,7 +26,28 @@ const socketConnect = (socket: Socket) => {
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("disconnect", leaveLobby);
|
||||
// Track online users and join personal notification room
|
||||
const userId = req.session.user?.id;
|
||||
if (userId && typeof userId === "number") {
|
||||
onlineUsers.set(userId, socket.id);
|
||||
socket.join(`user:${userId}`);
|
||||
}
|
||||
|
||||
socket.on("disconnect", () => {
|
||||
if (userId && typeof userId === "number") onlineUsers.delete(userId);
|
||||
leaveLobby.call(socket);
|
||||
});
|
||||
|
||||
// Forward game invites to target user's personal room
|
||||
socket.on("sendGameInvite", ({ toId, gameCode }: { toId: number; gameCode: string }) => {
|
||||
const fromUser = req.session.user;
|
||||
if (!fromUser?.id || typeof fromUser.id === "string") return;
|
||||
io.to(`user:${toId}`).emit("gameInviteReceived", {
|
||||
fromId: fromUser.id,
|
||||
fromName: fromUser.name,
|
||||
gameCode
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("joinLobby", joinLobby);
|
||||
socket.on("leaveLobby", leaveLobby);
|
||||
|
||||
2
server/src/socket/state.ts
Normal file
2
server/src/socket/state.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Shared socket state — imported by socket/index.ts and controllers to avoid circular deps
|
||||
export const onlineUsers = new Map<number, string>();
|
||||
12
types/index.d.ts
vendored
12
types/index.d.ts
vendored
@@ -93,6 +93,18 @@ export interface TournamentPlayer {
|
||||
gamesPlayed?: number;
|
||||
}
|
||||
|
||||
export type NotificationType = "friendRequest" | "friendAccepted" | "gameInvite";
|
||||
|
||||
export interface AppNotification {
|
||||
id: string;
|
||||
type: NotificationType;
|
||||
fromId: number;
|
||||
fromName: string;
|
||||
gameCode?: string;
|
||||
createdAt: number;
|
||||
read: boolean;
|
||||
}
|
||||
|
||||
export interface TournamentRound {
|
||||
id?: number;
|
||||
tournamentId?: number;
|
||||
|
||||
Reference in New Issue
Block a user