Modernize collaborative pixel art platform to production-ready architecture

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 1da96f34a6
69 changed files with 17771 additions and 1589 deletions

View file

@ -0,0 +1,276 @@
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { CANVAS_CONFIG, COLORS } from '@gaplace/shared';
interface PixelData {
x: number;
y: number;
color: string;
timestamp?: number;
userId?: string;
username?: string;
}
interface PixelInfo {
color: string;
userId?: string;
username?: string;
timestamp?: number;
}
interface Chunk {
chunkX: number;
chunkY: number;
pixels: Map<string, PixelInfo>; // key: "x,y", value: pixel info
isLoaded: boolean;
lastModified: number;
}
interface Viewport {
x: number;
y: number;
width: number;
height: number;
zoom: number;
}
interface CanvasState {
// Canvas data
canvasId: string;
chunks: Map<string, Chunk>; // key: "chunkX,chunkY"
// Viewport
viewport: Viewport;
// Tools
selectedColor: string;
selectedTool: 'pixel' | 'fill' | 'eyedropper';
brushSize: number;
// UI state
isLoading: boolean;
isPanning: boolean;
showGrid: boolean;
showCursors: boolean;
// User presence
activeUsers: string[];
userCursors: Map<string, { x: number; y: number; username: string; color: string }>;
// Stats
totalPixels: number;
userPixels: number;
// Actions
setCanvasId: (id: string) => void;
setPixel: (x: number, y: number, color: string, userId?: string, username?: string) => void;
loadChunk: (chunkX: number, chunkY: number, pixels: PixelData[]) => void;
setViewport: (viewport: Partial<Viewport>) => void;
setZoom: (zoom: number, centerX?: number, centerY?: number) => void;
pan: (deltaX: number, deltaY: number) => void;
setSelectedColor: (color: string) => void;
setSelectedTool: (tool: 'pixel' | 'fill' | 'eyedropper') => void;
setBrushSize: (size: number) => void;
setUserCursor: (userId: string, x: number, y: number, username: string, color: string) => void;
removeUserCursor: (userId: string) => void;
setActiveUsers: (users: string[]) => void;
setStats: (totalPixels: number, userPixels?: number) => void;
getPixelAt: (x: number, y: number) => string | null;
getPixelInfo: (x: number, y: number) => PixelInfo | null;
getChunkKey: (chunkX: number, chunkY: number) => string;
getPixelKey: (x: number, y: number) => string;
getChunkCoordinates: (x: number, y: number) => { chunkX: number; chunkY: number };
}
export const useCanvasStore = create<CanvasState>()(
devtools(
(set, get) => ({
// Initial state
canvasId: 'default',
chunks: new Map(),
viewport: {
x: 0,
y: 0,
width: 1920,
height: 1080,
zoom: 1,
},
selectedColor: COLORS.PALETTE[0],
selectedTool: 'pixel',
brushSize: 1,
isLoading: false,
isPanning: false,
showGrid: true,
showCursors: true,
activeUsers: [],
userCursors: new Map(),
totalPixels: 0,
userPixels: 0,
// Actions
setCanvasId: (id) => set({ canvasId: id }),
setPixel: (x, y, color, userId, username) => {
const state = get();
const { chunkX, chunkY } = state.getChunkCoordinates(x, y);
const chunkKey = state.getChunkKey(chunkX, chunkY);
const pixelKey = state.getPixelKey(
x % CANVAS_CONFIG.DEFAULT_CHUNK_SIZE,
y % CANVAS_CONFIG.DEFAULT_CHUNK_SIZE
);
const chunks = new Map(state.chunks);
let chunk = chunks.get(chunkKey);
if (!chunk) {
chunk = {
chunkX,
chunkY,
pixels: new Map(),
isLoaded: true,
lastModified: Date.now(),
};
}
const pixelInfo: PixelInfo = {
color,
userId,
username,
timestamp: Date.now()
};
chunk.pixels.set(pixelKey, pixelInfo);
chunk.lastModified = Date.now();
chunks.set(chunkKey, chunk);
set({ chunks });
},
loadChunk: (chunkX, chunkY, pixels) => {
const state = get();
const chunkKey = state.getChunkKey(chunkX, chunkY);
const chunks = new Map(state.chunks);
const pixelMap = new Map<string, PixelInfo>();
pixels.forEach((pixel) => {
const localX = pixel.x % CANVAS_CONFIG.DEFAULT_CHUNK_SIZE;
const localY = pixel.y % CANVAS_CONFIG.DEFAULT_CHUNK_SIZE;
const pixelKey = state.getPixelKey(localX, localY);
pixelMap.set(pixelKey, {
color: pixel.color,
userId: pixel.userId,
username: pixel.username,
timestamp: pixel.timestamp
});
});
const chunk: Chunk = {
chunkX,
chunkY,
pixels: pixelMap,
isLoaded: true,
lastModified: Date.now(),
};
chunks.set(chunkKey, chunk);
set({ chunks });
},
setViewport: (newViewport) => {
const state = get();
set({
viewport: { ...state.viewport, ...newViewport },
});
},
setZoom: (zoom, centerX, centerY) => {
const state = get();
const clampedZoom = Math.max(CANVAS_CONFIG.MIN_ZOOM, Math.min(CANVAS_CONFIG.MAX_ZOOM, zoom));
let newViewport = { ...state.viewport, zoom: clampedZoom };
// If center point is provided, zoom towards that point
if (centerX !== undefined && centerY !== undefined) {
const zoomFactor = clampedZoom / state.viewport.zoom;
newViewport.x = centerX - (centerX - state.viewport.x) * zoomFactor;
newViewport.y = centerY - (centerY - state.viewport.y) * zoomFactor;
}
set({ viewport: newViewport });
},
pan: (deltaX, deltaY) => {
const state = get();
set({
viewport: {
...state.viewport,
x: state.viewport.x + deltaX,
y: state.viewport.y + deltaY,
},
});
},
setSelectedColor: (color) => set({ selectedColor: color }),
setSelectedTool: (tool) => set({ selectedTool: tool }),
setBrushSize: (size) => set({ brushSize: Math.max(1, Math.min(10, size)) }),
setUserCursor: (userId, x, y, username, color) => {
const state = get();
const userCursors = new Map(state.userCursors);
userCursors.set(userId, { x, y, username, color });
set({ userCursors });
},
removeUserCursor: (userId) => {
const state = get();
const userCursors = new Map(state.userCursors);
userCursors.delete(userId);
set({ userCursors });
},
setActiveUsers: (users) => set({ activeUsers: users }),
setStats: (totalPixels, userPixels) => set({
totalPixels,
...(userPixels !== undefined && { userPixels })
}),
getPixelAt: (x, y) => {
const state = get();
const { chunkX, chunkY } = state.getChunkCoordinates(x, y);
const chunkKey = state.getChunkKey(chunkX, chunkY);
const chunk = state.chunks.get(chunkKey);
if (!chunk) return null;
const localX = x % CANVAS_CONFIG.DEFAULT_CHUNK_SIZE;
const localY = y % CANVAS_CONFIG.DEFAULT_CHUNK_SIZE;
const pixelKey = state.getPixelKey(localX, localY);
return chunk.pixels.get(pixelKey)?.color || null;
},
getPixelInfo: (x, y) => {
const state = get();
const { chunkX, chunkY } = state.getChunkCoordinates(x, y);
const chunkKey = state.getChunkKey(chunkX, chunkY);
const chunk = state.chunks.get(chunkKey);
if (!chunk) return null;
const localX = x % CANVAS_CONFIG.DEFAULT_CHUNK_SIZE;
const localY = y % CANVAS_CONFIG.DEFAULT_CHUNK_SIZE;
const pixelKey = state.getPixelKey(localX, localY);
return chunk.pixels.get(pixelKey) || null;
},
getChunkKey: (chunkX, chunkY) => `${chunkX},${chunkY}`,
getPixelKey: (x, y) => `${x},${y}`,
getChunkCoordinates: (x, y) => ({
chunkX: Math.floor(x / CANVAS_CONFIG.DEFAULT_CHUNK_SIZE),
chunkY: Math.floor(y / CANVAS_CONFIG.DEFAULT_CHUNK_SIZE),
}),
}),
{ name: 'canvas-store' }
)
);