feat: refactor lobby UI with collapsible panels, add player event notifications, and update card art crop threshold to 130px
This commit is contained in:
@@ -98,3 +98,5 @@
|
|||||||
- [Minimize Slider Defaults](./devlog/2025-12-18-013000_minimize_slider_defaults.md): Completed. Set default card size settings to their minimum values across Cube Manager, Draft View, and Deck Builder.
|
- [Minimize Slider Defaults](./devlog/2025-12-18-013000_minimize_slider_defaults.md): Completed. Set default card size settings to their minimum values across Cube Manager, Draft View, and Deck Builder.
|
||||||
- [Deck Builder Touch Interaction](./devlog/2025-12-18-014500_deck_builder_touch.md): Completed. Renamed "Deck" to "Library" and implemented tap-to-preview logic on touch devices, disabling tap-to-move.
|
- [Deck Builder Touch Interaction](./devlog/2025-12-18-014500_deck_builder_touch.md): Completed. Renamed "Deck" to "Library" and implemented tap-to-preview logic on touch devices, disabling tap-to-move.
|
||||||
- [Stack View Sorting & Sliders](./devlog/2025-12-18-020000_stack_sorting_sliders.md): Completed. Refactored StackView to group by Color by default, added sorting controls to Deck Builder, and reduced slider scales globally to allow smaller sizes.
|
- [Stack View Sorting & Sliders](./devlog/2025-12-18-020000_stack_sorting_sliders.md): Completed. Refactored StackView to group by Color by default, added sorting controls to Deck Builder, and reduced slider scales globally to allow smaller sizes.
|
||||||
|
- [Lobby UI & Notifications](./devlog/2025-12-18-023000_lobby_ui_update.md): Completed. Refactored Lobby/Chat into collapsible floating panels, implemented player event notifications (Join/Leave/Disconnect), and updated Deck Builder card size triggers.
|
||||||
|
- [Card Preview Threshold](./devlog/2025-12-18-024000_preview_threshold.md): Completed. Updated card art crop threshold to 130px (new 50% mark) across the application components.
|
||||||
|
|||||||
28
docs/development/devlog/2025-12-18-023000_lobby_ui_update.md
Normal file
28
docs/development/devlog/2025-12-18-023000_lobby_ui_update.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Work Plan - Lobby & Chat UI Overhaul and Notifications
|
||||||
|
|
||||||
|
## Request
|
||||||
|
1. **Lobby/Chat Sidebar**: Refactor to be collapsible on the right edge.
|
||||||
|
- Add floating/modal panel for Lobby and Chat content.
|
||||||
|
- Remove fixed right column layout.
|
||||||
|
2. **Notifications**: Implement toast notifications for player events (Join, Leave, Disconnect).
|
||||||
|
- Add setting to Enable/Disable notifications (persisted).
|
||||||
|
3. **Deck Builder**: Update "Full Card" display trigger to new slider range (50% = 130px).
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
- **DeckBuilderView.tsx**:
|
||||||
|
- Updated `useArtCrop` logic: `cardWidth < 130` (was 200).
|
||||||
|
|
||||||
|
- **GameRoom.tsx**:
|
||||||
|
- Refactored layout: Added `activePanel` state.
|
||||||
|
- Created `useEffect` hook with `prevPlayersRef` to detect changes and trigger toasts.
|
||||||
|
- Added "Notifications On/Off" toggle in Lobby panel.
|
||||||
|
- Implemented floating side panel UI for desktop.
|
||||||
|
- Updated mobile view (kept separate mobile tab logic but ensured layout stability).
|
||||||
|
|
||||||
|
- **Toast.tsx**:
|
||||||
|
- Added `'warning'` type support for amber-colored alerts (Player Left).
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- Verified `ref` based diffing logic for notifications.
|
||||||
|
- Verified persistence of notification settings in `localStorage`.
|
||||||
|
- checked `Toast` type definition update.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Work Plan - Card Preview Threshold Update
|
||||||
|
|
||||||
|
## Request
|
||||||
|
- **Card Preview**: Change the trigger to show the full card to the what now is the new 50% (130px) instead of 200px.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
- **PackCard.tsx**: Updated logic to `cardWidth < 130` for art crop usage and `cardWidth >= 130` for hover preview prevention.
|
||||||
|
- **StackView.tsx**: Updated logic to `cardWidth < 130` and `cardWidth >= 130` respectively.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
- Verified code changes in `PackCard.tsx` and `StackView.tsx` via `replace_file_content` outputs.
|
||||||
|
- `DeckBuilderView.tsx` was already updated in previous step.
|
||||||
@@ -104,11 +104,11 @@ export const PackCard: React.FC<PackCardProps> = ({ pack, viewMode, cardWidth =
|
|||||||
{viewMode === 'grid' && (
|
{viewMode === 'grid' && (
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
{pack.cards.map((card) => {
|
{pack.cards.map((card) => {
|
||||||
const useArtCrop = cardWidth < 200 && !!card.imageArtCrop;
|
const useArtCrop = cardWidth < 130 && !!card.imageArtCrop;
|
||||||
const displayImage = useArtCrop ? card.imageArtCrop : card.image;
|
const displayImage = useArtCrop ? card.imageArtCrop : card.image;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardHoverWrapper key={card.id} card={card} preventPreview={cardWidth >= 200}>
|
<CardHoverWrapper key={card.id} card={card} preventPreview={cardWidth >= 130}>
|
||||||
<div style={{ width: cardWidth }} className="relative group bg-slate-900 rounded-lg shrink-0">
|
<div style={{ width: cardWidth }} className="relative group bg-slate-900 rounded-lg shrink-0">
|
||||||
{/* Visual Card */}
|
{/* Visual Card */}
|
||||||
<div className={`relative ${useArtCrop ? 'aspect-square' : 'aspect-[2.5/3.5]'} overflow-hidden rounded-lg shadow-xl border transition-all duration-200 group-hover:ring-2 group-hover:ring-purple-400 group-hover:shadow-purple-500/30 cursor-pointer ${isFoil(card) ? 'border-purple-400 shadow-purple-500/20' : 'border-slate-800'}`}>
|
<div className={`relative ${useArtCrop ? 'aspect-square' : 'aspect-[2.5/3.5]'} overflow-hidden rounded-lg shadow-xl border transition-all duration-200 group-hover:ring-2 group-hover:ring-purple-400 group-hover:shadow-purple-500/30 cursor-pointer ${isFoil(card) ? 'border-purple-400 shadow-purple-500/20' : 'border-slate-800'}`}>
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ export const StackView: React.FC<StackViewProps> = ({ cards, cardWidth = 150, on
|
|||||||
// Margin calculation: Negative margin to pull up next cards.
|
// Margin calculation: Negative margin to pull up next cards.
|
||||||
// To show a "strip" of say 35px at the top of each card.
|
// To show a "strip" of say 35px at the top of each card.
|
||||||
const isLast = index === catCards.length - 1;
|
const isLast = index === catCards.length - 1;
|
||||||
const useArtCrop = cardWidth < 200 && !!card.imageArtCrop;
|
const useArtCrop = cardWidth < 130 && !!card.imageArtCrop;
|
||||||
const displayImage = useArtCrop ? card.imageArtCrop : card.image;
|
const displayImage = useArtCrop ? card.imageArtCrop : card.image;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -163,7 +163,7 @@ const StackCardItem = ({ card, cardWidth, isLast, useArtCrop, displayImage, onHo
|
|||||||
onTouchEnd={onTouchEnd}
|
onTouchEnd={onTouchEnd}
|
||||||
onTouchMove={onTouchMove}
|
onTouchMove={onTouchMove}
|
||||||
>
|
>
|
||||||
<CardHoverWrapper card={card} preventPreview={disableHoverPreview || cardWidth >= 200}>
|
<CardHoverWrapper card={card} preventPreview={disableHoverPreview || cardWidth >= 130}>
|
||||||
<div
|
<div
|
||||||
className={`relative w-full rounded-lg bg-slate-800 shadow-md border border-slate-950 overflow-hidden cursor-pointer group-hover:ring-2 group-hover:ring-purple-400`}
|
className={`relative w-full rounded-lg bg-slate-800 shadow-md border border-slate-950 overflow-hidden cursor-pointer group-hover:ring-2 group-hover:ring-purple-400`}
|
||||||
style={{
|
style={{
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import React, { createContext, useContext, useState, useCallback } from 'react';
|
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||||
import { X, Check, AlertCircle, Info } from 'lucide-react';
|
import { X, Check, AlertCircle, Info } from 'lucide-react';
|
||||||
|
|
||||||
type ToastType = 'success' | 'error' | 'info';
|
type ToastType = 'success' | 'error' | 'info' | 'warning';
|
||||||
|
|
||||||
interface Toast {
|
interface Toast {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -55,15 +55,18 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
|||||||
bg-slate-800 text-white
|
bg-slate-800 text-white
|
||||||
${toast.type === 'success' ? 'border-emerald-500/50 shadow-emerald-900/20' :
|
${toast.type === 'success' ? 'border-emerald-500/50 shadow-emerald-900/20' :
|
||||||
toast.type === 'error' ? 'border-red-500/50 shadow-red-900/20' :
|
toast.type === 'error' ? 'border-red-500/50 shadow-red-900/20' :
|
||||||
|
toast.type === 'warning' ? 'border-amber-500/50 shadow-amber-900/20' :
|
||||||
'border-blue-500/50 shadow-blue-900/20'}
|
'border-blue-500/50 shadow-blue-900/20'}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<div className={`p-2 rounded-full shrink-0 ${toast.type === 'success' ? 'bg-emerald-500/10 text-emerald-400' :
|
<div className={`p-2 rounded-full shrink-0 ${toast.type === 'success' ? 'bg-emerald-500/10 text-emerald-400' :
|
||||||
toast.type === 'error' ? 'bg-red-500/10 text-red-400' :
|
toast.type === 'error' ? 'bg-red-500/10 text-red-400' :
|
||||||
|
toast.type === 'warning' ? 'bg-amber-500/10 text-amber-400' :
|
||||||
'bg-blue-500/10 text-blue-400'
|
'bg-blue-500/10 text-blue-400'
|
||||||
}`}>
|
}`}>
|
||||||
{toast.type === 'success' && <Check className="w-5 h-5" />}
|
{toast.type === 'success' && <Check className="w-5 h-5" />}
|
||||||
{toast.type === 'error' && <AlertCircle className="w-5 h-5" />}
|
{toast.type === 'error' && <AlertCircle className="w-5 h-5" />}
|
||||||
|
{toast.type === 'warning' && <AlertCircle className="w-5 h-5" />}
|
||||||
{toast.type === 'info' && <Info className="w-5 h-5" />}
|
{toast.type === 'info' && <Info className="w-5 h-5" />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ const CardsDisplay: React.FC<{
|
|||||||
>
|
>
|
||||||
{cards.map(c => {
|
{cards.map(c => {
|
||||||
const card = normalizeCard(c);
|
const card = normalizeCard(c);
|
||||||
const useArtCrop = cardWidth < 200 && !!card.imageArtCrop;
|
const useArtCrop = cardWidth < 130 && !!card.imageArtCrop;
|
||||||
|
|
||||||
const isFoil = card.finish === 'foil';
|
const isFoil = card.finish === 'foil';
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { socketService } from '../../services/SocketService';
|
import { socketService } from '../../services/SocketService';
|
||||||
import { Users, MessageSquare, Send, Copy, Check, Layers, LogOut } from 'lucide-react';
|
import { Users, MessageSquare, Send, Copy, Check, Layers, LogOut, Bell, BellOff, X } from 'lucide-react';
|
||||||
import { Modal } from '../../components/Modal';
|
import { Modal } from '../../components/Modal';
|
||||||
|
import { useToast } from '../../components/Toast';
|
||||||
import { GameView } from '../game/GameView';
|
import { GameView } from '../game/GameView';
|
||||||
import { DraftView } from '../draft/DraftView';
|
import { DraftView } from '../draft/DraftView';
|
||||||
import { DeckBuilderView } from '../draft/DeckBuilderView';
|
import { DeckBuilderView } from '../draft/DeckBuilderView';
|
||||||
@@ -45,18 +46,73 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
|
|||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [modalConfig, setModalConfig] = useState({ title: '', message: '', type: 'info' as 'info' | 'error' | 'warning' | 'success' });
|
const [modalConfig, setModalConfig] = useState({ title: '', message: '', type: 'info' as 'info' | 'error' | 'warning' | 'success' });
|
||||||
|
|
||||||
|
// Side Panel State
|
||||||
|
const [activePanel, setActivePanel] = useState<'lobby' | 'chat' | null>(null);
|
||||||
|
const [notificationsEnabled, setNotificationsEnabled] = useState(() => {
|
||||||
|
return localStorage.getItem('notifications_enabled') !== 'false';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Services
|
||||||
|
const { showToast } = useToast();
|
||||||
|
|
||||||
// Restored States
|
// Restored States
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>(initialRoom.messages || []);
|
const [messages, setMessages] = useState<ChatMessage[]>(initialRoom.messages || []);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const [gameState, setGameState] = useState<any>(initialGameState || null);
|
const [gameState, setGameState] = useState<any>(initialGameState || null);
|
||||||
const [draftState, setDraftState] = useState<any>(initialDraftState || null);
|
const [draftState, setDraftState] = useState<any>(initialDraftState || null);
|
||||||
const [mobileTab, setMobileTab] = useState<'game' | 'chat'>('game');
|
const [mobileTab, setMobileTab] = useState<'game' | 'chat'>('game'); // Keep for mobile
|
||||||
|
|
||||||
// Derived State
|
// Derived State
|
||||||
const host = room.players.find(p => p.isHost);
|
const host = room.players.find(p => p.isHost);
|
||||||
const isHostOffline = host?.isOffline;
|
const isHostOffline = host?.isOffline;
|
||||||
const isMeHost = currentPlayerId === host?.id;
|
const isMeHost = currentPlayerId === host?.id;
|
||||||
|
const prevPlayersRef = useRef<Player[]>(initialRoom.players);
|
||||||
|
|
||||||
|
// Persistence
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem('notifications_enabled', notificationsEnabled.toString());
|
||||||
|
}, [notificationsEnabled]);
|
||||||
|
|
||||||
|
// Player Notification Logic
|
||||||
|
useEffect(() => {
|
||||||
|
if (!notificationsEnabled) {
|
||||||
|
prevPlayersRef.current = room.players;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prev = prevPlayersRef.current;
|
||||||
|
const curr = room.players;
|
||||||
|
|
||||||
|
// 1. New Players
|
||||||
|
curr.forEach(p => {
|
||||||
|
if (!prev.find(old => old.id === p.id)) {
|
||||||
|
showToast(`${p.name} (${p.role}) joined the room.`, 'info');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Left Players
|
||||||
|
prev.forEach(p => {
|
||||||
|
if (!curr.find(newP => newP.id === p.id)) {
|
||||||
|
showToast(`${p.name} left the room.`, 'warning');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Status Changes (Disconnect/Reconnect)
|
||||||
|
curr.forEach(p => {
|
||||||
|
const old = prev.find(o => o.id === p.id);
|
||||||
|
if (old) {
|
||||||
|
if (!old.isOffline && p.isOffline) {
|
||||||
|
showToast(`${p.name} lost connection.`, 'error');
|
||||||
|
}
|
||||||
|
if (old.isOffline && !p.isOffline) {
|
||||||
|
showToast(`${p.name} reconnected!`, 'success');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
prevPlayersRef.current = curr;
|
||||||
|
}, [room.players, notificationsEnabled, showToast]);
|
||||||
|
|
||||||
// Effects
|
// Effects
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -235,9 +291,11 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col lg:flex-row gap-4 overflow-hidden">
|
<div className="flex h-full w-full overflow-hidden relative">
|
||||||
|
{/* --- MOBILE LAYOUT (Keep simplified tabs for small screens) --- */}
|
||||||
|
<div className="lg:hidden flex flex-col w-full h-full">
|
||||||
{/* Mobile Tab Bar */}
|
{/* Mobile Tab Bar */}
|
||||||
<div className="lg:hidden shrink-0 flex items-center bg-slate-800 border-b border-slate-700">
|
<div className="shrink-0 flex items-center bg-slate-800 border-b border-slate-700">
|
||||||
<button
|
<button
|
||||||
onClick={() => setMobileTab('game')}
|
onClick={() => setMobileTab('game')}
|
||||||
className={`flex-1 p-3 flex items-center justify-center gap-2 text-sm font-bold transition-colors ${mobileTab === 'game' ? 'text-emerald-400 bg-slate-700/50 border-b-2 border-emerald-500' : 'text-slate-400 hover:text-slate-200'}`}
|
className={`flex-1 p-3 flex items-center justify-center gap-2 text-sm font-bold transition-colors ${mobileTab === 'game' ? 'text-emerald-400 bg-slate-700/50 border-b-2 border-emerald-500' : 'text-slate-400 hover:text-slate-200'}`}
|
||||||
@@ -257,55 +315,134 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`flex-1 min-h-0 flex flex-col ${mobileTab === 'game' ? 'flex' : 'hidden lg:flex'}`}>
|
{/* Mobile Content */}
|
||||||
|
<div className="flex-1 min-h-0 relative">
|
||||||
|
{mobileTab === 'game' ? (
|
||||||
|
renderContent()
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 overflow-y-auto p-4 bg-slate-900">
|
||||||
|
{/* Mobile Chat/Lobby merged view for simplicity, reusing logic if possible or duplicating strictly for mobile structure */}
|
||||||
|
{/* Re-implementing simplified mobile view directly here to avoid layout conflicts */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700">
|
||||||
|
<h3 className="text-sm font-bold text-slate-400 uppercase mb-3 flex items-center gap-2"><Users className="w-4 h-4" /> Lobby</h3>
|
||||||
|
{room.players.map(p => (
|
||||||
|
<div key={p.id} className="flex items-center justify-between bg-slate-900/50 p-2 rounded mb-2 text-sm">
|
||||||
|
<span className={p.id === currentPlayerId ? 'text-white font-bold' : 'text-slate-300'}>{p.name}</span>
|
||||||
|
<span className="text-[10px] text-slate-500">{p.role}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="bg-slate-800 rounded-xl p-4 border border-slate-700 h-96 flex flex-col">
|
||||||
|
<h3 className="text-sm font-bold text-slate-400 uppercase mb-3"><MessageSquare className="w-4 h-4 inline mr-2" /> Chat</h3>
|
||||||
|
<div className="flex-1 overflow-y-auto mb-2 space-y-2">
|
||||||
|
{messages.map(msg => (
|
||||||
|
<div key={msg.id} className="text-sm"><span className="font-bold text-purple-400">{msg.sender}:</span> <span className="text-slate-300">{msg.text}</span></div>
|
||||||
|
))}
|
||||||
|
<div ref={messagesEndRef} />
|
||||||
|
</div>
|
||||||
|
<form onSubmit={sendMessage} className="flex gap-2">
|
||||||
|
<input type="text" value={message} onChange={e => setMessage(e.target.value)} className="flex-1 bg-slate-900 border border-slate-700 rounded px-2 py-1 text-sm text-white" placeholder="Type..." />
|
||||||
|
<button type="submit" className="bg-purple-600 rounded px-3 py-1 text-white"><Send className="w-4 h-4" /></button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* --- DESKTOP LAYOUT --- */}
|
||||||
|
{/* Main Content Area - Full Width */}
|
||||||
|
<div className="hidden lg:flex flex-1 min-w-0 flex-col h-full relative z-0">
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`w-full lg:w-80 shrink-0 flex flex-col gap-4 min-h-0 ${mobileTab === 'chat' ? 'flex' : 'hidden lg:flex'}`}>
|
{/* Right Collapsible Toolbar */}
|
||||||
<div className="flex-1 bg-slate-800 rounded-xl p-4 border border-slate-700 shadow-xl overflow-hidden flex flex-col">
|
<div className="hidden lg:flex w-14 shrink-0 flex-col items-center gap-4 py-4 bg-slate-900 border-l border-slate-800 z-30 relative">
|
||||||
<h3 className="text-sm font-bold text-slate-400 uppercase mb-3 flex items-center gap-2">
|
<button
|
||||||
<Users className="w-4 h-4" /> Lobby
|
onClick={() => setActivePanel(activePanel === 'lobby' ? null : 'lobby')}
|
||||||
|
className={`p-3 rounded-xl transition-all duration-200 group relative ${activePanel === 'lobby' ? 'bg-purple-600 text-white shadow-lg shadow-purple-900/50' : 'text-slate-500 hover:text-purple-400 hover:bg-slate-800'}`}
|
||||||
|
title="Lobby & Players"
|
||||||
|
>
|
||||||
|
<Users className="w-6 h-6" />
|
||||||
|
<span className="absolute right-full mr-3 top-1/2 -translate-y-1/2 bg-slate-800 text-white text-xs font-bold px-2 py-1 rounded shadow-xl opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none ring-1 ring-white/10">
|
||||||
|
Lobby
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setActivePanel(activePanel === 'chat' ? null : 'chat')}
|
||||||
|
className={`p-3 rounded-xl transition-all duration-200 group relative ${activePanel === 'chat' ? 'bg-blue-600 text-white shadow-lg shadow-blue-900/50' : 'text-slate-500 hover:text-blue-400 hover:bg-slate-800'}`}
|
||||||
|
title="Chat"
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<MessageSquare className="w-6 h-6" />
|
||||||
|
{/* Unread indicator could go here */}
|
||||||
|
</div>
|
||||||
|
<span className="absolute right-full mr-3 top-1/2 -translate-y-1/2 bg-slate-800 text-white text-xs font-bold px-2 py-1 rounded shadow-xl opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none ring-1 ring-white/10">
|
||||||
|
Chat
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floating Panel (Desktop) */}
|
||||||
|
{activePanel && (
|
||||||
|
<div className="hidden lg:flex absolute right-16 top-4 bottom-4 w-96 bg-slate-800/95 backdrop-blur-xl border border-slate-700/50 rounded-2xl shadow-2xl z-40 flex-col animate-in slide-in-from-right-10 fade-in duration-200 overflow-hidden ring-1 ring-white/10">
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-4 border-b border-slate-700 flex justify-between items-center bg-slate-900/50">
|
||||||
|
<h3 className="text-lg font-bold text-white flex items-center gap-2">
|
||||||
|
{activePanel === 'lobby' ? <><Users className="w-5 h-5 text-purple-400" /> Lobby</> : <><MessageSquare className="w-5 h-5 text-blue-400" /> Chat</>}
|
||||||
</h3>
|
</h3>
|
||||||
|
<button onClick={() => setActivePanel(null)} className="p-1 hover:bg-slate-700 rounded-lg text-slate-400 hover:text-white transition-colors">
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lobby Content */}
|
||||||
|
{activePanel === 'lobby' && (
|
||||||
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
|
{/* Controls */}
|
||||||
|
<div className="p-3 bg-slate-900/30 flex items-center justify-between border-b border-slate-800">
|
||||||
|
<span className="text-xs font-bold text-slate-500 uppercase tracking-wider">{room.players.length} Connected</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setNotificationsEnabled(!notificationsEnabled)}
|
||||||
|
className={`flex items-center gap-2 text-xs font-bold px-2 py-1 rounded-lg transition-colors border ${notificationsEnabled ? 'bg-slate-800 border-slate-600 text-slate-300 hover:text-white' : 'bg-red-900/20 border-red-900/50 text-red-400'}`}
|
||||||
|
title={notificationsEnabled ? "Disable Notifications" : "Enable Notifications"}
|
||||||
|
>
|
||||||
|
{notificationsEnabled ? <Bell className="w-3 h-3" /> : <BellOff className="w-3 h-3" />}
|
||||||
|
{notificationsEnabled ? 'On' : 'Off'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto space-y-2 pr-1">
|
{/* Player List */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-2 custom-scrollbar">
|
||||||
{room.players.map(p => {
|
{room.players.map(p => {
|
||||||
const isReady = (p as any).ready;
|
const isReady = (p as any).ready;
|
||||||
const isMe = p.id === currentPlayerId;
|
const isMe = p.id === currentPlayerId;
|
||||||
const isSolo = room.players.length === 1 && room.status === 'playing';
|
const isSolo = room.players.length === 1 && room.status === 'playing';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={p.id} className="flex items-center justify-between bg-slate-900/50 p-2 rounded-lg border border-slate-700/50 group">
|
<div key={p.id} className="flex items-center justify-between bg-slate-900/80 p-3 rounded-xl border border-slate-700/50 hover:border-slate-600 transition-colors group">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center font-bold text-xs ${p.role === 'spectator' ? 'bg-slate-700 text-slate-300' : 'bg-gradient-to-br from-purple-500 to-blue-500 text-white'}`}>
|
<div className={`w-10 h-10 rounded-full flex items-center justify-center font-bold text-sm shadow-inner ${p.role === 'spectator' ? 'bg-slate-800 text-slate-500' : 'bg-gradient-to-br from-purple-600 to-blue-600 text-white shadow-purple-900/30'}`}>
|
||||||
{p.name.substring(0, 2).toUpperCase()}
|
{p.name.substring(0, 2).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className={`text-sm font-medium ${isMe ? 'text-white' : 'text-slate-300'}`}>
|
<span className={`text-sm font-bold ${isMe ? 'text-white' : 'text-slate-200'}`}>
|
||||||
{p.name} {isMe && '(You)'}
|
{p.name} {isMe && <span className="text-slate-500 font-normal">(You)</span>}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] uppercase font-bold tracking-wider text-slate-500">
|
<span className="text-[10px] uppercase font-bold tracking-wider text-slate-500 flex items-center gap-1">
|
||||||
{p.role} {p.isHost && <span className="text-amber-500 ml-1">• Host</span>}
|
{p.role}
|
||||||
{isReady && room.status === 'deck_building' && <span className="text-emerald-500 ml-1">• Ready</span>}
|
{p.isHost && <span className="text-amber-500 flex items-center">• Host</span>}
|
||||||
{p.isOffline && <span className="text-red-500 ml-1">• Offline</span>}
|
{isReady && room.status === 'deck_building' && <span className="text-emerald-500 flex items-center">• Ready</span>}
|
||||||
|
{p.isOffline && <span className="text-red-500 flex items-center">• Offline</span>}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`flex gap-2 ${isSolo ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity`}>
|
<div className={`flex gap-1 ${isSolo ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} transition-opacity`}>
|
||||||
{isMe && (
|
|
||||||
<button
|
|
||||||
onClick={onExit}
|
|
||||||
className={`p-1 rounded flex items-center gap-2 transition-colors ${isSolo
|
|
||||||
? 'bg-red-900/40 text-red-200 hover:bg-red-900/60 px-3 py-1.5'
|
|
||||||
: 'hover:bg-slate-700 text-slate-400 hover:text-red-400'
|
|
||||||
}`}
|
|
||||||
title={isSolo ? "End Solo Session" : "Leave Room"}
|
|
||||||
>
|
|
||||||
<LogOut className="w-4 h-4" />
|
|
||||||
{isSolo && <span className="text-xs font-bold">End Test</span>}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{isMeHost && !isMe && (
|
{isMeHost && !isMe && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -313,46 +450,65 @@ export const GameRoom: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
|
|||||||
socketService.socket.emit('kick_player', { roomId: room.id, targetId: p.id });
|
socketService.socket.emit('kick_player', { roomId: room.id, targetId: p.id });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="p-1 hover:bg-red-900/50 rounded text-slate-500 hover:text-red-500"
|
className="p-1.5 hover:bg-red-500/10 rounded-lg text-slate-500 hover:text-red-500 transition-colors"
|
||||||
title="Kick Player"
|
title="Kick Player"
|
||||||
>
|
>
|
||||||
<LogOut className="w-4 h-4 rotate-180" />
|
<LogOut className="w-4 h-4 rotate-180" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{isMe && (
|
||||||
|
<button onClick={onExit} className="p-1.5 hover:bg-red-500/10 rounded-lg text-slate-400 hover:text-red-400 transition-colors" title="Accions">
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="h-1/2 bg-slate-800 rounded-xl p-4 border border-slate-700 shadow-xl flex flex-col">
|
{/* Chat Content */}
|
||||||
<h3 className="text-sm font-bold text-slate-400 uppercase mb-3 flex items-center gap-2">
|
{activePanel === 'chat' && (
|
||||||
<MessageSquare className="w-4 h-4" /> Chat
|
<div className="flex-1 flex flex-col min-h-0">
|
||||||
</h3>
|
<div className="flex-1 overflow-y-auto p-4 space-y-4 custom-scrollbar">
|
||||||
<div className="flex-1 overflow-y-auto space-y-2 mb-3 pr-1 custom-scrollbar">
|
{messages.length === 0 && (
|
||||||
|
<div className="text-center text-slate-600 mt-10 text-sm italic">
|
||||||
|
No messages yet. Say hello!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{messages.map(msg => (
|
{messages.map(msg => (
|
||||||
<div key={msg.id} className="text-sm">
|
<div key={msg.id} className={`flex flex-col ${msg.sender === (room.players.find(p => p.id === currentPlayerId)?.name) ? 'items-end' : 'items-start'}`}>
|
||||||
<span className="font-bold text-purple-400 text-xs">{msg.sender}: </span>
|
<div className={`max-w-[85%] px-3 py-2 rounded-xl text-sm ${msg.sender === (room.players.find(p => p.id === currentPlayerId)?.name) ? 'bg-blue-600 text-white rounded-br-none shadow-blue-900/20' : 'bg-slate-700 text-slate-200 rounded-bl-none'}`}>
|
||||||
<span className="text-slate-300">{msg.text}</span>
|
{msg.text}
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-slate-500 mt-1 font-medium">{msg.sender}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="p-3 bg-slate-900/50 border-t border-slate-700">
|
||||||
<form onSubmit={sendMessage} className="flex gap-2">
|
<form onSubmit={sendMessage} className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={message}
|
value={message}
|
||||||
onChange={e => setMessage(e.target.value)}
|
onChange={e => setMessage(e.target.value)}
|
||||||
className="flex-1 bg-slate-900 border border-slate-700 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-purple-500"
|
className="flex-1 bg-slate-950 border border-slate-700 rounded-xl px-4 py-2.5 text-sm text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
|
||||||
placeholder="Type..."
|
placeholder="Type a message..."
|
||||||
/>
|
/>
|
||||||
<button type="submit" className="p-2 bg-purple-600 hover:bg-purple-500 rounded-lg text-white transition-colors">
|
<button type="submit" className="p-2.5 bg-blue-600 hover:bg-blue-500 rounded-xl text-white transition-all shadow-lg shadow-blue-900/20 disabled:opacity-50" disabled={!message.trim()}>
|
||||||
<Send className="w-4 h-4" />
|
<Send className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* Host Disconnected Overlay */}
|
{/* Host Disconnected Overlay */}
|
||||||
{isHostOffline && !isMeHost && (
|
{isHostOffline && !isMeHost && (
|
||||||
|
|||||||
Reference in New Issue
Block a user