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
This commit is contained in:
parent
8e02486a2a
commit
3ce5a97422
69 changed files with 17771 additions and 1589 deletions
1
frontend/.env.local.example
Normal file
1
frontend/.env.local.example
Normal file
|
|
@ -0,0 +1 @@
|
|||
NEXT_PUBLIC_BACKEND_URL=http://localhost:3001
|
||||
10
frontend/.eslintrc.json
Normal file
10
frontend/.eslintrc.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"extends": ["next/core-web-vitals", "../.eslintrc.json"],
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es6": true
|
||||
},
|
||||
"parserOptions": {
|
||||
"project": "./tsconfig.json"
|
||||
}
|
||||
}
|
||||
26
frontend/Dockerfile
Normal file
26
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
FROM node:18-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY frontend/package*.json ./
|
||||
COPY shared/ ../shared/
|
||||
|
||||
# Install dependencies
|
||||
RUN npm install
|
||||
|
||||
# Copy source code
|
||||
COPY frontend/ .
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:3000 || exit 1
|
||||
|
||||
# Start the application
|
||||
CMD ["npm", "start"]
|
||||
6
frontend/next-env.d.ts
vendored
Normal file
6
frontend/next-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
30
frontend/next.config.js
Normal file
30
frontend/next.config.js
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: 'localhost',
|
||||
port: '3001',
|
||||
pathname: '/**',
|
||||
},
|
||||
{
|
||||
protocol: 'http',
|
||||
hostname: '192.168.1.110',
|
||||
port: '3001',
|
||||
pathname: '/**',
|
||||
},
|
||||
],
|
||||
},
|
||||
allowedDevOrigins: ['192.168.1.110:3000', '192.168.1.110'],
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: '/api/:path*',
|
||||
destination: `${process.env.NEXT_PUBLIC_BACKEND_URL || 'http://localhost:3001'}/api/:path*`,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
35
frontend/package.json
Normal file
35
frontend/package.json
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"name": "@gaplace/frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@gaplace/shared": "file:../shared",
|
||||
"next": "^15.5.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"zustand": "^5.0.2",
|
||||
"@tanstack/react-query": "^5.62.8",
|
||||
"framer-motion": "^11.15.0",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.5.4",
|
||||
"clsx": "^2.1.1",
|
||||
"tailwind-merge": "^2.5.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.3",
|
||||
"@types/node": "^22.10.6",
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"eslint": "^8.57.1",
|
||||
"eslint-config-next": "^15.5.0"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
6
frontend/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
69
frontend/src/app/globals.css
Normal file
69
frontend/src/app/globals.css
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-white dark:bg-gray-900 text-gray-900 dark:text-white transition-colors duration-300;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
-khtml-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.canvas-container {
|
||||
@apply relative overflow-hidden bg-white dark:bg-gray-800 rounded-lg shadow-lg;
|
||||
image-rendering: pixelated;
|
||||
image-rendering: -moz-crisp-edges;
|
||||
image-rendering: crisp-edges;
|
||||
}
|
||||
|
||||
.pixel {
|
||||
@apply absolute cursor-crosshair;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.color-picker-button {
|
||||
@apply w-8 h-8 rounded-full border-2 border-gray-300 dark:border-gray-600 cursor-pointer transition-all duration-200 hover:scale-110;
|
||||
}
|
||||
|
||||
.color-picker-button.selected {
|
||||
@apply border-4 border-blue-500 scale-110 shadow-lg;
|
||||
}
|
||||
|
||||
.tool-button {
|
||||
@apply p-2 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors duration-200;
|
||||
}
|
||||
|
||||
.tool-button.active {
|
||||
@apply bg-blue-500 text-white hover:bg-blue-600;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
34
frontend/src/app/layout.tsx
Normal file
34
frontend/src/app/layout.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import type { Metadata } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import './globals.css';
|
||||
import { Providers } from './providers';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'GaPlace - Collaborative Pixel Art',
|
||||
description: 'Create collaborative pixel art in real-time with infinite canvas and modern features',
|
||||
keywords: ['pixel art', 'collaborative', 'real-time', 'canvas', 'drawing'],
|
||||
authors: [{ name: 'GaPlace Team' }],
|
||||
};
|
||||
|
||||
export const viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body className={inter.className}>
|
||||
<Providers>
|
||||
{children}
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
27
frontend/src/app/providers.tsx
Normal file
27
frontend/src/app/providers.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
'use client';
|
||||
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { ThemeProvider } from '../components/ThemeProvider';
|
||||
|
||||
export function Providers({ children }: { children: React.ReactNode }) {
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 60 * 1000, // 1 minute
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ThemeProvider>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
58
frontend/src/components/ErrorBoundary.tsx
Normal file
58
frontend/src/components/ErrorBoundary.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
'use client';
|
||||
|
||||
import React, { Component, ErrorInfo, ReactNode } from 'react';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return { hasError: true, error };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
console.error('ErrorBoundary caught an error:', error, errorInfo);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
if (this.props.fallback) {
|
||||
return this.props.fallback;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800">
|
||||
<div className="text-center p-8">
|
||||
<div className="text-6xl mb-4">⚠️</div>
|
||||
<h2 className="text-xl font-semibold text-red-800 dark:text-red-200 mb-2">
|
||||
Something went wrong
|
||||
</h2>
|
||||
<p className="text-red-600 dark:text-red-300 mb-4 max-w-md">
|
||||
{this.state.error?.message || 'An unexpected error occurred'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors"
|
||||
>
|
||||
Reload Page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
53
frontend/src/components/ThemeProvider.tsx
Normal file
53
frontend/src/components/ThemeProvider.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
'use client';
|
||||
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system';
|
||||
|
||||
interface ThemeContextType {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>('system');
|
||||
|
||||
useEffect(() => {
|
||||
const stored = localStorage.getItem('theme') as Theme;
|
||||
if (stored) {
|
||||
setTheme(stored);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
root.classList.remove('light', 'dark');
|
||||
|
||||
if (theme === 'system') {
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light';
|
||||
root.classList.add(systemTheme);
|
||||
} else {
|
||||
root.classList.add(theme);
|
||||
}
|
||||
|
||||
localStorage.setItem('theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, setTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
113
frontend/src/components/canvas/CanvasContainer.tsx
Normal file
113
frontend/src/components/canvas/CanvasContainer.tsx
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { VirtualCanvas } from './VirtualCanvas';
|
||||
import { useWebSocket } from '../../hooks/useWebSocket';
|
||||
import { useCanvasStore } from '../../store/canvasStore';
|
||||
import { PixelPlacedMessage, ChunkDataMessage } from '@gaplace/shared';
|
||||
|
||||
export function CanvasContainer() {
|
||||
const [userId] = useState(() => `user_${Date.now()}_${Math.random().toString(36).substring(2, 11)}`);
|
||||
const canvasId = 'default'; // TODO: Make this dynamic
|
||||
|
||||
const {
|
||||
selectedColor,
|
||||
selectedTool,
|
||||
setPixel,
|
||||
loadChunk,
|
||||
setUserCursor,
|
||||
removeUserCursor,
|
||||
setActiveUsers,
|
||||
setStats,
|
||||
} = useCanvasStore();
|
||||
|
||||
const { isConnected, connectionError, placePixel, loadChunk: requestChunk, moveCursor } = useWebSocket({
|
||||
canvasId,
|
||||
userId,
|
||||
onPixelPlaced: (message: PixelPlacedMessage) => {
|
||||
setPixel(message.x, message.y, message.color, message.userId);
|
||||
},
|
||||
onChunkData: (message: ChunkDataMessage) => {
|
||||
loadChunk(message.chunkX, message.chunkY, message.pixels);
|
||||
},
|
||||
onUserList: (users: string[]) => {
|
||||
setActiveUsers(users);
|
||||
},
|
||||
onCanvasStats: (stats: { totalPixels?: number; activeUsers?: number; lastActivity?: number; userPixels?: number }) => {
|
||||
setStats(stats.totalPixels || 0, stats.userPixels || 0);
|
||||
},
|
||||
onCursorUpdate: (data: { userId: string; username: string; x: number; y: number; tool: string }) => {
|
||||
setUserCursor(data.userId, data.x, data.y, data.username, '#ff0000');
|
||||
},
|
||||
});
|
||||
|
||||
const handlePixelClick = (x: number, y: number) => {
|
||||
if (!isConnected) return;
|
||||
|
||||
if (selectedTool === 'pixel') {
|
||||
placePixel(x, y, selectedColor);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCursorMove = (x: number, y: number) => {
|
||||
if (!isConnected) return;
|
||||
moveCursor(x, y, selectedTool);
|
||||
};
|
||||
|
||||
const handleChunkNeeded = (chunkX: number, chunkY: number) => {
|
||||
if (!isConnected) return;
|
||||
requestChunk(chunkX, chunkY);
|
||||
};
|
||||
|
||||
const handleHoverChange = (x: number, y: number, pixelInfo: { color: string; userId?: string } | null) => {
|
||||
// Handle pixel hover for tooltips or UI updates
|
||||
};
|
||||
|
||||
if (connectionError) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center bg-gray-100 dark:bg-gray-800 rounded-lg">
|
||||
<div className="text-center">
|
||||
<div className="text-red-500 mb-2">⚠️ Connection Error</div>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">{connectionError}</div>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
Refresh Page
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isConnected) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center bg-gray-100 dark:bg-gray-800 rounded-lg">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-4"></div>
|
||||
<div className="text-gray-600 dark:text-gray-400">Connecting to GaPlace...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full bg-white dark:bg-gray-800 rounded-lg shadow-lg overflow-hidden">
|
||||
<VirtualCanvas
|
||||
onPixelClick={handlePixelClick}
|
||||
onCursorMove={handleCursorMove}
|
||||
onChunkNeeded={handleChunkNeeded}
|
||||
onHoverChange={handleHoverChange}
|
||||
selectedColor={selectedColor}
|
||||
/>
|
||||
|
||||
{/* Connection status indicator */}
|
||||
<div className="absolute top-4 right-4 flex items-center space-x-2 bg-black/20 backdrop-blur-sm rounded-lg px-3 py-1">
|
||||
<div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-400' : 'bg-red-400'}`} />
|
||||
<span className="text-sm text-white">
|
||||
{isConnected ? 'Connected' : 'Disconnected'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
582
frontend/src/components/canvas/VirtualCanvas.tsx
Normal file
582
frontend/src/components/canvas/VirtualCanvas.tsx
Normal file
|
|
@ -0,0 +1,582 @@
|
|||
'use client';
|
||||
|
||||
import { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import { useCanvasStore } from '../../store/canvasStore';
|
||||
import { CANVAS_CONFIG } from '@gaplace/shared';
|
||||
|
||||
interface VirtualCanvasProps {
|
||||
onPixelClick: (x: number, y: number) => void;
|
||||
onCursorMove: (x: number, y: number) => void;
|
||||
onChunkNeeded: (chunkX: number, chunkY: number) => void;
|
||||
onHoverChange: (x: number, y: number, pixelInfo: { color: string; userId?: string } | null) => void;
|
||||
selectedColor: string;
|
||||
}
|
||||
|
||||
export function VirtualCanvas({ onPixelClick, onCursorMove, onChunkNeeded, onHoverChange, selectedColor }: VirtualCanvasProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const animationFrameRef = useRef<number | undefined>(undefined);
|
||||
const isMouseDownRef = useRef(false);
|
||||
const isPanningRef = useRef(false);
|
||||
const lastPanPointRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const mouseDownPositionRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const DRAG_THRESHOLD = 5; // pixels
|
||||
const [cursorStyle, setCursorStyle] = useState<'crosshair' | 'grab' | 'grabbing'>('crosshair');
|
||||
const [hoverPixel, setHoverPixel] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
// Touch handling state
|
||||
const touchStartRef = useRef<{ x: number; y: number; time: number } | null>(null);
|
||||
const lastTouchesRef = useRef<TouchList | null>(null);
|
||||
const pinchStartDistanceRef = useRef<number | null>(null);
|
||||
const pinchStartZoomRef = useRef<number | null>(null);
|
||||
|
||||
const {
|
||||
viewport,
|
||||
chunks,
|
||||
selectedTool,
|
||||
showGrid,
|
||||
userCursors,
|
||||
setViewport,
|
||||
setZoom,
|
||||
pan,
|
||||
getChunkCoordinates,
|
||||
getPixelAt,
|
||||
getPixelInfo,
|
||||
} = useCanvasStore();
|
||||
|
||||
// Large modern pixel size - fixed base size to avoid circular dependencies
|
||||
const BASE_PIXEL_SIZE = 32;
|
||||
const pixelSize = BASE_PIXEL_SIZE * viewport.zoom;
|
||||
|
||||
// Convert screen coordinates to canvas coordinates
|
||||
const screenToCanvas = useCallback((screenX: number, screenY: number) => {
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return { x: 0, y: 0 };
|
||||
|
||||
const x = Math.floor((screenX - rect.left + viewport.x) / pixelSize);
|
||||
const y = Math.floor((screenY - rect.top + viewport.y) / pixelSize);
|
||||
return { x, y };
|
||||
}, [viewport.x, viewport.y, pixelSize]);
|
||||
|
||||
// Convert canvas coordinates to screen coordinates
|
||||
const canvasToScreen = useCallback((canvasX: number, canvasY: number) => {
|
||||
return {
|
||||
x: canvasX * pixelSize - viewport.x,
|
||||
y: canvasY * pixelSize - viewport.y,
|
||||
};
|
||||
}, [viewport.x, viewport.y, pixelSize]);
|
||||
|
||||
// Track requested chunks to prevent spam
|
||||
const requestedChunksRef = useRef(new Set<string>());
|
||||
|
||||
// Get visible chunks and request loading if needed
|
||||
const getVisibleChunks = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return [];
|
||||
|
||||
const startX = Math.floor(viewport.x / pixelSize);
|
||||
const startY = Math.floor(viewport.y / pixelSize);
|
||||
const endX = Math.floor((viewport.x + canvas.width) / pixelSize);
|
||||
const endY = Math.floor((viewport.y + canvas.height) / pixelSize);
|
||||
|
||||
const startChunkX = Math.floor(startX / CANVAS_CONFIG.DEFAULT_CHUNK_SIZE);
|
||||
const startChunkY = Math.floor(startY / CANVAS_CONFIG.DEFAULT_CHUNK_SIZE);
|
||||
const endChunkX = Math.floor(endX / CANVAS_CONFIG.DEFAULT_CHUNK_SIZE);
|
||||
const endChunkY = Math.floor(endY / CANVAS_CONFIG.DEFAULT_CHUNK_SIZE);
|
||||
|
||||
const visibleChunks: Array<{ chunkX: number; chunkY: number }> = [];
|
||||
|
||||
for (let chunkX = startChunkX; chunkX <= endChunkX; chunkX++) {
|
||||
for (let chunkY = startChunkY; chunkY <= endChunkY; chunkY++) {
|
||||
visibleChunks.push({ chunkX, chunkY });
|
||||
|
||||
// Request chunk if not loaded and not already requested
|
||||
const chunkKey = `${chunkX},${chunkY}`;
|
||||
const chunk = chunks.get(chunkKey);
|
||||
if (!chunk || !chunk.isLoaded) {
|
||||
if (!requestedChunksRef.current.has(chunkKey)) {
|
||||
requestedChunksRef.current.add(chunkKey);
|
||||
onChunkNeeded(chunkX, chunkY);
|
||||
// Remove from requested after a delay to allow retry
|
||||
setTimeout(() => {
|
||||
requestedChunksRef.current.delete(chunkKey);
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return visibleChunks;
|
||||
}, [viewport, pixelSize, chunks, onChunkNeeded]);
|
||||
|
||||
// Track dirty state to avoid unnecessary renders
|
||||
const isDirtyRef = useRef(true);
|
||||
const lastRenderTimeRef = useRef(0);
|
||||
const MIN_RENDER_INTERVAL = 16; // ~60fps max
|
||||
|
||||
// Render function with performance optimizations
|
||||
const render = useCallback(() => {
|
||||
const now = performance.now();
|
||||
if (now - lastRenderTimeRef.current < MIN_RENDER_INTERVAL) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isDirtyRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
const ctx = canvas?.getContext('2d');
|
||||
if (!canvas || !ctx) return;
|
||||
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Set pixel rendering
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
|
||||
const visibleChunks = getVisibleChunks();
|
||||
|
||||
// Render pixels from visible chunks
|
||||
for (const { chunkX, chunkY } of visibleChunks) {
|
||||
const chunkKey = `${chunkX},${chunkY}`;
|
||||
const chunk = chunks.get(chunkKey);
|
||||
|
||||
if (!chunk || !chunk.isLoaded) continue;
|
||||
|
||||
for (const [pixelKey, pixelInfo] of chunk.pixels) {
|
||||
const [localX, localY] = pixelKey.split(',').map(Number);
|
||||
const worldX = chunkX * CANVAS_CONFIG.DEFAULT_CHUNK_SIZE + localX;
|
||||
const worldY = chunkY * CANVAS_CONFIG.DEFAULT_CHUNK_SIZE + localY;
|
||||
|
||||
const screenPos = canvasToScreen(worldX, worldY);
|
||||
|
||||
// Only render if pixel is visible
|
||||
if (
|
||||
screenPos.x >= -pixelSize &&
|
||||
screenPos.y >= -pixelSize &&
|
||||
screenPos.x < canvas.width &&
|
||||
screenPos.y < canvas.height
|
||||
) {
|
||||
ctx.fillStyle = pixelInfo.color;
|
||||
ctx.fillRect(screenPos.x, screenPos.y, pixelSize, pixelSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render grid only when enabled and zoomed in enough (pixel size > 16px)
|
||||
if (showGrid && pixelSize > 16) {
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.shadowColor = 'rgba(0, 0, 0, 0.1)';
|
||||
ctx.shadowBlur = 2;
|
||||
|
||||
const startX = Math.floor(viewport.x / pixelSize) * pixelSize - viewport.x;
|
||||
const startY = Math.floor(viewport.y / pixelSize) * pixelSize - viewport.y;
|
||||
|
||||
// Vertical grid lines
|
||||
for (let x = startX; x < canvas.width; x += pixelSize) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, canvas.height);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Horizontal grid lines
|
||||
for (let y = startY; y < canvas.height; y += pixelSize) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(canvas.width, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Reset shadow
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
// Render user cursors
|
||||
for (const [userId, cursor] of userCursors) {
|
||||
const screenPos = canvasToScreen(cursor.x, cursor.y);
|
||||
|
||||
if (
|
||||
screenPos.x >= 0 &&
|
||||
screenPos.y >= 0 &&
|
||||
screenPos.x < canvas.width &&
|
||||
screenPos.y < canvas.height
|
||||
) {
|
||||
// Draw cursor
|
||||
ctx.strokeStyle = cursor.color;
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.arc(screenPos.x + pixelSize / 2, screenPos.y + pixelSize / 2, pixelSize + 4, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
// Draw username
|
||||
ctx.fillStyle = cursor.color;
|
||||
ctx.font = '12px sans-serif';
|
||||
ctx.fillText(cursor.username, screenPos.x, screenPos.y - 8);
|
||||
}
|
||||
}
|
||||
|
||||
// Render hover cursor indicator when zoomed in enough
|
||||
if (hoverPixel && pixelSize > 32) {
|
||||
const screenPos = canvasToScreen(hoverPixel.x, hoverPixel.y);
|
||||
|
||||
if (
|
||||
screenPos.x >= 0 &&
|
||||
screenPos.y >= 0 &&
|
||||
screenPos.x < canvas.width &&
|
||||
screenPos.y < canvas.height
|
||||
) {
|
||||
// Draw subtle pixel highlight border
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([4, 4]);
|
||||
ctx.strokeRect(screenPos.x + 1, screenPos.y + 1, pixelSize - 2, pixelSize - 2);
|
||||
ctx.setLineDash([]); // Reset line dash
|
||||
|
||||
// Draw small corner indicators
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.9)';
|
||||
const cornerSize = 3;
|
||||
// Top-left corner
|
||||
ctx.fillRect(screenPos.x, screenPos.y, cornerSize, cornerSize);
|
||||
// Top-right corner
|
||||
ctx.fillRect(screenPos.x + pixelSize - cornerSize, screenPos.y, cornerSize, cornerSize);
|
||||
// Bottom-left corner
|
||||
ctx.fillRect(screenPos.x, screenPos.y + pixelSize - cornerSize, cornerSize, cornerSize);
|
||||
// Bottom-right corner
|
||||
ctx.fillRect(screenPos.x + pixelSize - cornerSize, screenPos.y + pixelSize - cornerSize, cornerSize, cornerSize);
|
||||
}
|
||||
}
|
||||
|
||||
isDirtyRef.current = false;
|
||||
lastRenderTimeRef.current = now;
|
||||
}, [viewport, chunks, pixelSize, showGrid, userCursors, hoverPixel]);
|
||||
|
||||
// Mouse event handlers - Left click for both pixel placement and panning
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (e.button === 0) { // Left click only
|
||||
isMouseDownRef.current = true;
|
||||
isPanningRef.current = false; // Reset panning state
|
||||
mouseDownPositionRef.current = { x: e.clientX, y: e.clientY };
|
||||
lastPanPointRef.current = { x: e.clientX, y: e.clientY };
|
||||
setCursorStyle('grab');
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
const { x, y } = screenToCanvas(e.clientX, e.clientY);
|
||||
onCursorMove(x, y);
|
||||
|
||||
// Update hover pixel for cursor indicator
|
||||
setHoverPixel({ x, y });
|
||||
|
||||
// Get pixel info at this position and call onHoverChange
|
||||
const pixelInfo = getPixelInfo(x, y);
|
||||
onHoverChange(x, y, pixelInfo);
|
||||
|
||||
if (isMouseDownRef.current && mouseDownPositionRef.current && lastPanPointRef.current) {
|
||||
// Calculate distance from initial mouse down position
|
||||
const deltaFromStart = Math.sqrt(
|
||||
Math.pow(e.clientX - mouseDownPositionRef.current.x, 2) +
|
||||
Math.pow(e.clientY - mouseDownPositionRef.current.y, 2)
|
||||
);
|
||||
|
||||
// If moved more than threshold, start panning
|
||||
if (deltaFromStart > DRAG_THRESHOLD && !isPanningRef.current) {
|
||||
isPanningRef.current = true;
|
||||
setCursorStyle('grabbing');
|
||||
}
|
||||
|
||||
// If we're panning, update viewport
|
||||
if (isPanningRef.current) {
|
||||
const deltaX = lastPanPointRef.current.x - e.clientX;
|
||||
const deltaY = lastPanPointRef.current.y - e.clientY;
|
||||
pan(deltaX, deltaY);
|
||||
lastPanPointRef.current = { x: e.clientX, y: e.clientY };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = (e: React.MouseEvent) => {
|
||||
if (e.button === 0) {
|
||||
// If we weren't panning, treat as pixel click
|
||||
if (!isPanningRef.current && mouseDownPositionRef.current) {
|
||||
const { x, y } = screenToCanvas(e.clientX, e.clientY);
|
||||
onPixelClick(x, y);
|
||||
}
|
||||
|
||||
// Reset all mouse state
|
||||
isMouseDownRef.current = false;
|
||||
isPanningRef.current = false;
|
||||
lastPanPointRef.current = null;
|
||||
mouseDownPositionRef.current = null;
|
||||
setCursorStyle('crosshair');
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
// Stop all interactions when mouse leaves canvas
|
||||
isMouseDownRef.current = false;
|
||||
isPanningRef.current = false;
|
||||
lastPanPointRef.current = null;
|
||||
mouseDownPositionRef.current = null;
|
||||
setHoverPixel(null);
|
||||
setCursorStyle('crosshair');
|
||||
};
|
||||
|
||||
const handleWheel = (e: React.WheelEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Get mouse position in screen coordinates (relative to canvas)
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
const mouseScreenX = e.clientX - rect.left;
|
||||
const mouseScreenY = e.clientY - rect.top;
|
||||
|
||||
// Calculate world position that mouse is pointing to
|
||||
const worldX = (mouseScreenX + viewport.x) / pixelSize;
|
||||
const worldY = (mouseScreenY + viewport.y) / pixelSize;
|
||||
|
||||
// Calculate new zoom with better zoom increments
|
||||
const zoomFactor = e.deltaY > 0 ? 0.8 : 1.25;
|
||||
const newZoom = Math.max(0.1, Math.min(10.0, viewport.zoom * zoomFactor));
|
||||
|
||||
// Calculate new pixel size
|
||||
const newPixelSize = BASE_PIXEL_SIZE * newZoom;
|
||||
|
||||
// Calculate new viewport position to keep world position under cursor
|
||||
const newViewportX = worldX * newPixelSize - mouseScreenX;
|
||||
const newViewportY = worldY * newPixelSize - mouseScreenY;
|
||||
|
||||
// Update viewport with new zoom and position
|
||||
setViewport({
|
||||
zoom: newZoom,
|
||||
x: newViewportX,
|
||||
y: newViewportY,
|
||||
});
|
||||
};
|
||||
|
||||
// Resize handler
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
const container = containerRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!container || !canvas) return;
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
const devicePixelRatio = window.devicePixelRatio || 1;
|
||||
|
||||
// Set the internal size to actual resolution
|
||||
canvas.width = rect.width * devicePixelRatio;
|
||||
canvas.height = rect.height * devicePixelRatio;
|
||||
|
||||
// Scale the canvas back down using CSS
|
||||
canvas.style.width = rect.width + 'px';
|
||||
canvas.style.height = rect.height + 'px';
|
||||
|
||||
// Scale the drawing context so everything draws at the correct size
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.scale(devicePixelRatio, devicePixelRatio);
|
||||
}
|
||||
|
||||
setViewport({ width: rect.width, height: rect.height });
|
||||
};
|
||||
|
||||
handleResize();
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [setViewport]);
|
||||
|
||||
// Mark as dirty when viewport, chunks, or other dependencies change
|
||||
useEffect(() => {
|
||||
isDirtyRef.current = true;
|
||||
}, [viewport, chunks, userCursors, hoverPixel]);
|
||||
|
||||
// Animation loop with render on demand
|
||||
useEffect(() => {
|
||||
const animate = () => {
|
||||
render();
|
||||
animationFrameRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
animationFrameRef.current = requestAnimationFrame(animate);
|
||||
|
||||
return () => {
|
||||
if (animationFrameRef.current) {
|
||||
cancelAnimationFrame(animationFrameRef.current);
|
||||
}
|
||||
};
|
||||
}, [render]);
|
||||
|
||||
// Touch event handlers for mobile
|
||||
const handleTouchStart = (e: React.TouchEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (e.touches.length === 1) {
|
||||
// Single touch - potential tap or pan
|
||||
const touch = e.touches[0];
|
||||
touchStartRef.current = {
|
||||
x: touch.clientX,
|
||||
y: touch.clientY,
|
||||
time: Date.now()
|
||||
};
|
||||
lastPanPointRef.current = { x: touch.clientX, y: touch.clientY };
|
||||
} else if (e.touches.length === 2) {
|
||||
// Two finger pinch to zoom
|
||||
const touch1 = e.touches[0];
|
||||
const touch2 = e.touches[1];
|
||||
const distance = Math.sqrt(
|
||||
Math.pow(touch1.clientX - touch2.clientX, 2) +
|
||||
Math.pow(touch1.clientY - touch2.clientY, 2)
|
||||
);
|
||||
pinchStartDistanceRef.current = distance;
|
||||
pinchStartZoomRef.current = viewport.zoom;
|
||||
|
||||
// Center point between touches for zoom
|
||||
const centerX = (touch1.clientX + touch2.clientX) / 2;
|
||||
const centerY = (touch1.clientY + touch2.clientY) / 2;
|
||||
lastPanPointRef.current = { x: centerX, y: centerY };
|
||||
}
|
||||
|
||||
lastTouchesRef.current = Array.from(e.touches) as any;
|
||||
};
|
||||
|
||||
const handleTouchMove = (e: React.TouchEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (e.touches.length === 1 && touchStartRef.current && lastPanPointRef.current) {
|
||||
// Single touch pan
|
||||
const touch = e.touches[0];
|
||||
const deltaX = lastPanPointRef.current.x - touch.clientX;
|
||||
const deltaY = lastPanPointRef.current.y - touch.clientY;
|
||||
|
||||
// Check if we've moved enough to start panning
|
||||
const totalDistance = Math.sqrt(
|
||||
Math.pow(touch.clientX - touchStartRef.current.x, 2) +
|
||||
Math.pow(touch.clientY - touchStartRef.current.y, 2)
|
||||
);
|
||||
|
||||
if (totalDistance > DRAG_THRESHOLD) {
|
||||
isPanningRef.current = true;
|
||||
|
||||
// Pan the viewport
|
||||
const newViewportX = viewport.x + deltaX;
|
||||
const newViewportY = viewport.y + deltaY;
|
||||
|
||||
setViewport({
|
||||
x: newViewportX,
|
||||
y: newViewportY,
|
||||
});
|
||||
|
||||
lastPanPointRef.current = { x: touch.clientX, y: touch.clientY };
|
||||
}
|
||||
|
||||
// Update hover for single touch
|
||||
const { x, y } = screenToCanvas(touch.clientX, touch.clientY);
|
||||
setHoverPixel({ x, y });
|
||||
const pixelInfo = getPixelInfo(x, y);
|
||||
onHoverChange(x, y, pixelInfo);
|
||||
|
||||
} else if (e.touches.length === 2 && pinchStartDistanceRef.current && pinchStartZoomRef.current) {
|
||||
// Two finger pinch zoom
|
||||
const touch1 = e.touches[0];
|
||||
const touch2 = e.touches[1];
|
||||
const currentDistance = Math.sqrt(
|
||||
Math.pow(touch1.clientX - touch2.clientX, 2) +
|
||||
Math.pow(touch1.clientY - touch2.clientY, 2)
|
||||
);
|
||||
|
||||
const scale = currentDistance / pinchStartDistanceRef.current;
|
||||
const newZoom = Math.max(0.1, Math.min(5.0, pinchStartZoomRef.current * scale));
|
||||
|
||||
// Zoom towards center of pinch
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
if (rect) {
|
||||
const centerX = (touch1.clientX + touch2.clientX) / 2 - rect.left;
|
||||
const centerY = (touch1.clientY + touch2.clientY) / 2 - rect.top;
|
||||
|
||||
const worldX = (centerX + viewport.x) / pixelSize;
|
||||
const worldY = (centerY + viewport.y) / pixelSize;
|
||||
|
||||
const newPixelSize = BASE_PIXEL_SIZE * newZoom;
|
||||
const newViewportX = worldX * newPixelSize - centerX;
|
||||
const newViewportY = worldY * newPixelSize - centerY;
|
||||
|
||||
setViewport({
|
||||
zoom: newZoom,
|
||||
x: newViewportX,
|
||||
y: newViewportY,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchEnd = (e: React.TouchEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (e.touches.length === 0) {
|
||||
// All touches ended
|
||||
if (touchStartRef.current && !isPanningRef.current) {
|
||||
// This was a tap, not a pan
|
||||
const timeDiff = Date.now() - touchStartRef.current.time;
|
||||
if (timeDiff < 300) { // Quick tap
|
||||
const { x, y } = screenToCanvas(touchStartRef.current.x, touchStartRef.current.y);
|
||||
onPixelClick(x, y);
|
||||
}
|
||||
}
|
||||
|
||||
// Reset touch state
|
||||
touchStartRef.current = null;
|
||||
isPanningRef.current = false;
|
||||
lastPanPointRef.current = null;
|
||||
pinchStartDistanceRef.current = null;
|
||||
pinchStartZoomRef.current = null;
|
||||
}
|
||||
|
||||
lastTouchesRef.current = Array.from(e.touches) as any;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="fixed inset-0 w-full h-full"
|
||||
style={{
|
||||
cursor: cursorStyle,
|
||||
background: `
|
||||
linear-gradient(135deg,
|
||||
rgba(15, 23, 42, 0.95) 0%,
|
||||
rgba(30, 41, 59, 0.9) 25%,
|
||||
rgba(51, 65, 85, 0.85) 50%,
|
||||
rgba(30, 58, 138, 0.8) 75%,
|
||||
rgba(29, 78, 216, 0.75) 100%
|
||||
),
|
||||
radial-gradient(circle at 20% 20%, rgba(99, 102, 241, 0.15) 0%, transparent 40%),
|
||||
radial-gradient(circle at 80% 80%, rgba(59, 130, 246, 0.12) 0%, transparent 40%),
|
||||
radial-gradient(circle at 40% 70%, rgba(147, 51, 234, 0.1) 0%, transparent 40%),
|
||||
radial-gradient(circle at 70% 30%, rgba(16, 185, 129, 0.08) 0%, transparent 40%)
|
||||
`,
|
||||
backgroundAttachment: 'fixed'
|
||||
}}
|
||||
>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
onWheel={handleWheel}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
className="w-full h-full touch-none"
|
||||
style={{ touchAction: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
131
frontend/src/components/ui/ColorPalette.tsx
Normal file
131
frontend/src/components/ui/ColorPalette.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const PIXEL_COLORS = [
|
||||
// Primary colors
|
||||
'#FF0000', // Bright Red
|
||||
'#00FF00', // Bright Green
|
||||
'#0000FF', // Bright Blue
|
||||
'#FFFF00', // Bright Yellow
|
||||
|
||||
// Secondary colors
|
||||
'#FF8C00', // Dark Orange
|
||||
'#FF69B4', // Hot Pink
|
||||
'#9400D3', // Violet
|
||||
'#00CED1', // Dark Turquoise
|
||||
|
||||
// Earth tones
|
||||
'#8B4513', // Saddle Brown
|
||||
'#228B22', // Forest Green
|
||||
'#B22222', // Fire Brick
|
||||
'#4682B4', // Steel Blue
|
||||
|
||||
// Grays and basics
|
||||
'#000000', // Black
|
||||
'#FFFFFF', // White
|
||||
'#808080', // Gray
|
||||
'#C0C0C0', // Silver
|
||||
|
||||
// Pastels
|
||||
'#FFB6C1', // Light Pink
|
||||
'#87CEEB', // Sky Blue
|
||||
'#98FB98', // Pale Green
|
||||
'#F0E68C', // Khaki
|
||||
|
||||
// Additional vibrant colors
|
||||
'#FF1493', // Deep Pink
|
||||
'#00BFFF', // Deep Sky Blue
|
||||
'#32CD32', // Lime Green
|
||||
'#FF4500', // Orange Red
|
||||
];
|
||||
|
||||
interface ColorPaletteProps {
|
||||
selectedColor: string;
|
||||
onColorSelect: (color: string) => void;
|
||||
}
|
||||
|
||||
export function ColorPalette({ selectedColor, onColorSelect }: ColorPaletteProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="fixed bottom-4 md:bottom-6 left-4 md:left-6 z-50 max-w-xs"
|
||||
initial={{ opacity: 0, y: 50 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="bg-gradient-to-br from-gray-900/95 to-black/90 backdrop-blur-xl rounded-2xl p-4 border border-white/10 shadow-2xl">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<motion.div
|
||||
className="w-10 h-10 rounded-xl border-2 border-white/20 cursor-pointer shadow-lg overflow-hidden"
|
||||
style={{ backgroundColor: selectedColor }}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
{selectedColor === '#FFFFFF' && (
|
||||
<div className="w-full h-full bg-white border border-gray-300" />
|
||||
)}
|
||||
</motion.div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-white/90 font-semibold text-sm">Color Palette</span>
|
||||
<span className="text-white/60 text-xs">{selectedColor.toUpperCase()}</span>
|
||||
</div>
|
||||
<motion.div
|
||||
className="ml-auto text-white/60"
|
||||
animate={{ rotate: isExpanded ? 180 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
▼
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="grid grid-cols-6 gap-2"
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{
|
||||
height: isExpanded ? 'auto' : 0,
|
||||
opacity: isExpanded ? 1 : 0
|
||||
}}
|
||||
transition={{ duration: 0.3, ease: "easeInOut" }}
|
||||
style={{ overflow: 'hidden' }}
|
||||
>
|
||||
{PIXEL_COLORS.map((color, index) => (
|
||||
<motion.button
|
||||
key={color}
|
||||
className={`w-8 h-8 rounded-lg border-2 transition-all duration-200 relative overflow-hidden ${
|
||||
selectedColor === color
|
||||
? 'border-white/80 shadow-lg ring-2 ring-white/30'
|
||||
: 'border-white/20 hover:border-white/50 hover:shadow-md'
|
||||
}`}
|
||||
style={{ backgroundColor: color }}
|
||||
onClick={() => {
|
||||
onColorSelect(color);
|
||||
setIsExpanded(false);
|
||||
}}
|
||||
whileHover={{ scale: 1.1, y: -2 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: index * 0.02 }}
|
||||
>
|
||||
{color === '#FFFFFF' && (
|
||||
<div className="absolute inset-0 bg-white border border-gray-300" />
|
||||
)}
|
||||
{selectedColor === color && (
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-white/20 rounded-md"
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
/>
|
||||
)}
|
||||
</motion.button>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
100
frontend/src/components/ui/ColorPicker.tsx
Normal file
100
frontend/src/components/ui/ColorPicker.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useCanvasStore } from '../../store/canvasStore';
|
||||
import { COLORS } from '@gaplace/shared';
|
||||
|
||||
export function ColorPicker() {
|
||||
const { selectedColor, setSelectedColor } = useCanvasStore();
|
||||
const [customColor, setCustomColor] = useState('#000000');
|
||||
|
||||
const handleColorSelect = (color: string) => {
|
||||
setSelectedColor(color);
|
||||
};
|
||||
|
||||
const handleCustomColorChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const color = e.target.value;
|
||||
setCustomColor(color);
|
||||
setSelectedColor(color);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4">
|
||||
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">
|
||||
Color Palette
|
||||
</h3>
|
||||
|
||||
{/* Current color display */}
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div
|
||||
className="w-12 h-12 rounded-lg border-2 border-gray-300 dark:border-gray-600 shadow-inner"
|
||||
style={{ backgroundColor: selectedColor }}
|
||||
/>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Current: {selectedColor.toUpperCase()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Predefined colors */}
|
||||
<div className="grid grid-cols-6 gap-2 mb-4">
|
||||
{COLORS.PALETTE.map((color, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className={`color-picker-button ${
|
||||
selectedColor === color ? 'selected' : ''
|
||||
}`}
|
||||
style={{ backgroundColor: color }}
|
||||
onClick={() => handleColorSelect(color)}
|
||||
title={color}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Custom color picker */}
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Custom Color
|
||||
</label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="color"
|
||||
value={customColor}
|
||||
onChange={handleCustomColorChange}
|
||||
className="w-10 h-10 rounded border border-gray-300 dark:border-gray-600 cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={customColor}
|
||||
onChange={(e) => {
|
||||
const color = e.target.value;
|
||||
if (/^#[0-9A-Fa-f]{6}$/.test(color)) {
|
||||
setCustomColor(color);
|
||||
setSelectedColor(color);
|
||||
}
|
||||
}}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm"
|
||||
placeholder="#000000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recent colors */}
|
||||
<div className="mt-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Recent Colors
|
||||
</label>
|
||||
<div className="grid grid-cols-8 gap-1">
|
||||
{/* TODO: Implement recent colors storage */}
|
||||
{Array.from({ length: 8 }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="w-6 h-6 rounded bg-gray-200 dark:bg-gray-600"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
frontend/src/components/ui/CooldownTimer.tsx
Normal file
80
frontend/src/components/ui/CooldownTimer.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface CooldownTimerProps {
|
||||
isActive: boolean;
|
||||
duration: number; // in seconds
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
export function CooldownTimer({ isActive, duration, onComplete }: CooldownTimerProps) {
|
||||
const [timeLeft, setTimeLeft] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (isActive) {
|
||||
setTimeLeft(duration);
|
||||
const interval = setInterval(() => {
|
||||
setTimeLeft((prev) => {
|
||||
if (prev <= 1) {
|
||||
clearInterval(interval);
|
||||
// Use setTimeout to avoid setState during render
|
||||
setTimeout(() => onComplete(), 0);
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [isActive, duration, onComplete]);
|
||||
|
||||
if (!isActive || timeLeft === 0) return null;
|
||||
|
||||
const progress = ((duration - timeLeft) / duration) * 100;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="fixed top-4 sm:top-6 left-1/2 transform -translate-x-1/2 z-50"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 25
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
className="bg-white/5 backdrop-blur-2xl rounded-full px-4 sm:px-6 py-2 sm:py-3 border border-white/20 shadow-lg ring-1 ring-white/10"
|
||||
whileHover={{
|
||||
scale: 1.05
|
||||
}}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 25 }}
|
||||
>
|
||||
<div className="flex items-center gap-3 sm:gap-4">
|
||||
<motion.div
|
||||
className="text-white font-medium text-sm"
|
||||
animate={{
|
||||
color: timeLeft <= 3 ? "#f87171" : "#ffffff"
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{timeLeft}s
|
||||
</motion.div>
|
||||
|
||||
<div className="w-16 sm:w-24 h-2 bg-white/20 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
className="h-full bg-gradient-to-r from-cyan-400 to-blue-500 rounded-full"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${progress}%` }}
|
||||
transition={{ duration: 0.5, ease: "easeOut" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
76
frontend/src/components/ui/CoordinateDisplay.tsx
Normal file
76
frontend/src/components/ui/CoordinateDisplay.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface CoordinateDisplayProps {
|
||||
x: number;
|
||||
y: number;
|
||||
pixelColor?: string | null;
|
||||
pixelOwner?: string | null;
|
||||
zoom: number;
|
||||
}
|
||||
|
||||
export function CoordinateDisplay({
|
||||
x,
|
||||
y,
|
||||
pixelColor,
|
||||
pixelOwner,
|
||||
zoom
|
||||
}: CoordinateDisplayProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className="fixed bottom-4 left-4 sm:bottom-6 sm:left-6 z-50"
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 30 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 25
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
className="bg-white/5 backdrop-blur-2xl rounded-xl sm:rounded-2xl px-3 sm:px-4 py-2 sm:py-3 border border-white/20 shadow-lg ring-1 ring-white/10"
|
||||
whileHover={{
|
||||
scale: 1.02
|
||||
}}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 25 }}
|
||||
>
|
||||
<div className="space-y-1 sm:space-y-2 text-xs sm:text-sm">
|
||||
{/* Coordinates */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white/70 font-medium">Pos:</span>
|
||||
<span className="text-white font-mono font-bold">
|
||||
({x}, {y})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Pixel info */}
|
||||
{pixelColor && (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white/70 font-medium">Pixel:</span>
|
||||
<div
|
||||
className="w-3 h-3 sm:w-4 sm:h-4 rounded border border-white/30"
|
||||
style={{ backgroundColor: pixelColor }}
|
||||
/>
|
||||
<span className="text-white font-mono text-xs">
|
||||
{pixelColor.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{pixelOwner && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white/70 font-medium">By:</span>
|
||||
<span className="text-blue-300 font-medium text-xs max-w-[80px] sm:max-w-none truncate">
|
||||
{pixelOwner}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
202
frontend/src/components/ui/PixelConfirmModal.tsx
Normal file
202
frontend/src/components/ui/PixelConfirmModal.tsx
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
const PIXEL_COLORS = [
|
||||
'#FF0000', // Red
|
||||
'#00FF00', // Green
|
||||
'#0000FF', // Blue
|
||||
'#FFFF00', // Yellow
|
||||
'#FF00FF', // Magenta
|
||||
'#00FFFF', // Cyan
|
||||
'#FFA500', // Orange
|
||||
'#800080', // Purple
|
||||
'#FFC0CB', // Pink
|
||||
'#A52A2A', // Brown
|
||||
'#808080', // Gray
|
||||
'#000000', // Black
|
||||
'#FFFFFF', // White
|
||||
'#90EE90', // Light Green
|
||||
'#FFB6C1', // Light Pink
|
||||
'#87CEEB', // Sky Blue
|
||||
];
|
||||
|
||||
interface PixelConfirmModalProps {
|
||||
isOpen: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
color: string;
|
||||
onColorChange: (color: string) => void;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function PixelConfirmModal({
|
||||
isOpen,
|
||||
x,
|
||||
y,
|
||||
color,
|
||||
onColorChange,
|
||||
onConfirm,
|
||||
onCancel
|
||||
}: PixelConfirmModalProps) {
|
||||
const [selectedLocalColor, setSelectedLocalColor] = useState(color);
|
||||
|
||||
const handleColorSelect = (newColor: string) => {
|
||||
setSelectedLocalColor(newColor);
|
||||
onColorChange(newColor);
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
onColorChange(selectedLocalColor);
|
||||
onConfirm();
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
className="fixed inset-0 bg-black/30 backdrop-blur-md z-50"
|
||||
initial={{ opacity: 0, backdropFilter: "blur(0px)" }}
|
||||
animate={{ opacity: 1, backdropFilter: "blur(16px)" }}
|
||||
exit={{ opacity: 0, backdropFilter: "blur(0px)" }}
|
||||
transition={{ duration: 0.4, ease: "easeOut" }}
|
||||
onClick={onCancel}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<motion.div
|
||||
className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50"
|
||||
initial={{ opacity: 0, scale: 0.8, y: 40, rotateX: 10 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0, rotateX: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.8, y: 40, rotateX: 10 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 30,
|
||||
opacity: { duration: 0.3 }
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
className="bg-white/5 backdrop-blur-2xl rounded-2xl sm:rounded-3xl p-4 sm:p-6 md:p-8 border border-white/20 w-[95vw] max-w-[350px] sm:max-w-[400px] shadow-2xl ring-1 ring-white/10 relative overflow-hidden"
|
||||
whileHover={{
|
||||
boxShadow: "0 20px 40px rgba(0,0,0,0.2)"
|
||||
}}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 25 }}
|
||||
>
|
||||
{/* Flowing glass background effect */}
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-gradient-to-br from-white/10 via-transparent to-white/5 rounded-3xl"
|
||||
animate={{
|
||||
background: [
|
||||
"linear-gradient(45deg, rgba(255,255,255,0.1) 0%, transparent 50%, rgba(255,255,255,0.05) 100%)",
|
||||
"linear-gradient(225deg, rgba(255,255,255,0.1) 0%, transparent 50%, rgba(255,255,255,0.05) 100%)",
|
||||
"linear-gradient(45deg, rgba(255,255,255,0.1) 0%, transparent 50%, rgba(255,255,255,0.05) 100%)"
|
||||
]
|
||||
}}
|
||||
transition={{ duration: 8, repeat: Infinity, ease: "linear" }}
|
||||
/>
|
||||
<div className="text-center relative z-10">
|
||||
<motion.h3
|
||||
className="text-white text-xl font-bold mb-2"
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
Place Pixel
|
||||
</motion.h3>
|
||||
|
||||
<motion.div
|
||||
className="flex items-center justify-center gap-4 mb-6"
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.15 }}
|
||||
>
|
||||
<div className="text-white/70 font-mono">
|
||||
Position: ({x}, {y})
|
||||
</div>
|
||||
<motion.div
|
||||
className="w-10 h-10 rounded-xl border-2 border-white/30 shadow-xl ring-1 ring-white/20"
|
||||
style={{ backgroundColor: selectedLocalColor }}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
transition={{ type: "spring", stiffness: 400 }}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Color Palette */}
|
||||
<motion.div
|
||||
className="mb-8"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<h4 className="text-white/90 text-sm font-medium mb-4">Choose Color</h4>
|
||||
<div className="grid grid-cols-6 sm:grid-cols-8 gap-2 sm:gap-3 max-w-xs sm:max-w-sm mx-auto">
|
||||
{PIXEL_COLORS.map((paletteColor, index) => (
|
||||
<motion.button
|
||||
key={paletteColor}
|
||||
className={`w-7 h-7 sm:w-8 sm:h-8 rounded-lg border-2 transition-all duration-200 ring-1 ring-white/10 touch-manipulation ${
|
||||
selectedLocalColor === paletteColor
|
||||
? 'border-white/80 scale-110 shadow-xl ring-white/30'
|
||||
: 'border-white/30 hover:border-white/60 hover:ring-white/20'
|
||||
}`}
|
||||
style={{ backgroundColor: paletteColor }}
|
||||
onClick={() => handleColorSelect(paletteColor)}
|
||||
whileHover={{
|
||||
scale: 1.1,
|
||||
boxShadow: "0 4px 15px rgba(0,0,0,0.3)"
|
||||
}}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{
|
||||
delay: 0.1 + index * 0.01,
|
||||
type: "spring",
|
||||
stiffness: 500,
|
||||
damping: 20
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="flex gap-4"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<motion.button
|
||||
className="flex-1 px-6 py-3 bg-white/5 backdrop-blur-xl text-white rounded-xl border border-white/20 hover:bg-white/10 transition-all duration-200 font-medium ring-1 ring-white/10 relative overflow-hidden"
|
||||
onClick={onCancel}
|
||||
whileHover={{
|
||||
backgroundColor: "rgba(255,255,255,0.15)"
|
||||
}}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 25 }}
|
||||
>
|
||||
Cancel
|
||||
</motion.button>
|
||||
<motion.button
|
||||
className="flex-1 px-6 py-3 bg-gradient-to-r from-blue-500/80 to-purple-600/80 backdrop-blur-xl text-white rounded-xl hover:from-blue-600/90 hover:to-purple-700/90 transition-all duration-200 font-medium shadow-xl ring-1 ring-white/20 relative overflow-hidden"
|
||||
onClick={handleConfirm}
|
||||
whileHover={{
|
||||
boxShadow: "0 10px 25px rgba(0,0,0,0.4)"
|
||||
}}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 25 }}
|
||||
>
|
||||
Place Pixel
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
43
frontend/src/components/ui/SettingsButton.tsx
Normal file
43
frontend/src/components/ui/SettingsButton.tsx
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface SettingsButtonProps {
|
||||
username: string;
|
||||
onOpenSettings: () => void;
|
||||
}
|
||||
|
||||
export function SettingsButton({ username, onOpenSettings }: SettingsButtonProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className="fixed top-4 right-4 sm:top-6 sm:right-6 z-40"
|
||||
initial={{ opacity: 0, x: 30 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 25,
|
||||
delay: 0.2
|
||||
}}
|
||||
>
|
||||
<motion.button
|
||||
className="bg-white/5 backdrop-blur-2xl rounded-full pl-3 pr-4 sm:pl-4 sm:pr-6 py-2 sm:py-3 border border-white/20 shadow-lg hover:bg-white/10 active:bg-white/15 transition-all duration-200 ring-1 ring-white/10 touch-manipulation"
|
||||
onClick={onOpenSettings}
|
||||
whileHover={{
|
||||
scale: 1.05,
|
||||
boxShadow: "0 8px 25px rgba(0,0,0,0.3)"
|
||||
}}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<div className="w-6 h-6 sm:w-8 sm:h-8 bg-gradient-to-br from-blue-500 to-purple-600 rounded-full flex items-center justify-center text-white font-bold text-xs sm:text-sm">
|
||||
{username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<span className="text-white font-medium text-xs sm:text-sm max-w-[80px] sm:max-w-none truncate">
|
||||
{username}
|
||||
</span>
|
||||
</div>
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
69
frontend/src/components/ui/StatsOverlay.tsx
Normal file
69
frontend/src/components/ui/StatsOverlay.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface StatsOverlayProps {
|
||||
onlineUsers: number;
|
||||
totalPixels: number;
|
||||
zoom: number;
|
||||
}
|
||||
|
||||
export function StatsOverlay({ onlineUsers, totalPixels, zoom }: StatsOverlayProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className="fixed top-4 left-4 sm:top-6 sm:left-6 z-50"
|
||||
initial={{ opacity: 0, x: -30 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 400,
|
||||
damping: 25
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
className="bg-white/5 backdrop-blur-2xl rounded-xl sm:rounded-2xl p-3 sm:p-4 border border-white/20 shadow-lg ring-1 ring-white/10"
|
||||
whileHover={{
|
||||
scale: 1.05,
|
||||
boxShadow: "0 10px 25px rgba(0,0,0,0.3)"
|
||||
}}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 25 }}
|
||||
>
|
||||
<div className="space-y-2 sm:space-y-3">
|
||||
<motion.div
|
||||
className="flex items-center gap-2 sm:gap-3"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<motion.div
|
||||
className="w-2.5 h-2.5 sm:w-3 sm:h-3 bg-green-400 rounded-full"
|
||||
animate={{
|
||||
scale: [1, 1.3, 1]
|
||||
}}
|
||||
transition={{
|
||||
duration: 2,
|
||||
repeat: Infinity,
|
||||
ease: "easeInOut"
|
||||
}}
|
||||
/>
|
||||
<span className="text-white font-medium text-xs sm:text-sm">
|
||||
{onlineUsers} online
|
||||
</span>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="flex items-center gap-2 sm:gap-3"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<div className="w-2.5 h-2.5 sm:w-3 sm:h-3 bg-blue-400 rounded-full" />
|
||||
<span className="text-white font-medium text-xs sm:text-sm">
|
||||
{totalPixels.toLocaleString()} pixels
|
||||
</span>
|
||||
</motion.div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
148
frontend/src/components/ui/StatusBar.tsx
Normal file
148
frontend/src/components/ui/StatusBar.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
'use client';
|
||||
|
||||
import { useCanvasStore } from '../../store/canvasStore';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
export function StatusBar() {
|
||||
const {
|
||||
totalPixels,
|
||||
userPixels,
|
||||
activeUsers,
|
||||
viewport,
|
||||
} = useCanvasStore();
|
||||
|
||||
const formatNumber = (num: number) => {
|
||||
if (num >= 1000000) {
|
||||
return `${(num / 1000000).toFixed(1)}M`;
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return `${(num / 1000).toFixed(1)}K`;
|
||||
}
|
||||
return num.toString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Statistics Card */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4">
|
||||
<h3 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">
|
||||
Statistics
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">Total Pixels:</span>
|
||||
<motion.span
|
||||
className="text-sm font-medium text-gray-900 dark:text-white"
|
||||
key={totalPixels}
|
||||
initial={{ scale: 1.2 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{formatNumber(totalPixels)}
|
||||
</motion.span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">Your Pixels:</span>
|
||||
<motion.span
|
||||
className="text-sm font-medium text-blue-600 dark:text-blue-400"
|
||||
key={userPixels}
|
||||
initial={{ scale: 1.2 }}
|
||||
animate={{ scale: 1 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
{formatNumber(userPixels)}
|
||||
</motion.span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">Zoom:</span>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{Math.round(viewport.zoom * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">Position:</span>
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
({Math.round(viewport.x)}, {Math.round(viewport.y)})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Users Card */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Active Users
|
||||
</h3>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-2 h-2 bg-green-400 rounded-full animate-pulse" />
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{activeUsers.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 max-h-32 overflow-y-auto scrollbar-hide">
|
||||
<AnimatePresence>
|
||||
{activeUsers.map((userId, index) => (
|
||||
<motion.div
|
||||
key={userId}
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: 20 }}
|
||||
transition={{ duration: 0.2, delay: index * 0.05 }}
|
||||
className="flex items-center space-x-2 py-1"
|
||||
>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{
|
||||
backgroundColor: `hsl(${userId.slice(-6).split('').reduce((a, c) => a + c.charCodeAt(0), 0) % 360}, 70%, 50%)`
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400 truncate">
|
||||
{userId.startsWith('Guest_') ? userId : `User ${userId.slice(0, 8)}...`}
|
||||
</span>
|
||||
</motion.div>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{activeUsers.length === 0 && (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-500 text-center py-2">
|
||||
No other users online
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Help Card */}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-4">
|
||||
<h3 className="text-lg font-semibold mb-3 text-gray-900 dark:text-white">
|
||||
Controls
|
||||
</h3>
|
||||
|
||||
<div className="space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<div className="flex justify-between">
|
||||
<span>Place pixel:</span>
|
||||
<span className="font-mono">Click</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Pan canvas:</span>
|
||||
<span className="font-mono">Ctrl + Drag</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Zoom:</span>
|
||||
<span className="font-mono">Mouse wheel</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Reset view:</span>
|
||||
<span className="font-mono">🏠 button</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
146
frontend/src/components/ui/Toolbar.tsx
Normal file
146
frontend/src/components/ui/Toolbar.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
'use client';
|
||||
|
||||
import { useCanvasStore } from '../../store/canvasStore';
|
||||
import { useTheme } from '../ThemeProvider';
|
||||
|
||||
export function Toolbar() {
|
||||
const {
|
||||
selectedTool,
|
||||
setSelectedTool,
|
||||
brushSize,
|
||||
setBrushSize,
|
||||
showGrid,
|
||||
showCursors,
|
||||
viewport,
|
||||
setZoom,
|
||||
setViewport,
|
||||
} = useCanvasStore();
|
||||
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
const tools = [
|
||||
{ id: 'pixel', name: 'Pixel', icon: '🖊️' },
|
||||
{ id: 'fill', name: 'Fill', icon: '🪣' },
|
||||
{ id: 'eyedropper', name: 'Eyedropper', icon: '💉' },
|
||||
] as const;
|
||||
|
||||
const handleZoomIn = () => {
|
||||
setZoom(viewport.zoom * 1.2);
|
||||
};
|
||||
|
||||
const handleZoomOut = () => {
|
||||
setZoom(viewport.zoom * 0.8);
|
||||
};
|
||||
|
||||
const handleResetView = () => {
|
||||
setViewport({ x: 0, y: 0, zoom: 1 });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed top-6 left-1/2 transform -translate-x-1/2 z-40 flex flex-wrap items-center gap-2 md:gap-3 bg-gradient-to-r from-gray-900/95 to-black/90 backdrop-blur-xl rounded-2xl p-2 md:p-4 border border-white/10 shadow-2xl max-w-[calc(100vw-2rem)]">
|
||||
{/* Tools */}
|
||||
<div className="flex items-center gap-1 bg-white/5 rounded-xl p-1 border border-white/10">
|
||||
{tools.map((tool) => (
|
||||
<button
|
||||
key={tool.id}
|
||||
className={`px-2 md:px-3 py-1 md:py-2 rounded-lg text-xs md:text-sm font-medium transition-all duration-200 ${
|
||||
selectedTool === tool.id
|
||||
? 'bg-blue-500 text-white shadow-lg'
|
||||
: 'text-white/70 hover:text-white hover:bg-white/10'
|
||||
}`}
|
||||
onClick={() => setSelectedTool(tool.id)}
|
||||
title={tool.name}
|
||||
>
|
||||
<span className="text-base md:mr-1">{tool.icon}</span>
|
||||
<span className="hidden md:inline">{tool.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Brush size (only for pixel tool) */}
|
||||
{selectedTool === 'pixel' && (
|
||||
<div className="flex items-center gap-3 bg-white/5 rounded-xl p-3 border border-white/10">
|
||||
<span className="text-sm text-white/80 font-medium">Size:</span>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={10}
|
||||
value={brushSize}
|
||||
onChange={(e) => setBrushSize(parseInt(e.target.value))}
|
||||
className="w-20 h-2 bg-white/20 rounded-lg appearance-none cursor-pointer"
|
||||
style={{
|
||||
background: `linear-gradient(to right, #3b82f6 0%, #3b82f6 ${(brushSize - 1) * 11.11}%, rgba(255,255,255,0.2) ${(brushSize - 1) * 11.11}%, rgba(255,255,255,0.2) 100%)`
|
||||
}}
|
||||
/>
|
||||
<span className="text-sm text-white/80 font-medium min-w-[1.5rem] text-center">
|
||||
{brushSize}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Zoom controls */}
|
||||
<div className="flex items-center gap-1 bg-white/5 rounded-xl p-1 border border-white/10">
|
||||
<button
|
||||
className="px-3 py-2 rounded-lg text-white/70 hover:text-white hover:bg-white/10 transition-all duration-200"
|
||||
onClick={handleZoomOut}
|
||||
title="Zoom Out"
|
||||
>
|
||||
🔍➖
|
||||
</button>
|
||||
<div className="px-3 py-2 text-sm text-white/80 font-mono min-w-[4rem] text-center">
|
||||
{Math.round(viewport.zoom * 100)}%
|
||||
</div>
|
||||
<button
|
||||
className="px-3 py-2 rounded-lg text-white/70 hover:text-white hover:bg-white/10 transition-all duration-200"
|
||||
onClick={handleZoomIn}
|
||||
title="Zoom In"
|
||||
>
|
||||
🔍➕
|
||||
</button>
|
||||
<div className="w-px h-6 bg-white/20 mx-1" />
|
||||
<button
|
||||
className="px-3 py-2 rounded-lg text-white/70 hover:text-white hover:bg-white/10 transition-all duration-200"
|
||||
onClick={handleResetView}
|
||||
title="Reset View"
|
||||
>
|
||||
🏠
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* View options */}
|
||||
<div className="flex items-center gap-1 bg-white/5 rounded-xl p-1 border border-white/10">
|
||||
<button
|
||||
className={`px-3 py-2 rounded-lg transition-all duration-200 ${
|
||||
showGrid
|
||||
? 'bg-green-500 text-white shadow-lg'
|
||||
: 'text-white/70 hover:text-white hover:bg-white/10'
|
||||
}`}
|
||||
onClick={() => useCanvasStore.setState({ showGrid: !showGrid })}
|
||||
title="Toggle Grid"
|
||||
>
|
||||
⬜
|
||||
</button>
|
||||
<button
|
||||
className={`px-3 py-2 rounded-lg transition-all duration-200 ${
|
||||
showCursors
|
||||
? 'bg-purple-500 text-white shadow-lg'
|
||||
: 'text-white/70 hover:text-white hover:bg-white/10'
|
||||
}`}
|
||||
onClick={() => useCanvasStore.setState({ showCursors: !showCursors })}
|
||||
title="Toggle Cursors"
|
||||
>
|
||||
👁️
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Theme toggle */}
|
||||
<button
|
||||
className="px-3 py-2 rounded-xl bg-white/5 border border-white/10 text-white/70 hover:text-white hover:bg-white/10 transition-all duration-200"
|
||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||
title="Toggle Theme"
|
||||
>
|
||||
{theme === 'dark' ? '☀️' : '🌙'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
128
frontend/src/components/ui/UsernameModal.tsx
Normal file
128
frontend/src/components/ui/UsernameModal.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
|
||||
interface UsernameModalProps {
|
||||
isOpen: boolean;
|
||||
currentUsername: string;
|
||||
onUsernameChange: (username: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function UsernameModal({
|
||||
isOpen,
|
||||
currentUsername,
|
||||
onUsernameChange,
|
||||
onClose
|
||||
}: UsernameModalProps) {
|
||||
const [username, setUsername] = useState(currentUsername);
|
||||
|
||||
useEffect(() => {
|
||||
setUsername(currentUsername);
|
||||
}, [currentUsername]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (username.trim()) {
|
||||
onUsernameChange(username.trim());
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<motion.div
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-50"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<motion.div
|
||||
className="fixed top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 z-50"
|
||||
initial={{ opacity: 0, scale: 0.7, y: 30 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.7, y: 30 }}
|
||||
transition={{ type: "spring", stiffness: 300, damping: 25 }}
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="bg-white/5 backdrop-blur-2xl rounded-2xl sm:rounded-3xl p-4 sm:p-6 lg:p-8 border border-white/20 w-[95vw] max-w-[320px] sm:max-w-[350px] shadow-2xl ring-1 ring-white/10">
|
||||
<div className="text-center">
|
||||
<motion.h3
|
||||
className="text-white text-lg sm:text-xl font-bold mb-2"
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
Choose Username
|
||||
</motion.h3>
|
||||
|
||||
<motion.p
|
||||
className="text-white/70 text-xs sm:text-sm mb-4 sm:mb-6"
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.15 }}
|
||||
>
|
||||
Your username will be shown when you place pixels
|
||||
</motion.p>
|
||||
|
||||
<motion.div
|
||||
className="mb-6 sm:mb-8"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder="Enter your username..."
|
||||
className="w-full px-3 sm:px-4 py-3 sm:py-3 bg-white/5 backdrop-blur-xl border border-white/20 rounded-xl text-white text-sm sm:text-base placeholder-white/50 focus:outline-none focus:border-blue-400/60 focus:bg-white/10 transition-all duration-200 ring-1 ring-white/10 touch-manipulation"
|
||||
maxLength={20}
|
||||
autoFocus
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="flex gap-3 sm:gap-4"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.25 }}
|
||||
>
|
||||
<motion.button
|
||||
type="button"
|
||||
className="flex-1 px-4 sm:px-6 py-3 bg-white/5 backdrop-blur-xl text-white text-sm sm:text-base rounded-xl border border-white/20 hover:bg-white/10 active:bg-white/15 transition-all duration-200 font-medium ring-1 ring-white/10 touch-manipulation"
|
||||
onClick={onClose}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
Cancel
|
||||
</motion.button>
|
||||
<motion.button
|
||||
type="submit"
|
||||
className="flex-1 px-4 sm:px-6 py-3 bg-gradient-to-r from-blue-500/80 to-purple-600/80 backdrop-blur-xl text-white text-sm sm:text-base rounded-xl hover:from-blue-600/90 hover:to-purple-700/90 active:from-blue-700/90 active:to-purple-800/90 transition-all duration-200 font-medium shadow-xl ring-1 ring-white/20 disabled:opacity-50 disabled:cursor-not-allowed touch-manipulation"
|
||||
disabled={!username.trim()}
|
||||
whileHover={{
|
||||
scale: username.trim() ? 1.02 : 1,
|
||||
boxShadow: username.trim() ? "0 10px 25px rgba(0,0,0,0.3)" : undefined
|
||||
}}
|
||||
whileTap={{ scale: username.trim() ? 0.98 : 1 }}
|
||||
>
|
||||
Save
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
51
frontend/src/components/ui/ZoomControls.tsx
Normal file
51
frontend/src/components/ui/ZoomControls.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
'use client';
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
interface ZoomControlsProps {
|
||||
zoom: number;
|
||||
onZoomIn: () => void;
|
||||
onZoomOut: () => void;
|
||||
}
|
||||
|
||||
export function ZoomControls({ zoom, onZoomIn, onZoomOut }: ZoomControlsProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className="fixed bottom-4 right-4 sm:bottom-6 sm:right-6 z-40"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ type: "spring", stiffness: 400, damping: 25 }}
|
||||
>
|
||||
<div className="flex flex-col gap-2 sm:gap-3">
|
||||
{/* Zoom In Button */}
|
||||
<motion.button
|
||||
className="w-12 h-12 sm:w-14 sm:h-14 bg-white/10 backdrop-blur-2xl rounded-full border border-white/20 shadow-lg ring-1 ring-white/10 flex items-center justify-center text-white text-xl sm:text-2xl font-bold hover:bg-white/20 active:bg-white/30 transition-all duration-200 select-none touch-manipulation"
|
||||
onClick={onZoomIn}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
transition={{ type: "spring", stiffness: 400 }}
|
||||
>
|
||||
+
|
||||
</motion.button>
|
||||
|
||||
{/* Zoom Out Button */}
|
||||
<motion.button
|
||||
className="w-12 h-12 sm:w-14 sm:h-14 bg-white/10 backdrop-blur-2xl rounded-full border border-white/20 shadow-lg ring-1 ring-white/10 flex items-center justify-center text-white text-xl sm:text-2xl font-bold hover:bg-white/20 active:bg-white/30 transition-all duration-200 select-none touch-manipulation"
|
||||
onClick={onZoomOut}
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
transition={{ type: "spring", stiffness: 400 }}
|
||||
>
|
||||
−
|
||||
</motion.button>
|
||||
|
||||
{/* Zoom Display */}
|
||||
<div className="bg-white/5 backdrop-blur-2xl rounded-xl sm:rounded-2xl px-2 py-1 sm:px-3 sm:py-2 border border-white/20 shadow-lg ring-1 ring-white/10">
|
||||
<div className="text-white font-bold text-xs sm:text-sm text-center min-w-[40px] sm:min-w-[50px]">
|
||||
{Math.round(zoom * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
246
frontend/src/hooks/useWebSocket.ts
Normal file
246
frontend/src/hooks/useWebSocket.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
276
frontend/src/store/canvasStore.ts
Normal file
276
frontend/src/store/canvasStore.ts
Normal 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' }
|
||||
)
|
||||
);
|
||||
54
frontend/src/styles/globals.css
Normal file
54
frontend/src/styles/globals.css
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-white dark:bg-gray-900 text-gray-900 dark:text-white transition-colors duration-300;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.canvas-container {
|
||||
@apply relative overflow-hidden bg-white dark:bg-gray-800 rounded-lg shadow-lg;
|
||||
image-rendering: pixelated;
|
||||
image-rendering: -moz-crisp-edges;
|
||||
image-rendering: crisp-edges;
|
||||
}
|
||||
|
||||
.pixel {
|
||||
@apply absolute cursor-crosshair;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.color-picker-button {
|
||||
@apply w-8 h-8 rounded-full border-2 border-gray-300 dark:border-gray-600 cursor-pointer transition-all duration-200 hover:scale-110;
|
||||
}
|
||||
|
||||
.color-picker-button.selected {
|
||||
@apply border-4 border-blue-500 scale-110 shadow-lg;
|
||||
}
|
||||
|
||||
.tool-button {
|
||||
@apply p-2 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors duration-200;
|
||||
}
|
||||
|
||||
.tool-button.active {
|
||||
@apply bg-blue-500 text-white hover:bg-blue-600;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
55
frontend/tailwind.config.js
Normal file
55
frontend/tailwind.config.js
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
200: '#bfdbfe',
|
||||
300: '#93c5fd',
|
||||
400: '#60a5fa',
|
||||
500: '#3b82f6',
|
||||
600: '#2563eb',
|
||||
700: '#1d4ed8',
|
||||
800: '#1e40af',
|
||||
900: '#1e3a8a',
|
||||
},
|
||||
gray: {
|
||||
900: '#0f0f0f',
|
||||
800: '#1a1a1a',
|
||||
700: '#2a2a2a',
|
||||
600: '#3a3a3a',
|
||||
500: '#6a6a6a',
|
||||
400: '#9a9a9a',
|
||||
300: '#cacaca',
|
||||
200: '#e0e0e0',
|
||||
100: '#f0f0f0',
|
||||
50: '#fafafa',
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fadeIn 0.5s ease-in-out',
|
||||
'slide-up': 'slideUp 0.3s ease-out',
|
||||
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
'0%': { opacity: '0' },
|
||||
'100%': { opacity: '1' },
|
||||
},
|
||||
slideUp: {
|
||||
'0%': { transform: 'translateY(10px)', opacity: '0' },
|
||||
'100%': { transform: 'translateY(0)', opacity: '1' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
29
frontend/tsconfig.json
Normal file
29
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2017",
|
||||
"lib": ["dom", "dom.iterable", "es2017"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"exactOptionalPropertyTypes": false,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue