Rewrite with modern stack

Major refactor from simple HTML/JS app to modern full-stack TypeScript application:

## Architecture Changes
- Migrated to monorepo structure with workspaces (backend, frontend, shared)
- Backend: Node.js + Express + TypeScript + Socket.IO
- Frontend: Next.js 15.5 + React 19 + TypeScript + Tailwind CSS
- Shared: Common types and utilities across packages

## Key Features Implemented
- Real-time WebSocket collaboration via Socket.IO
- Virtual canvas with chunked loading for performance
- Modern UI with dark mode and responsive design
- Mock database system for easy development (Redis/PostgreSQL compatible)
- Comprehensive error handling and rate limiting
- User presence and cursor tracking
- Infinite canvas support with zoom/pan controls

## Performance Optimizations
- Canvas virtualization - only renders visible viewport
- Chunked pixel data loading (64x64 pixel chunks)
- Optimized WebSocket protocol
- Memory-efficient state management with Zustand

## Development Experience
- Full TypeScript support across all packages
- Hot reload for both frontend and backend
- Docker support for production deployment
- Comprehensive linting and formatting
- Automated development server startup

## Fixed Issues
- Corrected start script paths
- Updated environment configuration
- Fixed ESLint configuration issues
- Ensured all dependencies are properly installed
- Verified build process works correctly

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
martin 2025-08-22 19:28:05 +02:00
commit 98f290a662
69 changed files with 17771 additions and 1589 deletions

View file

