feat: introduce game log panel and context, integrating it into the side panel and logging game events.
Some checks failed
Build and Deploy / build (push) Failing after 11m56s

This commit is contained in:
2025-12-23 01:09:05 +01:00
parent 9b25d3f0be
commit 23b8e3203d
5 changed files with 171 additions and 10 deletions

View File

@@ -0,0 +1,87 @@
import React, { useEffect, useRef } from 'react';
import { useGameLog, GameLogEntry } from '../contexts/GameLogContext';
import { ScrollText, User, Bot, Info, AlertTriangle, ShieldAlert } from 'lucide-react';
interface GameLogPanelProps {
className?: string;
maxHeight?: string;
}
export const GameLogPanel: React.FC<GameLogPanelProps> = ({ className, maxHeight = '200px' }) => {
const { logs } = useGameLog();
const bottomRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom on new logs
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [logs]);
const getIcon = (type: GameLogEntry['type'], source: string) => {
if (source === 'System') return <Info className="w-3 h-3 text-slate-500" />;
if (type === 'error') return <AlertTriangle className="w-3 h-3 text-red-500" />;
if (type === 'combat') return <ShieldAlert className="w-3 h-3 text-red-400" />;
if (source.includes('Bot')) return <Bot className="w-3 h-3 text-indigo-400" />;
return <User className="w-3 h-3 text-blue-400" />;
};
const getTypeStyle = (type: GameLogEntry['type']) => {
switch (type) {
case 'error': return 'text-red-400 bg-red-900/10 border-red-900/30';
case 'warning': return 'text-amber-400 bg-amber-900/10 border-amber-900/30';
case 'success': return 'text-emerald-400 bg-emerald-900/10 border-emerald-900/30';
case 'combat': return 'text-red-300 bg-red-900/20 border-red-900/40 font-bold';
case 'action': return 'text-blue-300 bg-blue-900/10 border-blue-900/30';
default: return 'text-slate-300 border-transparent';
}
};
return (
<div className={`flex flex-col bg-slate-900 border-t border-slate-800 ${className} overflow-hidden`} style={{ maxHeight }}>
<div className="flex items-center gap-2 px-3 py-1 bg-slate-950 border-b border-slate-800 shrink-0">
<ScrollText className="w-3 h-3 text-slate-500" />
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-500">Game Log</span>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-1 custom-scrollbar text-xs font-mono">
{logs.length === 0 && (
<div className="text-slate-600 italic px-2 py-4 text-center">
Game started. Actions will appear here.
</div>
)}
{logs.map((log) => (
<div
key={log.id}
className={`
relative pl-2 pr-2 py-1.5 rounded border-l-2
${getTypeStyle(log.type)}
animate-in fade-in slide-in-from-left-2 duration-300
`}
>
<div className="flex items-start gap-2">
<div className="mt-0.5 shrink-0 opacity-70">
{getIcon(log.type, log.source)}
</div>
<div className="flex flex-col min-w-0">
{/* Source Header */}
{log.source !== 'System' && (
<span className="text-[10px] font-bold opacity-70 mb-0.5 leading-none">
{log.source}
</span>
)}
{/* Message Body */}
<span className="leading-tight break-words">
{log.message}
</span>
</div>
<span className="ml-auto text-[9px] text-slate-600 whitespace-nowrap mt-0.5">
{new Date(log.timestamp).toLocaleTimeString([], { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
</div>
</div>
))}
<div ref={bottomRef} />
</div>
</div>
);
};

View File

@@ -1,8 +1,9 @@
import React from 'react';
import React, { forwardRef } from 'react';
import { CardVisual, VisualCard } from './CardVisual';
import { Eye, ChevronLeft } from 'lucide-react';
import { ManaIcon } from './ManaIcon';
import { formatOracleText } from '../utils/textUtils';
import { GameLogPanel } from './GameLogPanel';
interface SidePanelPreviewProps {
card: VisualCard | null;
@@ -10,23 +11,25 @@ interface SidePanelPreviewProps {
isCollapsed: boolean;
onToggleCollapse: (collapsed: boolean) => void;
onResizeStart?: (e: React.MouseEvent | React.TouchEvent) => void;
className?: string; // For additional styling (positioning, z-index, etc)
className?: string;
children?: React.ReactNode;
showLog?: boolean;
}
export const SidePanelPreview: React.FC<SidePanelPreviewProps> = ({
export const SidePanelPreview = forwardRef<HTMLDivElement, SidePanelPreviewProps>(({
card,
width,
isCollapsed,
onToggleCollapse,
onResizeStart,
className,
children
}) => {
children,
showLog = true,
}, ref) => {
// If collapsed, render the collapsed strip
if (isCollapsed) {
return (
<div className={`flex shrink-0 w-12 flex-col items-center py-4 bg-slate-900 border-r border-slate-800 z-30 gap-4 transition-all duration-300 ${className || ''}`}>
<div ref={ref} className={`flex shrink-0 w-12 flex-col items-center py-4 bg-slate-900 border-r border-slate-800 z-30 gap-4 transition-all duration-300 ${className || ''}`}>
<button
onClick={() => onToggleCollapse(false)}
className="p-3 rounded-xl transition-all duration-200 group relative text-slate-500 hover:text-purple-400 hover:bg-slate-800"
@@ -44,6 +47,7 @@ export const SidePanelPreview: React.FC<SidePanelPreviewProps> = ({
// Expanded View
return (
<div
ref={ref}
className={`flex shrink-0 flex-col items-center justify-start pt-4 border-r border-slate-800 bg-slate-900 z-30 p-4 relative group/sidebar shadow-2xl ${className || ''}`}
style={{ width: width }}
>
@@ -144,6 +148,14 @@ export const SidePanelPreview: React.FC<SidePanelPreviewProps> = ({
<div className="h-8 w-1 bg-slate-700/50 rounded-full group-hover:bg-emerald-400 transition-colors" />
</div>
)}
{/* Game Action Log - Fixed at bottom */}
{showLog && (
<GameLogPanel className="w-full shrink-0 border-t border-slate-800" maxHeight="30%" />
)}
</div>
);
};
});
SidePanelPreview.displayName = 'SidePanelPreview';

View File

@@ -0,0 +1,50 @@
import React, { createContext, useContext, useState, useCallback } from 'react';
export interface GameLogEntry {
id: string;
timestamp: number;
message: string;
source: 'System' | 'Player' | 'Opponent' | string;
type: 'info' | 'action' | 'combat' | 'error' | 'success' | 'warning';
}
interface GameLogContextType {
logs: GameLogEntry[];
addLog: (message: string, type?: GameLogEntry['type'], source?: string) => void;
clearLogs: () => void;
}
const GameLogContext = createContext<GameLogContextType | undefined>(undefined);
export const GameLogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [logs, setLogs] = useState<GameLogEntry[]>([]);
const addLog = useCallback((message: string, type: GameLogEntry['type'] = 'info', source: string = 'System') => {
const newLog: GameLogEntry = {
id: Math.random().toString(36).substr(2, 9),
timestamp: Date.now(),
message,
source,
type
};
setLogs(prev => [...prev, newLog]);
}, []);
const clearLogs = useCallback(() => {
setLogs([]);
}, []);
return (
<GameLogContext.Provider value={{ logs, addLog, clearLogs }}>
{children}
</GameLogContext.Provider>
);
};
export const useGameLog = () => {
const context = useContext(GameLogContext);
if (!context) {
throw new Error('useGameLog must be used within a GameLogProvider');
}
return context;
};

View File

@@ -608,11 +608,13 @@ export const GameView: React.FC<GameViewProps> = ({ gameState, currentPlayerId }
{/* Zoom Sidebar */}
<SidePanelPreview
ref={sidebarRef}
card={hoveredCard}
width={sidebarWidth}
isCollapsed={isSidebarCollapsed}
onToggleCollapse={setIsSidebarCollapsed}
onResizeStart={handleResizeStart}
showLog={true}
/>
{/* Main Game Area */}

View File

@@ -4,6 +4,7 @@ import { Users, LogOut, Copy, Check, MessageSquare, Send, Bell, BellOff, X, Bot,
import { useConfirm } from '../../components/ConfirmDialog';
import { Modal } from '../../components/Modal';
import { useGameToast, GameToastProvider } from '../../components/GameToast';
import { GameLogProvider, useGameLog } from '../../contexts/GameLogContext'; // Import Log Provider and Hook
import { GameView } from '../game/GameView';
import { DraftView } from '../draft/DraftView';
import { TournamentManager as TournamentView } from '../tournament/TournamentManager';
@@ -64,6 +65,7 @@ const GameRoomContent: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
// Services
const { showGameToast } = useGameToast();
const { addLog } = useGameLog(); // Use Log Hook
const { confirm } = useConfirm();
// Restored States
@@ -222,13 +224,19 @@ const GameRoomContent: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
// Only show error if it's for me, or maybe generic "Action Failed"
if (data.userId && data.userId !== currentPlayerId) return; // Don't spam others errors?
if (data.userId && data.userId !== currentPlayerId) return; // Don't spam others errors?
showGameToast(data.message, 'error');
addLog(data.message, 'error', 'System'); // Add to log
};
const handleGameNotification = (data: { message: string, type?: 'info' | 'success' | 'warning' | 'error' }) => {
showGameToast(data.message, data.type || 'info');
// Infer source from message content or default to System
// Ideally backend sends source, but for now we parse or default
let source = 'System';
if (data.message.includes('turn')) source = 'Game'; // Example heuristic
addLog(data.message, (data.type as any) || 'info', source);
};
socket.on('game_error', handleGameError);
@@ -675,7 +683,9 @@ const GameRoomContent: React.FC<GameRoomProps> = ({ room: initialRoom, currentPl
export const GameRoom: React.FC<GameRoomProps> = (props) => {
return (
<GameToastProvider>
<GameRoomContent {...props} />
<GameLogProvider>
<GameRoomContent {...props} />
</GameLogProvider>
</GameToastProvider>
);
};