Skip to content

Commit 16177d2

Browse files
committed
Chat
1 parent 4bf7871 commit 16177d2

7 files changed

Lines changed: 309 additions & 70 deletions

File tree

artifacts/api-server/src/quiz-nav.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,27 @@ export function setupQuizNav(io: Server) {
108108
logger.info({}, "Quiz hostReturnToLogin — broadcast returnToLogin");
109109
});
110110

111+
socket.on("chatMessage", (payload: unknown) => {
112+
const po =
113+
payload && typeof payload === "object"
114+
? (payload as Record<string, unknown>)
115+
: {};
116+
const text =
117+
typeof po["text"] === "string" ? po["text"].trim().slice(0, 500) : "";
118+
const nick =
119+
typeof po["nick"] === "string"
120+
? po["nick"].trim().slice(0, 64)
121+
: "Аноним";
122+
const role = po["role"] === "host" ? "host" : "spectator";
123+
if (!text) return;
124+
ns.emit("chatMessage", {
125+
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
126+
nick,
127+
role,
128+
text,
129+
});
130+
});
131+
111132
socket.on("disconnect", () => {
112133
unbindQuizSocketPresence(socket.id);
113134
logger.info({ socketId: socket.id }, "Quiz nav client disconnected");

artifacts/game-client/src/apps/adepts-game-2/pages/Home.tsx

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { QuestionModal } from "@/lib/adepts-question-modal";
66
import { GamePhaseNav } from "@/components/GamePhaseArrows";
77
import { QuizBoardReloadButton } from "@/components/QuizBoardReloadButton";
88
import { useRole } from "@/hooks/useRole";
9+
import { ChatPanel } from "@/components/ChatPanel";
910

1011
function resolveUrl(url: string): string {
1112
if (!url) return url;
@@ -97,16 +98,20 @@ export default function Home() {
9798
</div>
9899
</header>
99100

100-
<main className="flex-1 min-h-0 py-3">
101-
<QuizBoard
102-
board={2}
103-
themes={state.themes}
104-
questions={state.questions}
105-
onUpdateTheme={updateThemeName}
106-
onQuestionClick={handleQuestionClick}
107-
readonly={!canOpenCards}
108-
/>
109-
</main>
101+
<div className="flex flex-1 min-h-0">
102+
<ChatPanel className="w-[15%] flex-shrink-0 m-2" />
103+
104+
<main className="flex-1 min-h-0 py-3">
105+
<QuizBoard
106+
board={2}
107+
themes={state.themes}
108+
questions={state.questions}
109+
onUpdateTheme={updateThemeName}
110+
onQuestionClick={handleQuestionClick}
111+
readonly={!canOpenCards}
112+
/>
113+
</main>
114+
</div>
110115

111116
<div className="flex-shrink-0 w-full">
112117
<Scoreboard

artifacts/game-client/src/apps/adepts-game-3/pages/Home.tsx

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { QuestionModal } from "@/lib/adepts-question-modal";
66
import { GamePhaseNav } from "@/components/GamePhaseArrows";
77
import { QuizBoardReloadButton } from "@/components/QuizBoardReloadButton";
88
import { useRole } from "@/hooks/useRole";
9+
import { ChatPanel } from "@/components/ChatPanel";
910

1011
function resolveUrl(url: string): string {
1112
if (!url) return url;
@@ -97,16 +98,20 @@ export default function Home() {
9798
</div>
9899
</header>
99100

100-
<main className="flex-1 min-h-0 py-3">
101-
<QuizBoard
102-
board={3}
103-
themes={state.themes}
104-
questions={state.questions}
105-
onUpdateTheme={updateThemeName}
106-
onQuestionClick={handleQuestionClick}
107-
readonly={!canOpenCards}
108-
/>
109-
</main>
101+
<div className="flex flex-1 min-h-0">
102+
<ChatPanel className="w-[15%] flex-shrink-0 m-2" />
103+
104+
<main className="flex-1 min-h-0 py-3">
105+
<QuizBoard
106+
board={3}
107+
themes={state.themes}
108+
questions={state.questions}
109+
onUpdateTheme={updateThemeName}
110+
onQuestionClick={handleQuestionClick}
111+
readonly={!canOpenCards}
112+
/>
113+
</main>
114+
</div>
110115

111116
<div className="flex-shrink-0 w-full">
112117
<Scoreboard

artifacts/game-client/src/apps/adepts-game/pages/Home.tsx

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { QuestionModal } from "@/lib/adepts-question-modal";
66
import { GamePhaseNav } from "@/components/GamePhaseArrows";
77
import { QuizBoardReloadButton } from "@/components/QuizBoardReloadButton";
88
import { useRole } from "@/hooks/useRole";
9+
import { ChatPanel } from "@/components/ChatPanel";
910

1011
function resolveUrl(url: string): string {
1112
if (!url) return url;
@@ -97,16 +98,20 @@ export default function Home() {
9798
</div>
9899
</header>
99100

100-
<main className="flex-1 min-h-0 py-3">
101-
<QuizBoard
102-
board={1}
103-
themes={state.themes}
104-
questions={state.questions}
105-
onUpdateTheme={updateThemeName}
106-
onQuestionClick={handleQuestionClick}
107-
readonly={!canOpenCards}
108-
/>
109-
</main>
101+
<div className="flex flex-1 min-h-0">
102+
<ChatPanel className="w-[15%] flex-shrink-0 m-2" />
103+
104+
<main className="flex-1 min-h-0 py-3">
105+
<QuizBoard
106+
board={1}
107+
themes={state.themes}
108+
questions={state.questions}
109+
onUpdateTheme={updateThemeName}
110+
onQuestionClick={handleQuestionClick}
111+
readonly={!canOpenCards}
112+
/>
113+
</main>
114+
</div>
110115

111116
<div className="flex-shrink-0 w-full">
112117
<Scoreboard
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { useRef, useEffect, useState, type KeyboardEvent } from "react";
2+
import { useChat } from "@/hooks/useChat";
3+
4+
const SPECTATOR_COLORS = [
5+
"#a78bfa",
6+
"#fb923c",
7+
"#34d399",
8+
"#f472b6",
9+
"#60a5fa",
10+
"#f87171",
11+
"#2dd4bf",
12+
"#c084fc",
13+
"#4ade80",
14+
"#e879f9",
15+
];
16+
17+
const HOST_COLOR = "#facc15";
18+
const HOST_FONT_SIZE = 15;
19+
const SPECTATOR_FONT_SIZE = 13;
20+
21+
const EMOJIS = [
22+
"🦝","😀","😅","😂","🤣","🥰","😘","🤩",
23+
"🥳","🤯","🥶","🤓","😎","😱","🤑","😻",
24+
"🙀","😽","👍","👎","🤟","👌","🫶","🖕",
25+
"🫵","🏆","🎁","🪙","💰","🪅","🎊","🎉",
26+
"❤️","💖","❤️‍🔥","🔥",
27+
];
28+
29+
function getNickColor(nick: string, role: "host" | "spectator"): string {
30+
if (role === "host") return HOST_COLOR;
31+
let hash = 0;
32+
for (let i = 0; i < nick.length; i++) {
33+
hash = ((hash << 5) - hash) + nick.charCodeAt(i);
34+
hash |= 0;
35+
}
36+
return SPECTATOR_COLORS[Math.abs(hash) % SPECTATOR_COLORS.length];
37+
}
38+
39+
interface ChatPanelProps {
40+
className?: string;
41+
}
42+
43+
export function ChatPanel({ className = "" }: ChatPanelProps) {
44+
const { messages, text, setText, sendMessage } = useChat();
45+
const bottomRef = useRef<HTMLDivElement>(null);
46+
const inputRef = useRef<HTMLInputElement>(null);
47+
const [pickerOpen, setPickerOpen] = useState(false);
48+
49+
useEffect(() => {
50+
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
51+
}, [messages]);
52+
53+
const handleSend = () => {
54+
sendMessage(text);
55+
setText("");
56+
};
57+
58+
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
59+
if (e.key === "Enter") {
60+
e.preventDefault();
61+
handleSend();
62+
}
63+
};
64+
65+
const insertEmoji = (emoji: string) => {
66+
const input = inputRef.current;
67+
if (!input) {
68+
setText((prev) => prev + emoji);
69+
return;
70+
}
71+
const start = input.selectionStart ?? text.length;
72+
const end = input.selectionEnd ?? text.length;
73+
const next = text.slice(0, start) + emoji + text.slice(end);
74+
setText(next);
75+
// restore cursor after the inserted emoji
76+
requestAnimationFrame(() => {
77+
const pos = start + emoji.length;
78+
input.setSelectionRange(pos, pos);
79+
input.focus();
80+
});
81+
};
82+
83+
return (
84+
<div
85+
className={`flex flex-col overflow-hidden rounded-xl border border-fuchsia-500/70 bg-card/80 shadow-[0_0_18px_hsla(300,90%,55%,0.28)] backdrop-blur-sm ${className}`}
86+
style={{ minWidth: 0 }}
87+
>
88+
<div className="flex-shrink-0 border-b border-fuchsia-500/30 px-4 py-2.5">
89+
<h2 className="font-display text-xs tracking-widest text-primary/70 uppercase">
90+
Чат зрителей
91+
</h2>
92+
</div>
93+
94+
<div className="flex-1 min-h-0 overflow-y-auto px-3 py-2 space-y-1.5 [scrollbar-width:thin] [scrollbar-color:hsla(300,90%,55%,0.5)_transparent] [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-fuchsia-500/50 [&::-webkit-scrollbar-thumb:hover]:bg-fuchsia-500/80">
95+
{messages.map((msg) => (
96+
<div
97+
key={msg.id}
98+
style={{ fontSize: msg.role === "host" ? HOST_FONT_SIZE : SPECTATOR_FONT_SIZE }}
99+
>
100+
<span style={{ color: getNickColor(msg.nick, msg.role), fontWeight: 600 }}>
101+
{msg.nick}:
102+
</span>{" "}
103+
<span style={{ color: "rgba(255,255,255,0.88)" }}>{msg.text}</span>
104+
</div>
105+
))}
106+
<div ref={bottomRef} />
107+
</div>
108+
109+
{/* Emoji picker */}
110+
{pickerOpen && (
111+
<div className="flex-shrink-0 border-t border-fuchsia-500/30 bg-card/90 p-1.5">
112+
<div className="grid grid-cols-6 gap-0.5">
113+
{EMOJIS.map((emoji) => (
114+
<button
115+
key={emoji}
116+
type="button"
117+
onClick={() => insertEmoji(emoji)}
118+
className="flex items-center justify-center rounded p-1 text-base leading-none transition hover:bg-fuchsia-500/20 active:scale-90"
119+
style={{ fontSize: 18 }}
120+
>
121+
{emoji}
122+
</button>
123+
))}
124+
</div>
125+
</div>
126+
)}
127+
128+
<div className="flex-shrink-0 flex items-center gap-1.5 border-t border-fuchsia-500/30 p-2">
129+
{/* Emoji toggle */}
130+
131+
<input
132+
ref={inputRef}
133+
type="text"
134+
value={text}
135+
onChange={(e) => setText(e.target.value)}
136+
onKeyDown={handleKeyDown}
137+
placeholder="Пиши сюдой..."
138+
className="flex-1 min-w-0 rounded border border-border/60 bg-background/50 px-2 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-primary/50 focus:outline-none"
139+
/>
140+
141+
<button
142+
type="button"
143+
onClick={() => setPickerOpen((o) => !o)}
144+
title="Смайлики"
145+
className={`flex-shrink-0 flex items-center justify-center rounded border px-2 py-1.5 text-base transition ${
146+
pickerOpen
147+
? "border-fuchsia-500/60 bg-fuchsia-500/20 text-fuchsia-300"
148+
: "border-border/50 bg-background/40 text-foreground/60 hover:border-fuchsia-500/50 hover:text-fuchsia-300"
149+
}`}
150+
style={{ fontSize: 16 }}
151+
>
152+
🙂
153+
</button>
154+
155+
<button
156+
type="button"
157+
onClick={handleSend}
158+
className="flex-shrink-0 rounded border border-primary/40 bg-primary/15 px-3 py-1.5 text-sm text-primary transition hover:bg-primary/25"
159+
>
160+
{'>'}
161+
</button>
162+
</div>
163+
</div>
164+
);
165+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { useState, useEffect, useCallback } from "react";
2+
import { getQuizNavSocket } from "./quizNavSocket";
3+
4+
export interface ChatMessage {
5+
id: string;
6+
nick: string;
7+
role: "host" | "spectator";
8+
text: string;
9+
}
10+
11+
export function useChat() {
12+
const [messages, setMessages] = useState<ChatMessage[]>([]);
13+
const [text, setText] = useState("");
14+
15+
useEffect(() => {
16+
const socket = getQuizNavSocket();
17+
18+
const onMessage = (msg: ChatMessage) => {
19+
setMessages((prev) => [...prev, msg]);
20+
};
21+
22+
socket.on("chatMessage", onMessage);
23+
return () => {
24+
socket.off("chatMessage", onMessage);
25+
};
26+
}, []);
27+
28+
const sendMessage = useCallback((msgText: string) => {
29+
const trimmed = msgText.trim();
30+
if (!trimmed) return;
31+
const nick = localStorage.getItem("player_nick") || "Аноним";
32+
const roleRaw = localStorage.getItem("player_role");
33+
const role = roleRaw === "host" ? "host" : "spectator";
34+
getQuizNavSocket().emit("chatMessage", { nick, role, text: trimmed });
35+
}, []);
36+
37+
return { messages, text, setText, sendMessage };
38+
}

0 commit comments

Comments
 (0)