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:
martin 2025-08-22 19:28:05 +02:00
commit 3ce5a97422
69 changed files with 17771 additions and 1589 deletions

View file

@ -0,0 +1 @@
NEXT_PUBLIC_BACKEND_URL=http://localhost:3001

10
frontend/.eslintrc.json Normal file
View 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
View 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
View 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
View 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
View 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"
}
}

View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View 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;
}
}

View 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
View 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>
);
}

View 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>
);
}

View 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;
}
}

View 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;
};

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View file

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

View file

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

View 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;
}
}

View 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
View 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"]
}