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:
parent
ee5b0bee92
commit
1da96f34a6
69 changed files with 17771 additions and 1589 deletions
294
frontend/src/app/page.tsx
Normal file
294
frontend/src/app/page.tsx
Normal file
|
|
@ -0,0 +1,294 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { VirtualCanvas } from '../components/canvas/VirtualCanvas';
|
||||
import { CooldownTimer } from '../components/ui/CooldownTimer';
|
||||
import { PixelConfirmModal } from '../components/ui/PixelConfirmModal';
|
||||
import { StatsOverlay } from '../components/ui/StatsOverlay';
|
||||
import { CoordinateDisplay } from '../components/ui/CoordinateDisplay';
|
||||
import { UsernameModal } from '../components/ui/UsernameModal';
|
||||
import { SettingsButton } from '../components/ui/SettingsButton';
|
||||
import { ZoomControls } from '../components/ui/ZoomControls';
|
||||
import { useWebSocket } from '../hooks/useWebSocket';
|
||||
import { useCanvasStore } from '../store/canvasStore';
|
||||
import { ErrorBoundary } from '../components/ErrorBoundary';
|
||||
import type { PixelPlacedMessage, ChunkDataMessage } from '@gaplace/shared';
|
||||
|
||||
export default function HomePage() {
|
||||
const [selectedColor, setSelectedColor] = useState('#FF0000');
|
||||
const [pendingPixel, setPendingPixel] = useState<{ x: number; y: number } | null>(null);
|
||||
const [isCooldownActive, setIsCooldownActive] = useState(false);
|
||||
const [onlineUsers, setOnlineUsers] = useState(1);
|
||||
const [totalPixels, setTotalPixels] = useState(0);
|
||||
const [hoverCoords, setHoverCoords] = useState<{ x: number; y: number; pixelInfo: { color: string; userId?: string; username?: string } | null } | null>(null);
|
||||
const [username, setUsername] = useState('');
|
||||
const [showUsernameModal, setShowUsernameModal] = useState(false);
|
||||
|
||||
// Generate userId once and keep it stable, store in localStorage
|
||||
const [userId] = useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
let storedUserId = localStorage.getItem('gaplace-user-id');
|
||||
if (!storedUserId) {
|
||||
storedUserId = 'guest-' + Math.random().toString(36).substr(2, 9);
|
||||
localStorage.setItem('gaplace-user-id', storedUserId);
|
||||
}
|
||||
return storedUserId;
|
||||
}
|
||||
return 'guest-' + Math.random().toString(36).substr(2, 9);
|
||||
});
|
||||
|
||||
// Load username from localStorage and show modal if none exists
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
const storedUsername = localStorage.getItem('gaplace-username');
|
||||
if (storedUsername) {
|
||||
setUsername(storedUsername);
|
||||
} else {
|
||||
setShowUsernameModal(true);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Canvas store
|
||||
const { setPixel, loadChunk: loadChunkToStore, viewport, setZoom, setViewport } = useCanvasStore();
|
||||
|
||||
const handlePixelPlaced = useCallback((message: PixelPlacedMessage & { username?: string }) => {
|
||||
console.log('Pixel placed:', message);
|
||||
setPixel(message.x, message.y, message.color, message.userId, message.username);
|
||||
}, [setPixel]);
|
||||
|
||||
const handleChunkData = useCallback((message: ChunkDataMessage) => {
|
||||
console.log('Chunk data received:', message);
|
||||
loadChunkToStore(message.chunkX, message.chunkY, message.pixels);
|
||||
}, [loadChunkToStore]);
|
||||
|
||||
const handleUserList = useCallback((users: string[]) => {
|
||||
setOnlineUsers(users.length);
|
||||
}, []);
|
||||
|
||||
const handleCanvasStats = useCallback((stats: { totalPixels?: number; activeUsers?: number; lastActivity?: number }) => {
|
||||
setTotalPixels(stats.totalPixels || 0);
|
||||
}, []);
|
||||
|
||||
const { isConnected, placePixel, loadChunk, moveCursor } = useWebSocket({
|
||||
canvasId: 'main',
|
||||
userId,
|
||||
username,
|
||||
onPixelPlaced: handlePixelPlaced,
|
||||
onChunkData: handleChunkData,
|
||||
onUserList: handleUserList,
|
||||
onCanvasStats: handleCanvasStats,
|
||||
});
|
||||
|
||||
const handlePixelClick = useCallback((x: number, y: number) => {
|
||||
if (isCooldownActive) return;
|
||||
setPendingPixel({ x, y });
|
||||
}, [isCooldownActive]);
|
||||
|
||||
const handleConfirmPixel = useCallback(() => {
|
||||
if (!pendingPixel) return;
|
||||
|
||||
// Immediately place pixel locally for instant feedback
|
||||
setPixel(pendingPixel.x, pendingPixel.y, selectedColor, userId, username);
|
||||
|
||||
// Send to server
|
||||
placePixel(pendingPixel.x, pendingPixel.y, selectedColor);
|
||||
setPendingPixel(null);
|
||||
setIsCooldownActive(true);
|
||||
}, [pendingPixel, selectedColor, placePixel, setPixel, userId, username]);
|
||||
|
||||
const handleCancelPixel = useCallback(() => {
|
||||
setPendingPixel(null);
|
||||
}, []);
|
||||
|
||||
const handleCooldownComplete = useCallback(() => {
|
||||
setIsCooldownActive(false);
|
||||
}, []);
|
||||
|
||||
const handleCursorMove = useCallback((x: number, y: number) => {
|
||||
moveCursor(x, y, 'pixel');
|
||||
}, [moveCursor]);
|
||||
|
||||
const handleChunkNeeded = useCallback((chunkX: number, chunkY: number) => {
|
||||
loadChunk(chunkX, chunkY);
|
||||
}, [loadChunk]);
|
||||
|
||||
const handleHoverChange = useCallback((x: number, y: number, pixelInfo: { color: string; userId?: string } | null) => {
|
||||
setHoverCoords({ x, y, pixelInfo });
|
||||
}, []);
|
||||
|
||||
const handleUsernameChange = useCallback((newUsername: string) => {
|
||||
setUsername(newUsername);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('gaplace-username', newUsername);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleZoomIn = useCallback(() => {
|
||||
const newZoom = Math.min(viewport.zoom * 1.2, 5.0);
|
||||
|
||||
// Zoom towards center of screen
|
||||
if (typeof window !== 'undefined') {
|
||||
const centerX = window.innerWidth / 2;
|
||||
const centerY = window.innerHeight / 2;
|
||||
|
||||
// Calculate world position at center of screen
|
||||
const pixelSize = 32 * viewport.zoom; // BASE_PIXEL_SIZE * current zoom
|
||||
const worldX = (centerX + viewport.x) / pixelSize;
|
||||
const worldY = (centerY + viewport.y) / pixelSize;
|
||||
|
||||
// Calculate new viewport position to keep center point stable
|
||||
const newPixelSize = 32 * newZoom;
|
||||
const newViewportX = worldX * newPixelSize - centerX;
|
||||
const newViewportY = worldY * newPixelSize - centerY;
|
||||
|
||||
setViewport({
|
||||
zoom: newZoom,
|
||||
x: Math.max(0, newViewportX),
|
||||
y: Math.max(0, newViewportY),
|
||||
});
|
||||
} else {
|
||||
setZoom(newZoom);
|
||||
}
|
||||
}, [setZoom, setViewport, viewport]);
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
const newZoom = Math.max(viewport.zoom / 1.2, 0.1);
|
||||
|
||||
// Zoom towards center of screen
|
||||
if (typeof window !== 'undefined') {
|
||||
const centerX = window.innerWidth / 2;
|
||||
const centerY = window.innerHeight / 2;
|
||||
|
||||
// Calculate world position at center of screen
|
||||
const pixelSize = 32 * viewport.zoom; // BASE_PIXEL_SIZE * current zoom
|
||||
const worldX = (centerX + viewport.x) / pixelSize;
|
||||
const worldY = (centerY + viewport.y) / pixelSize;
|
||||
|
||||
// Calculate new viewport position to keep center point stable
|
||||
const newPixelSize = 32 * newZoom;
|
||||
const newViewportX = worldX * newPixelSize - centerX;
|
||||
const newViewportY = worldY * newPixelSize - centerY;
|
||||
|
||||
setViewport({
|
||||
zoom: newZoom,
|
||||
x: Math.max(0, newViewportX),
|
||||
y: Math.max(0, newViewportY),
|
||||
});
|
||||
} else {
|
||||
setZoom(newZoom);
|
||||
}
|
||||
}, [setZoom, setViewport, viewport]);
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div className="relative w-full h-screen overflow-hidden">
|
||||
{/* Fullscreen Canvas */}
|
||||
<ErrorBoundary fallback={
|
||||
<div className="w-full h-full bg-gray-900 flex items-center justify-center">
|
||||
<div className="text-white text-center">
|
||||
<div className="text-4xl mb-4">🎨</div>
|
||||
<div>Canvas failed to load</div>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="mt-4 px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<VirtualCanvas
|
||||
onPixelClick={handlePixelClick}
|
||||
onCursorMove={handleCursorMove}
|
||||
onChunkNeeded={handleChunkNeeded}
|
||||
onHoverChange={handleHoverChange}
|
||||
selectedColor={selectedColor}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
|
||||
{/* Overlay UI Components */}
|
||||
<ErrorBoundary>
|
||||
<StatsOverlay
|
||||
onlineUsers={onlineUsers}
|
||||
totalPixels={totalPixels}
|
||||
zoom={viewport.zoom}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
|
||||
{/* Zoom Controls */}
|
||||
<ErrorBoundary>
|
||||
<ZoomControls
|
||||
zoom={viewport.zoom}
|
||||
onZoomIn={handleZoomIn}
|
||||
onZoomOut={handleZoomOut}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
|
||||
{/* Username Settings Button - Only show when no stats are visible */}
|
||||
{username && (
|
||||
<ErrorBoundary>
|
||||
<SettingsButton
|
||||
username={username}
|
||||
onOpenSettings={() => setShowUsernameModal(true)}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
|
||||
<ErrorBoundary>
|
||||
<CooldownTimer
|
||||
isActive={isCooldownActive}
|
||||
duration={10}
|
||||
onComplete={handleCooldownComplete}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
|
||||
<ErrorBoundary>
|
||||
<PixelConfirmModal
|
||||
isOpen={!!pendingPixel}
|
||||
x={pendingPixel?.x || 0}
|
||||
y={pendingPixel?.y || 0}
|
||||
color={selectedColor}
|
||||
onColorChange={setSelectedColor}
|
||||
onConfirm={handleConfirmPixel}
|
||||
onCancel={handleCancelPixel}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
|
||||
{/* Coordinate Display */}
|
||||
{hoverCoords && (
|
||||
<ErrorBoundary>
|
||||
<CoordinateDisplay
|
||||
x={hoverCoords.x}
|
||||
y={hoverCoords.y}
|
||||
pixelColor={hoverCoords.pixelInfo?.color || null}
|
||||
pixelOwner={hoverCoords.pixelInfo?.username || hoverCoords.pixelInfo?.userId || null}
|
||||
zoom={viewport.zoom}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
|
||||
{/* Username Modal */}
|
||||
<ErrorBoundary>
|
||||
<UsernameModal
|
||||
isOpen={showUsernameModal}
|
||||
currentUsername={username}
|
||||
onUsernameChange={handleUsernameChange}
|
||||
onClose={() => setShowUsernameModal(false)}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
|
||||
{/* Connection Status */}
|
||||
{!isConnected && (
|
||||
<ErrorBoundary>
|
||||
<div className="fixed top-6 left-1/2 transform -translate-x-1/2 z-50">
|
||||
<div className="bg-red-500/90 backdrop-blur-md rounded-xl px-4 py-2 text-white text-sm">
|
||||
Connecting...
|
||||
</div>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue