Collaborative-pixel-art/frontend/src/app/page.tsx
martin 415919b3e1 Fix pixel persistence and improve mobile UX
- Fix pixel data storage to include user information (userId, username, timestamp)
- Enhance zoom controls to center properly without drift
- Improve mobile modal centering with flexbox layout
- Add dynamic backend URL detection for network access
- Fix CORS configuration for development mode
- Add mobile-optimized touch targets and safe area support

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-22 20:14:48 +02:00

298 lines
No EOL
10 KiB
TypeScript

'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 viewport (canvas center)
if (typeof window !== 'undefined') {
const screenCenterX = window.innerWidth / 2;
const screenCenterY = window.innerHeight / 2;
// Calculate what canvas coordinate is currently at screen center
const BASE_PIXEL_SIZE = 32;
const currentPixelSize = BASE_PIXEL_SIZE * viewport.zoom;
const newPixelSize = BASE_PIXEL_SIZE * newZoom;
const canvasCenterX = (screenCenterX + viewport.x) / currentPixelSize;
const canvasCenterY = (screenCenterY + viewport.y) / currentPixelSize;
// Calculate new viewport to keep same canvas point at screen center
const newViewportX = canvasCenterX * newPixelSize - screenCenterX;
const newViewportY = canvasCenterY * newPixelSize - screenCenterY;
setViewport({
zoom: newZoom,
x: newViewportX,
y: newViewportY,
});
} else {
setZoom(newZoom);
}
}, [setZoom, setViewport, viewport]);
const handleZoomOut = useCallback(() => {
const newZoom = Math.max(viewport.zoom / 1.2, 0.1);
// Zoom towards center of viewport (canvas center)
if (typeof window !== 'undefined') {
const screenCenterX = window.innerWidth / 2;
const screenCenterY = window.innerHeight / 2;
// Calculate what canvas coordinate is currently at screen center
const BASE_PIXEL_SIZE = 32;
const currentPixelSize = BASE_PIXEL_SIZE * viewport.zoom;
const newPixelSize = BASE_PIXEL_SIZE * newZoom;
const canvasCenterX = (screenCenterX + viewport.x) / currentPixelSize;
const canvasCenterY = (screenCenterY + viewport.y) / currentPixelSize;
// Calculate new viewport to keep same canvas point at screen center
const newViewportX = canvasCenterX * newPixelSize - screenCenterX;
const newViewportY = canvasCenterY * newPixelSize - screenCenterY;
setViewport({
zoom: newZoom,
x: newViewportX,
y: 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-4 sm:top-6 left-1/2 transform -translate-x-1/2 z-50">
<div className="bg-red-500/90 backdrop-blur-md rounded-xl px-3 sm:px-4 py-2 text-white text-xs sm:text-sm font-medium">
Connecting...
</div>
</div>
</ErrorBoundary>
)}
</div>
</ErrorBoundary>
);
}