@ -0,0 +1,246 @@
'use client';
import { useEffect, useRef, useState, useCallback } from 'react';
import { io, Socket } from 'socket.io-client';
import {
WebSocketMessage,
MessageType,
PlacePixelMessage,
PixelPlacedMessage,
ChunkDataMessage,
LoadChunkMessage
} from '@gaplace/shared';
interface UseWebSocketProps {
canvasId: string;
userId?: string;
username?: string;
onPixelPlaced?: (message: PixelPlacedMessage & { username?: string }) => void;
onChunkData?: (message: ChunkDataMessage) => void;
onUserList?: (users: string[]) => void;
onCanvasStats?: (stats: { totalPixels?: number; activeUsers?: number; lastActivity?: number }) => void;
onCursorUpdate?: (data: { userId: string; username: string; x: number; y: number; tool: string }) => void;
}
export function useWebSocket({
canvasId,
userId,
username,
onPixelPlaced,
onChunkData,
onUserList,
onCanvasStats,
onCursorUpdate,
}: UseWebSocketProps) {
const [socket, setSocket] = useState<Socket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [connectionError, setConnectionError] = useState<string | null>(null);
const reconnectAttempts = useRef(0);
const maxReconnectAttempts = 5;
// Simple refs for callbacks to avoid dependency issues
const callbacksRef = useRef({
onPixelPlaced,
onChunkData,
onUserList,
onCanvasStats,
onCursorUpdate,
});
// Update refs on every render
callbacksRef.current = {
onPixelPlaced,
onChunkData,
onUserList,
onCanvasStats,
onCursorUpdate,
};
useEffect(() => {
const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:3001';
console.log('🔌 Initializing WebSocket connection to:', backendUrl);
const newSocket = io(backendUrl, {
transports: ['polling', 'websocket'], // Start with polling first for better compatibility
autoConnect: true,
reconnection: true,
reconnectionAttempts: maxReconnectAttempts,
reconnectionDelay: 1000,
timeout: 10000,
forceNew: true,
upgrade: true, // Allow upgrading from polling to websocket
});
// Add error handling for all Socket.IO events
newSocket.on('error', (error) => {
console.error('❌ Socket.IO error:', error);
setConnectionError(error.message || 'Unknown socket error');
});
// Connection event handlers
newSocket.on('connect', () => {
console.log('✅ Connected to WebSocket server');
console.log('Transport:', newSocket.io.engine.transport.name);
setIsConnected(true);
setConnectionError(null);
reconnectAttempts.current = 0;
// Authenticate after connecting
try {
newSocket.emit('auth', { userId, canvasId, username });
console.log('🔑 Authentication sent for user:', userId);
} catch (error) {
console.error('❌ Auth error:', error);
}
});
newSocket.on('disconnect', (reason) => {
console.log('❌ Disconnected from WebSocket server:', reason);
setIsConnected(false);
});
newSocket.on('connect_error', (error) => {
console.error('Connection error:', error);
console.error('Error details:', {
message: error.message,
description: (error as any).description,
context: (error as any).context,
type: (error as any).type
});
reconnectAttempts.current++;
if (reconnectAttempts.current >= maxReconnectAttempts) {
setConnectionError('Failed to connect to server. Please refresh the page.');
} else {
setConnectionError(`Connection attempt ${reconnectAttempts.current}/${maxReconnectAttempts}...`);
}
});
// Canvas event handlers with error wrapping
newSocket.on('pixel_placed', (message) => {
try {
callbacksRef.current.onPixelPlaced?.(message);
} catch (error) {
console.error('❌ Error in onPixelPlaced callback:', error);
}
});
newSocket.on('chunk_data', (message) => {
try {
callbacksRef.current.onChunkData?.(message);
} catch (error) {
console.error('❌ Error in onChunkData callback:', error);
}
});
newSocket.on('user_list', (users) => {
try {
callbacksRef.current.onUserList?.(users);
} catch (error) {
console.error('❌ Error in onUserList callback:', error);
}
});
newSocket.on('canvas_info', (stats) => {
try {
callbacksRef.current.onCanvasStats?.(stats);
} catch (error) {
console.error('❌ Error in onCanvasStats callback:', error);
}
});
newSocket.on('canvas_updated', (stats) => {
try {
callbacksRef.current.onCanvasStats?.(stats);
} catch (error) {
console.error('❌ Error in onCanvasStats callback:', error);
}
});
// Error handlers (removing duplicate error handler)
// Already handled above
newSocket.on('rate_limited', (data: { message: string; resetTime: number }) => {
console.warn('Rate limited:', data.message);
setConnectionError(`Rate limited. Try again in ${Math.ceil((data.resetTime - Date.now()) / 1000)} seconds.`);
});
newSocket.on('cursor_update', (data: { userId: string; username: string; x: number; y: number; tool: string }) => {
try {
callbacksRef.current.onCursorUpdate?.(data);
} catch (error) {
console.error('❌ Error in onCursorUpdate callback:', error);
}
});
setSocket(newSocket);
return () => {
newSocket.disconnect();
};
}, [canvasId, userId, username]);
const placePixel = (x: number, y: number, color: string) => {
if (!socket || !isConnected) {
console.warn('Cannot place pixel: not connected');
return;
}
try {
const message: PlacePixelMessage = {
type: MessageType.PLACE_PIXEL,
x,
y,
color,
canvasId,
timestamp: Date.now(),
};
socket.emit('place_pixel', message);
console.log('📍 Placed pixel at:', { x, y, color });
} catch (error) {
console.error('❌ Error placing pixel:', error);
}
};
const loadChunk = (chunkX: number, chunkY: number) => {
if (!socket || !isConnected) {
console.warn('Cannot load chunk: not connected');
return;
}
const message: LoadChunkMessage = {
type: MessageType.LOAD_CHUNK,
chunkX,
chunkY,
canvasId,
timestamp: Date.now(),
};
socket.emit('load_chunk', message);
};
const moveCursor = (x: number, y: number, tool: string) => {
if (!socket || !isConnected) {
return;
}
socket.emit('cursor_move', {
type: MessageType.CURSOR_MOVE,
x,
y,
tool,
canvasId,
timestamp: Date.now(),
});
};
return {
socket,
isConnected,
connectionError,
placePixel,
loadChunk,
moveCursor,
};
}