Initial Commit

This commit is contained in:
ExilProductions
2026-01-14 17:52:35 +01:00
parent 2d3b97c5db
commit cf066ef305
28 changed files with 1922 additions and 0 deletions

17
.env.example Normal file
View File

@@ -0,0 +1,17 @@
NEXTAUTH_SECRET=your-very-long-secret-here
# GitHub OAuth Configuration
# Create OAuth app at: https://github.com/settings/applications/new
# Homepage URL: http://localhost:3000
# Callback URL: http://localhost:3000/auth/github/callback
GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secret
# Database Configuration
DATABASE_URL=sqlite:./data/invicanvas.db
# Server Configuration
NODE_ENV=development
PORT=3000

18
.gitignore vendored Normal file
View File

@@ -0,0 +1,18 @@
node_modules
dist
*.log
.env
.env.local
.env.production
.DS_Store
client/node_modules
client/dist
client/.env
client/.env.local
client/.env.production
server/data
*.db
*.db-shm
*.db-wal
logs/
*.log

13
client/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>InviCanvas - Collaborative Pixel Art</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

23
client/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "invicanvas-client",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"socket.io-client": "^4.7.2"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1",
"typescript": "^5.3.3",
"vite": "^5.0.8"
}
}

65
client/src/App.tsx Normal file
View File

@@ -0,0 +1,65 @@
import { useState, useEffect } from 'react';
import { SocketProvider, useSocket } from './context/SocketContext';
import { AuthProvider } from './context/AuthContext';
import Canvas from './components/Canvas';
import Controls from './components/Controls';
function App() {
const { cursors, sendCursorMove } = useSocket();
const [brushColor, setBrushColor] = useState('#ffffff');
const [brushSize, setBrushSize] = useState(1);
const [currentCanvasPosition, setCurrentCanvasPosition] = useState({ x: 0, y: 0 });
const [targetPosition, setTargetPosition] = useState<{ x: number; y: number } | null>(null);
// Parse URL hash for shared location
useEffect(() => {
const hash = window.location.hash;
if (hash && hash.includes('x=') && hash.includes('y=')) {
const params = new URLSearchParams(hash.substring(1));
const x = parseInt(params.get('x') || '0');
const y = parseInt(params.get('y') || '0');
setTargetPosition({ x, y });
}
}, []);
return (
<>
<Canvas
cursors={cursors}
onCursorMove={sendCursorMove}
onCanvasPositionChange={(x, y) => setCurrentCanvasPosition({ x, y })}
targetPosition={targetPosition}
brushColor={brushColor}
brushSize={brushSize}
/>
<Controls
brushColor={brushColor}
brushSize={brushSize}
onBrushColorChange={setBrushColor}
onBrushSizeChange={setBrushSize}
canvasX={currentCanvasPosition.x}
canvasY={currentCanvasPosition.y}
/>
<div style={{
position: 'fixed',
top: '20px',
left: '20px',
color: '#888',
fontSize: '0.75rem',
zIndex: 100
}}>
<div>Scroll to zoom Middle-click to pan Left-click to paint</div>
</div>
</>
);
}
export default function AppWrapper() {
return (
<AuthProvider>
<SocketProvider>
<App />
</SocketProvider>
</AuthProvider>
);
}

View File

@@ -0,0 +1,301 @@
import { useEffect, useRef, useState } from 'react';
import { useSocket } from '../context/SocketContext';
import { useAuth } from '../context/AuthContext';
import { Pixel } from '@shared/types';
interface CanvasProps {
cursors: any[];
onCursorMove: (x: number, y: number) => void;
brushColor: string;
brushSize: number;
onCanvasPositionChange?: (x: number, y: number) => void;
targetPosition?: { x: number; y: number } | null;
}
export default function Canvas({
cursors,
onCursorMove,
brushColor,
brushSize,
onCanvasPositionChange
}: CanvasProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const previewCanvasRef = useRef<HTMLCanvasElement>(null);
const ctxRef = useRef<CanvasRenderingContext2D | null>(null);
const { sendPaintPixels, socket } = useSocket();
const { user, showLoginPrompt } = useAuth();
const pixelsRef = useRef<Map<string, Pixel>>(new Map());
const animationFrameRef = useRef<number>();
const mouseScreenRef = useRef({ x: 0, y: 0 });
const lastPanRef = useRef({ x: 0, y: 0 });
const transformRef = useRef({ x: 0, y: 0, scale: 1 });
const [isDragging, setIsDragging] = useState(false);
const [isPanning, setIsPanning] = useState(false);
const cursorsRef = useRef<any[]>(cursors);
const brushColorRef = useRef(brushColor);
const brushSizeRef = useRef(brushSize);
const userRef = useRef(user);
useEffect(() => { cursorsRef.current = cursors; }, [cursors]);
useEffect(() => { brushColorRef.current = brushColor; }, [brushColor]);
useEffect(() => { brushSizeRef.current = brushSize; }, [brushSize]);
useEffect(() => { userRef.current = user; }, [user]);
useEffect(() => {
const canvas = canvasRef.current;
const previewCanvas = previewCanvasRef.current;
if (!canvas) return;
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
ctxRef.current = canvas.getContext('2d');
if (previewCanvas) {
previewCanvas.width = window.innerWidth;
previewCanvas.height = window.innerHeight;
}
const resize = () => {
canvas.width = window.innerWidth;
canvas.height = window.innerHeight;
if (previewCanvas) {
previewCanvas.width = window.innerWidth;
previewCanvas.height = window.innerHeight;
}
};
window.addEventListener('resize', resize);
if (socket) {
socket.on('canvas-state', (pixels: Pixel[]) => {
pixelsRef.current.clear();
for (const p of pixels) {
pixelsRef.current.set(`${p.x},${p.y}`, p);
}
});
}
return () => {
window.removeEventListener('resize', resize);
};
}, [socket]);
const pixelSize = () => 10 * transformRef.current.scale;
const screenToWorld = (sx: number, sy: number) => {
const { x, y, scale } = transformRef.current;
const size = 10 * scale;
return {
x: Math.floor((sx - x) / size),
y: Math.floor((sy - y) / size)
};
};
const drawGrid = (ctx: CanvasRenderingContext2D) => {
const size = pixelSize();
const { x, y } = transformRef.current;
ctx.strokeStyle = '#333';
ctx.lineWidth = 0.5;
const startX = Math.floor(-x / size) * size + x;
const startY = Math.floor(-y / size) * size + y;
for (let gx = startX; gx < ctx.canvas.width; gx += size) {
ctx.beginPath();
ctx.moveTo(gx, 0);
ctx.lineTo(gx, ctx.canvas.height);
ctx.stroke();
}
for (let gy = startY; gy < ctx.canvas.height; gy += size) {
ctx.beginPath();
ctx.moveTo(0, gy);
ctx.lineTo(ctx.canvas.width, gy);
ctx.stroke();
}
};
const drawPixels = (ctx: CanvasRenderingContext2D) => {
const size = pixelSize();
const { x, y } = transformRef.current;
for (const p of pixelsRef.current.values()) {
ctx.fillStyle = p.color;
ctx.fillRect(p.x * size + x, p.y * size + y, size, size);
}
};
const drawPreview = (ctx: CanvasRenderingContext2D) => {
const size = pixelSize();
const { x, y } = transformRef.current;
const { x: mx, y: my } = mouseScreenRef.current;
const { x: wx, y: wy } = screenToWorld(mx, my);
const radius = brushSizeRef.current / 2;
ctx.save();
ctx.globalAlpha = 0.5;
ctx.fillStyle = brushColorRef.current;
for (let dx = Math.ceil(-radius); dx <= Math.floor(radius); dx++) {
for (let dy = Math.ceil(-radius); dy <= Math.floor(radius); dy++) {
if (Math.hypot(dx, dy) > radius) continue;
ctx.fillRect((wx + dx) * size + x, (wy + dy) * size + y, size, size);
}
}
ctx.restore();
};
const drawCursors = (ctx: CanvasRenderingContext2D) => {
const size = pixelSize();
const { x, y, scale } = transformRef.current;
for (const c of cursorsRef.current) {
if (userRef.current && c.userId === userRef.current.id.toString()) continue;
const sx = c.x * size + x;
const sy = c.y * size + y;
ctx.fillStyle = c.color;
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(sx + 15 * scale, sy + 5 * scale);
ctx.lineTo(sx + 5 * scale, sy + 15 * scale);
ctx.closePath();
ctx.fill();
}
};
const render = () => {
const ctx = ctxRef.current;
if (!ctx) return;
const previewCtx = previewCanvasRef.current?.getContext('2d');
ctx.fillStyle = '#1a1a1a';
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
drawGrid(ctx);
drawPixels(ctx);
drawCursors(ctx);
if (previewCtx) {
previewCtx.clearRect(0, 0, previewCtx.canvas.width, previewCtx.canvas.height);
if (isDragging) {
drawPreview(previewCtx);
}
}
animationFrameRef.current = requestAnimationFrame(render);
};
useEffect(() => {
render();
return () => {
if (animationFrameRef.current) cancelAnimationFrame(animationFrameRef.current);
};
}, []);
const paintPixel = () => {
if (!user) {
showLoginPrompt();
return;
}
const { x, y } = screenToWorld(mouseScreenRef.current.x, mouseScreenRef.current.y);
const pixels: Pixel[] = [];
const radius = brushSizeRef.current / 2;
for (let dx = Math.ceil(-radius); dx <= Math.floor(radius); dx++) {
for (let dy = Math.ceil(-radius); dy <= Math.floor(radius); dy++) {
if (Math.hypot(dx, dy) > radius) continue;
const p = { x: x + dx, y: y + dy, color: brushColorRef.current };
pixels.push(p);
pixelsRef.current.set(`${p.x},${p.y}`, p);
}
}
sendPaintPixels(pixels);
};
const handleMouseDown = (e: React.MouseEvent) => {
if (e.button === 1) {
setIsPanning(true);
lastPanRef.current = { x: e.clientX, y: e.clientY };
e.preventDefault();
return;
}
if (e.button === 0) {
setIsDragging(true);
paintPixel();
}
};
const handleMouseMove = (e: React.MouseEvent) => {
mouseScreenRef.current = { x: e.clientX, y: e.clientY };
const world = screenToWorld(e.clientX, e.clientY);
onCursorMove(world.x, world.y);
if (onCanvasPositionChange) onCanvasPositionChange(world.x, world.y);
if (isPanning) {
const dx = e.clientX - lastPanRef.current.x;
const dy = e.clientY - lastPanRef.current.y;
const current = transformRef.current;
transformRef.current = {
...current,
x: current.x + dx,
y: current.y + dy
};
lastPanRef.current = { x: e.clientX, y: e.clientY };
}
if (isDragging) {
paintPixel();
}
};
const handleMouseUp = () => {
setIsDragging(false);
setIsPanning(false);
};
const handleWheel = (e: React.WheelEvent) => {
e.preventDefault();
const zoom = e.deltaY > 0 ? 0.9 : 1.1;
const current = transformRef.current;
const newScale = Math.min(10, Math.max(0.1, current.scale * zoom));
const mx = e.clientX;
const my = e.clientY;
const wx = (mx - current.x) / current.scale;
const wy = (my - current.y) / current.scale;
transformRef.current = {
scale: newScale,
x: mx - wx * newScale,
y: my - wy * newScale
};
};
return (
<>
<canvas
ref={canvasRef}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onWheel={handleWheel}
style={{
position: 'fixed',
inset: 0,
cursor: isPanning ? 'grabbing' : 'crosshair'
}}
/>
<canvas
ref={previewCanvasRef}
style={{
position: 'fixed',
inset: 0,
pointerEvents: 'none'
}}
/>
</>
);
}

View File

@@ -0,0 +1,193 @@
import { useState } from 'react';
import { useAuth } from '../context/AuthContext';
import { ShareLinkModal } from './ShareLinkModal';
interface ControlsProps {
brushColor: string;
brushSize: number;
onBrushColorChange: (color: string) => void;
onBrushSizeChange: (size: number) => void;
canvasX?: number;
canvasY?: number;
}
export default function Controls({
brushColor,
brushSize,
onBrushColorChange,
onBrushSizeChange,
canvasX,
canvasY
}: ControlsProps) {
const { user, logout } = useAuth();
const [showColorPicker, setShowColorPicker] = useState(false);
const [showShareModal, setShowShareModal] = useState(false);
const colors = [
'#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF',
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7', '#DDA0DD',
'#FFFFFF', '#000000', '#808080', '#FFA500', '#800080', '#008000',
'#FFD700', '#FFC0CB', '#A52A2A', '#808000', '#008080', '#800000'
];
return (
<>
<div
style={{
position: 'fixed',
bottom: '20px',
left: '50%',
transform: 'translateX(-50%)',
background: '#2a2a2a',
padding: '1rem',
borderRadius: '12px',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.4)',
display: 'flex',
alignItems: 'center',
gap: '1rem',
zIndex: 100,
maxWidth: '90vw'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', position: 'relative' }}>
<div
onClick={() => setShowColorPicker((v) => !v)}
style={{
width: '36px',
height: '36px',
borderRadius: '6px',
background: brushColor,
border: '2px solid #555',
cursor: 'pointer',
transition: 'transform 0.2s'
}}
onMouseOver={(e) => (e.currentTarget.style.transform = 'scale(1.1)')}
onMouseOut={(e) => (e.currentTarget.style.transform = 'scale(1)')}
/>
{showColorPicker && (
<div
style={{
position: 'absolute',
bottom: '50px',
left: 0,
background: '#2a2a2a',
padding: '0.75rem',
borderRadius: '8px',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.4)',
display: 'grid',
gridTemplateColumns: 'repeat(6, 1fr)',
gap: '0.5rem',
zIndex: 101
}}
>
{colors.map((color) => (
<div
key={color}
onClick={() => {
onBrushColorChange(color);
setShowColorPicker(false);
}}
style={{
width: '28px',
height: '28px',
borderRadius: '4px',
background: color,
cursor: 'pointer',
border: brushColor === color ? '2px solid #fff' : '1px solid #555',
transition: 'transform 0.2s'
}}
onMouseOver={(e) => (e.currentTarget.style.transform = 'scale(1.1)')}
onMouseOut={(e) => (e.currentTarget.style.transform = 'scale(1)')}
/>
))}
</div>
)}
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0 0.5rem',
borderLeft: '1px solid #333',
borderRight: '1px solid #333'
}}
>
<span style={{ color: '#888', fontSize: '0.75rem' }}>Size:</span>
<input
type="range"
min={1}
max={10}
value={brushSize}
onChange={(e) => onBrushSizeChange(parseInt(e.target.value, 10))}
style={{ width: '80px' }}
/>
<span style={{ color: '#fff', fontSize: '0.75rem', minWidth: '20px' }}>
{brushSize}
</span>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button
onClick={() => {
onBrushColorChange('#ffffff');
onBrushSizeChange(1);
}}
style={{
padding: '0.5rem 0.75rem',
background: '#1a1a1a',
border: '1px solid #333',
borderRadius: '6px',
color: '#fff',
fontSize: '0.75rem',
cursor: 'pointer'
}}
>
Reset
</button>
<button
onClick={() => setShowShareModal(true)}
style={{
padding: '0.5rem 0.75rem',
background: '#17a2b8',
border: 'none',
borderRadius: '6px',
color: '#fff',
fontSize: '0.75rem',
cursor: 'pointer'
}}
>
Share Location
</button>
{user && (
<button
onClick={logout}
style={{
padding: '0.5rem 0.75rem',
background: '#FF6B6B',
border: 'none',
borderRadius: '6px',
color: '#fff',
fontSize: '0.75rem',
cursor: 'pointer'
}}
>
Logout
</button>
)}
</div>
</div>
<ShareLinkModal
isOpen={showShareModal}
onClose={() => setShowShareModal(false)}
x={canvasX ?? Math.floor(window.innerWidth / 20)}
y={canvasY ?? Math.floor(window.innerHeight / 20)}
/>
</>
);
}

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { useAuth } from '../context/AuthContext';
export const LoginButton: React.FC = () => {
const { user, loading } = useAuth();
if (loading) {
return <div>Loading...</div>;
}
if (user) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<img
src={user.avatar_url}
alt={user.display_name}
style={{
width: '32px',
height: '32px',
borderRadius: '50%'
}}
/>
</div>
);
}
return null; // this makes the login button not apear when we alr logged in
};

View File

@@ -0,0 +1,94 @@
import React from 'react';
import { useAuth } from '../context/AuthContext';
interface LoginModalProps {
isOpen: boolean;
}
export const LoginModal: React.FC<LoginModalProps> = ({ isOpen }) => {
const { login } = useAuth();
if (!isOpen) return null;
return (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.8)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 9999,
backdropFilter: 'blur(4px)'
}}>
<div style={{
backgroundColor: '#2a2a2a',
padding: '40px',
borderRadius: '16px',
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.5)',
textAlign: 'center',
maxWidth: '400px',
width: '90%',
border: '2px solid #444'
}}>
<h2 style={{
color: '#fff',
marginBottom: '16px',
fontSize: '24px',
fontWeight: 'bold'
}}>
Login Required to Draw
</h2>
<p style={{
color: '#ccc',
marginBottom: '30px',
lineHeight: '1.5',
fontSize: '16px'
}}>
You need to be logged in with GitHub to paint on the canvas. Your drawing attempts won't be saved until you authenticate.
</p>
<button
onClick={login}
style={{
width: '100%',
padding: '16px 24px',
backgroundColor: '#24292e',
color: 'white',
border: 'none',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '16px',
fontWeight: 'bold',
alignItems: 'center',
justifyContent: 'center',
transition: 'transform 0.2s, backgroundColor 0.2s'
}}
onMouseOver={(e) => {
e.currentTarget.style.transform = 'scale(1.05)';
e.currentTarget.style.backgroundColor = '#1a1a1a';
}}
onMouseOut={(e) => {
e.currentTarget.style.transform = 'scale(1)';
e.currentTarget.style.backgroundColor = '#24292e';
}}
>
Login with GitHub
</button>
<p style={{
color: '#666',
fontSize: '12px',
marginTop: '20px',
margin: 0
}}>
This modal will close automatically after you log in
</p>
</div>
</div>
);
};

View File

@@ -0,0 +1,158 @@
import React, { useState } from 'react';
interface ShareLinkModalProps {
isOpen: boolean;
onClose: () => void;
x: number;
y: number;
}
export const ShareLinkModal: React.FC<ShareLinkModalProps> = ({ isOpen, onClose, x, y }) => {
const [copied, setCopied] = useState(false);
const shareUrl = `${window.location.origin}${window.location.pathname}#x=${x}&y=${y}`;
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(shareUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error('Failed to copy:', error);
}
};
if (!isOpen) return null;
return (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.6)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 9998,
backdropFilter: 'blur(4px)'
}}>
<div style={{
backgroundColor: '#2a2a2a',
padding: '30px',
borderRadius: '12px',
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.5)',
textAlign: 'center',
maxWidth: '500px',
width: '90%',
border: '2px solid #444'
}}>
<h2 style={{
color: '#fff',
marginBottom: '12px',
fontSize: '20px',
fontWeight: 'bold'
}}>
Share Canvas Location
</h2>
<p style={{
color: '#ccc',
marginBottom: '20px',
lineHeight: '1.4',
fontSize: '14px'
}}>
Coordinates: ({x}, {y})
</p>
<div style={{
backgroundColor: '#1a1a1a',
padding: '12px',
borderRadius: '8px',
marginBottom: '20px',
border: '1px solid #333',
wordBreak: 'break-all'
}}>
<p style={{
color: '#fff',
fontSize: '12px',
margin: 0,
fontFamily: 'monospace'
}}>
{shareUrl}
</p>
</div>
<div style={{
display: 'flex',
gap: '12px',
justifyContent: 'center'
}}>
<button
onClick={copyToClipboard}
style={{
padding: '12px 24px',
backgroundColor: copied ? '#28a745' : '#24292e',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 'bold',
transition: 'transform 0.2s, backgroundColor 0.2s'
}}
onMouseOver={(e) => {
if (!copied) {
e.currentTarget.style.transform = 'scale(1.05)';
e.currentTarget.style.backgroundColor = '#1a1a1a';
}
}}
onMouseOut={(e) => {
if (!copied) {
e.currentTarget.style.transform = 'scale(1)';
e.currentTarget.style.backgroundColor = '#24292e';
}
}}
>
{copied ? 'Copied!' : 'Copy Link'}
</button>
<button
onClick={onClose}
style={{
padding: '12px 24px',
backgroundColor: '#6c757d',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: 'bold',
transition: 'transform 0.2s, backgroundColor 0.2s'
}}
onMouseOver={(e) => {
e.currentTarget.style.transform = 'scale(1.05)';
e.currentTarget.style.backgroundColor = '#5a6268';
}}
onMouseOut={(e) => {
e.currentTarget.style.transform = 'scale(1)';
e.currentTarget.style.backgroundColor = '#6c757d';
}}
>
Close
</button>
</div>
<p style={{
color: '#888',
fontSize: '12px',
marginTop: '16px',
margin: 0
}}>
Anyone with this link will see the cursor pointing to this location when they visit
</p>
</div>
</div>
);
};

View File

@@ -0,0 +1,87 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { LoginModal } from '../components/LoginModal';
interface User {
id: number;
github_id: string;
username: string;
display_name: string;
avatar_url: string;
email: string;
}
interface AuthContextType {
user: User | null;
login: () => void;
logout: () => Promise<void>;
loading: boolean;
showLoginPrompt: () => void;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
};
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [showLoginModal, setShowLoginModal] = useState(false);
useEffect(() => {
checkAuthStatus();
}, []);
const checkAuthStatus = async () => {
try {
const response = await fetch('/api/user', {
credentials: 'include'
});
if (response.ok) {
const userData = await response.json();
setUser(userData);
setShowLoginModal(false); // Close modal on auth
} else {
setUser(null);
}
} catch (error) {
console.error('Auth check failed:', error);
setUser(null);
} finally {
setLoading(false);
}
};
const login = () => {
window.location.href = '/auth/github';
};
const logout = async () => {
try {
await fetch('/api/logout', {
method: 'POST',
credentials: 'include'
});
setUser(null);
} catch (error) {
console.error('Logout failed:', error);
}
};
const showLoginPrompt = () => {
setShowLoginModal(true);
};
return (
<AuthContext.Provider value={{ user, login, logout, loading, showLoginPrompt }}>
{children}
<LoginModal isOpen={showLoginModal} />
</AuthContext.Provider>
);
};

View File

@@ -0,0 +1,122 @@
import { io, Socket } from 'socket.io-client';
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import { useAuth } from './AuthContext';
import { Pixel, Cursor } from '../types';
interface SocketContextType {
socket: Socket | null;
isConnected: boolean;
cursors: Cursor[];
sendCursorMove: (x: number, y: number) => void;
sendPaintPixels: (pixels: Pixel[]) => void;
}
const SocketContext = createContext<SocketContextType | undefined>(undefined);
export function SocketProvider({ children }: { children: ReactNode }) {
const [socket, setSocket] = useState<Socket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [cursors, setCursors] = useState<Cursor[]>([]);
const { user } = useAuth();
useEffect(() => {
// Only connect sock if user is authed
if (!user) {
setSocket(null);
setIsConnected(false);
return;
}
// Get socket auth token first
fetch('/api/socket-token', {
credentials: 'include'
})
.then(res => {
if (!res.ok) {
throw new Error('Not authenticated');
}
return res.json();
})
.then(data => {
if (data.token) {
// Conn with token
const newSocket = io('', {
transports: ['websocket']
});
newSocket.on('connect', () => {
setIsConnected(true);
// auth when logged in
newSocket.emit('join-canvas', data.token);
});
newSocket.on('disconnect', () => {
setIsConnected(false);
});
newSocket.on('canvas-state', (pixels: Pixel[]) => {
window.dispatchEvent(new CustomEvent('canvas-state', { detail: pixels }));
});
newSocket.on('canvas-update', (update: { pixels: Pixel[] }) => {
window.dispatchEvent(new CustomEvent('canvas-update', { detail: update.pixels }));
});
newSocket.on('cursor-update', (updatedCursors: Cursor[]) => {
setCursors(updatedCursors);
});
newSocket.on('error', (message: string) => {
console.error('Socket error:', message);
// If auth fails we disconnect and retry after delay
if (message.includes('Authentication required') || message.includes('expired')) {
newSocket.disconnect();
setTimeout(() => {
// Trigger reconn
}, 2000);
}
});
newSocket.on('auth-success', () => {
});
setSocket(newSocket);
return () => {
newSocket.disconnect();
};
}
})
.catch(error => {
console.error('Failed to get socket token:', error);
setSocket(null);
setIsConnected(false);
});
}, [user]);
const sendCursorMove = (x: number, y: number) => {
if (socket && isConnected) {
socket.emit('cursor-move', { x, y });
}
};
const sendPaintPixels = (pixels: Pixel[]) => {
if (socket && isConnected) {
socket.emit('paint-pixels', pixels);
}
};
return (
<SocketContext.Provider value={{ socket, isConnected, cursors, sendCursorMove, sendPaintPixels }}>
{children}
</SocketContext.Provider>
);
}
export function useSocket() {
const context = useContext(SocketContext);
if (context === undefined) {
throw new Error('useSocket must be used within a SocketProvider');
}
return context;
}

20
client/src/index.css Normal file
View File

@@ -0,0 +1,20 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow: hidden;
background: #1a1a1a;
}
#root {
width: 100vw;
height: 100vh;
}

10
client/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

16
client/src/types.ts Normal file
View File

@@ -0,0 +1,16 @@
export interface Cursor {
userId: string;
x: number;
y: number;
username: string;
}
export interface Pixel {
x: number;
y: number;
color: string;
userId?: string;
timestamp?: string;
}

26
client/tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@shared/*": ["../shared/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
client/tsconfig.node.json Normal file
View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

22
client/vite.config.ts Normal file
View File

@@ -0,0 +1,22 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
'@shared': path.resolve(__dirname, '../shared')
}
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true
}
}
}
})

54
package.json Normal file
View File

@@ -0,0 +1,54 @@
{
"name": "invicanvas",
"version": "1.0.0",
"description": "Collaborative pixel-art web game",
"main": "server/index.js",
"scripts": {
"dev": "PORT=3000 concurrently \"PORT=3000 npm run server\" \"npm run client\"",
"server": "tsx watch server/index.ts",
"client": "cd client && npm run dev",
"build": "npm run build:client",
"build:client": "cd client && npm run build",
"build:all": "npm run build:client && npx tsc",
"start": "npx tsx server/index.ts",
"start:prod": "node dist/server/index.js"
},
"keywords": [
"pixel-art",
"collaborative",
"canvas"
],
"author": "",
"license": "MIT",
"dependencies": {
"@types/connect-sqlite3": "^0.9.6",
"@types/cookie-parser": "^1.4.10",
"@types/express-session": "^1.18.2",
"@types/express-socket.io-session": "^1.3.9",
"@types/passport": "^1.0.17",
"@types/passport-github2": "^1.2.9",
"better-sqlite3": "^11.7.0",
"connect-sqlite3": "^0.9.16",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-rate-limit": "^7.1.5",
"express-session": "^1.18.2",
"express-socket.io-session": "^1.3.5",
"helmet": "^7.1.0",
"passport": "^0.7.0",
"passport-github2": "^0.1.12",
"socket.io": "^4.7.2",
"winston": "^3.11.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.10.5",
"concurrently": "^8.2.2",
"tsx": "^4.7.0",
"typescript": "^5.3.3"
}
}

53
server/auth.ts Normal file
View File

@@ -0,0 +1,53 @@
import passport from 'passport';
import { Strategy as GitHubStrategy } from 'passport-github2';
import { db } from './database';
passport.serializeUser((user: any, done) => {
done(null, user.id);
});
passport.deserializeUser((id: number, done) => {
try {
const stmt = db.prepare('SELECT * FROM users WHERE id = ?');
const user = stmt.get(id);
done(null, user);
} catch (error) {
done(error, null);
}
});
passport.use(new GitHubStrategy({
clientID: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
callbackURL: '/auth/github/callback'
}, async (accessToken, refreshToken, profile, done) => {
try {
let user;
const existingUser = db.prepare('SELECT * FROM users WHERE github_id = ?').get(profile.id);
if (existingUser) {
user = existingUser;
} else {
const stmt = db.prepare(`
INSERT INTO users (github_id, username, display_name, avatar_url, email)
VALUES (?, ?, ?, ?, ?)
`);
const result = stmt.run(
profile.id,
profile.username,
profile.displayName || profile.username,
profile._json.avatar_url,
profile._json.email
);
user = db.prepare('SELECT * FROM users WHERE id = ?').get(result.lastInsertRowid);
}
return done(null, user);
} catch (error) {
return done(error, undefined);
}
}));
export { passport };

55
server/config.ts Normal file
View File

@@ -0,0 +1,55 @@
import dotenv from 'dotenv';
import path from 'path';
// Load environment variables from .env file
dotenv.config({ path: path.join(__dirname, '../.env') });
// Validate required environment variables
const requiredEnvVars = [
'NEXTAUTH_SECRET',
'GITHUB_CLIENT_ID',
'GITHUB_CLIENT_SECRET'
];
const missingEnvVars = requiredEnvVars.filter(envVar => !process.env[envVar]);
if (missingEnvVars.length > 0) {
console.error('Missing required environment variables:', missingEnvVars.join(', '));
console.error('Please check your .env file and ensure all required variables are set.');
process.exit(1);
}
// Warn about default secrets in production
if (process.env.NODE_ENV === 'production') {
if (process.env.NEXTAUTH_SECRET == "") {
console.error('CRITICAL: Default session secret detected in production!');
console.error('Please change NEXTAUTH_SECRET in your .env file.');
process.exit(1);
}
}
export const config = {
server: {
port: parseInt(process.env.PORT || '3000', 10),
nodeEnv: process.env.NODE_ENV || 'development'
},
security: {
sessionSecret: process.env.NEXTAUTH_SECRET!,
githubClientId: process.env.GITHUB_CLIENT_ID!,
githubClientSecret: process.env.GITHUB_CLIENT_SECRET!,
corsOrigin: process.env.NODE_ENV === 'development'
? ['http://localhost:3000', 'http://localhost:5173']
: 'http://localhost:3000'
},
rateLimit: {
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000', 10), // 15 minutes
maxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100', 10)
},
slowDown: {
windowMs: parseInt(process.env.SLOW_DOWN_WINDOW_MS || '60000', 10), // 1 minute
delayMs: parseInt(process.env.SLOW_DOWN_DELAY_MS || '500', 10) // 500ms delay
},
logging: {
level: process.env.LOG_LEVEL || 'info'
}
};

59
server/database.ts Normal file
View File

@@ -0,0 +1,59 @@
const Database = require('better-sqlite3');
import path from 'path';
import fs from 'fs';
const dbPath = path.join(__dirname, '../data/invicanvas.db');
const dbDir = path.dirname(dbPath);
if (!fs.existsSync(dbDir)) {
fs.mkdirSync(dbDir, { recursive: true });
}
export const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
github_id TEXT UNIQUE,
username TEXT NOT NULL,
display_name TEXT,
avatar_url TEXT,
email TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS pixels (
x INTEGER NOT NULL,
y INTEGER NOT NULL,
color TEXT NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (x, y)
);
CREATE INDEX IF NOT EXISTS idx_pixels_updated ON pixels(updated_at);
`);
export function generateUserId(): string {
return `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
export function upsertPixels(pixels: { x: number; y: number; color: string }[]): void {
const stmt = db.prepare(
'INSERT OR REPLACE INTO pixels (x, y, color, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)'
);
const transaction = db.transaction((pixels: any[]) => {
for (const pixel of pixels) {
stmt.run(pixel.x, pixel.y, pixel.color);
}
});
transaction(pixels);
}
export function getAllPixels(): { x: number; y: number; color: string }[] {
const stmt = db.prepare('SELECT x, y, color FROM pixels ORDER BY x, y');
return stmt.all() as { x: number; y: number; color: string }[];
}

129
server/index.ts Normal file
View File

@@ -0,0 +1,129 @@
import express, { Request, Response } from 'express';
import http from 'http';
import { Server as SocketIOServer } from 'socket.io';
import cors from 'cors';
import cookieParser from 'cookie-parser';
import session from 'express-session';
import passport from 'passport';
import path from 'path';
import { setupSocketIO } from './socket';
import { db } from './database';
import { config } from './config';
import {
securityHeaders,
createRateLimit,
requestLogger,
sanitizeInput
} from './middleware';
import { logger } from './logger';
import './auth';
const app = express();
const server = http.createServer(app);
const io = new SocketIOServer(server, {
cors: {
origin: config.security.corsOrigin,
methods: ['GET', 'POST'],
credentials: true
},
transports: ['polling', 'websocket']
});
// Security middleware
app.use(securityHeaders);
app.use(requestLogger);
app.use(sanitizeInput);
// Cookie parsing
app.use(cookieParser());
// Rate limiting
app.use('/api', createRateLimit);
// Session configuration
const SQLiteStore = require('connect-sqlite3')(session);
const sessionMiddleware = session({
store: new SQLiteStore({ db: 'sessions.db', dir: './data' }),
secret: process.env.NEXTAUTH_SECRET || 'fallback-secret-change-in-production',
resave: false,
saveUninitialized: false,
cookie: { secure: false, maxAge: 24 * 60 * 60 * 1000 } // 24 hours
});
app.use(sessionMiddleware);
// Passport initialization
app.use(passport.initialize());
app.use(passport.session());
// Body parsing
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// CORS with specific origin
app.use(cors({
origin: config.server.nodeEnv === 'development'
? ['http://localhost:3000', 'http://localhost:5173']
: config.security.corsOrigin,
credentials: true
}));
app.use(express.static(path.join(__dirname, '../client/dist')));
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// Auth routes
app.get('/auth/github', passport.authenticate('github'));
app.get('/auth/github/callback',
passport.authenticate('github', { failureRedirect: '/login' }),
(req, res) => {
res.redirect('/');
}
);
app.get('/api/user', (req, res) => {
if (req.user) {
res.json(req.user);
} else {
res.status(401).json({ error: 'Not authenticated' });
}
});
app.get('/api/socket-token', (req, res) => {
if (req.user) {
// Create a token containing user info
const user = req.user as any; // Use any to bypass typing issues
const token = Buffer.from(JSON.stringify({
userId: user.id,
username: user.username,
timestamp: Date.now()
})).toString('base64');
res.json({ token, user: req.user });
} else {
res.status(401).json({ error: 'Not authenticated' });
}
});
// SPA catch-all route
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, '../client/dist/index.html'));
});
io.use((socket: any, next: any) => {
next();
});
setupSocketIO(io);
const PORT = config.server.port;
server.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});

85
server/logger.ts Normal file
View File

@@ -0,0 +1,85 @@
import winston from 'winston';
import path from 'path';
import { config } from './config';
const logDir = path.join(__dirname, '../logs');
// Create logs directory if it doesn't exist
import fs from 'fs';
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true });
}
const logFormat = winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }),
winston.format.errors({ stack: true }),
winston.format.json()
);
const consoleFormat = winston.format.combine(
winston.format.colorize(),
winston.format.timestamp({ format: 'HH:mm:ss' }),
winston.format.printf(({ timestamp, level, message, ...meta }) => {
const metaStr = Object.keys(meta).length ? ` ${JSON.stringify(meta)}` : '';
return `${timestamp} ${level}: ${message}${metaStr}`;
})
);
// log auth attempts and failures
export const logger = winston.createLogger({
level: config.logging.level,
format: logFormat,
transports: [
// logs auth and security events
new winston.transports.File({
filename: path.join(logDir, 'security.log'),
level: 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
)
}),
// logs all errors
new winston.transports.File({
filename: path.join(logDir, 'error.log'),
level: 'error'
}),
// logs everything
new winston.transports.File({
filename: path.join(logDir, 'combined.log')
})
]
});
// console log in dev
if (config.server.nodeEnv === 'development') {
logger.add(new winston.transports.Console({
format: consoleFormat
}));
}
// Security specific logging
export const securityLogger = {
rateLimitExceeded: (ip: string, endpoint: string) => {
logger.warn('Rate limit exceeded', {
event: 'rate_limit_exceeded',
ip,
endpoint,
timestamp: new Date().toISOString()
});
},
suspiciousActivity: (ip: string, activity: string, details?: any) => {
logger.warn('Suspicious activity detected', {
event: 'suspicious_activity',
ip,
activity,
details,
timestamp: new Date().toISOString()
});
}
};
export default logger;

85
server/middleware.ts Normal file
View File

@@ -0,0 +1,85 @@
import helmet from 'helmet';
import rateLimit from 'express-rate-limit';
import { Request, Response, NextFunction } from 'express';
import { config } from './config';
import { securityLogger } from './logger';
// Security headers middleware
export const securityHeaders = helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'"],
scriptSrc: ["'self'"],
imgSrc: ["'self'", "data:", "blob:"],
connectSrc: ["'self'"],
fontSrc: ["'self'"],
objectSrc: ["'none'"],
mediaSrc: ["'self'"],
frameSrc: ["'none'"],
},
},
crossOriginEmbedderPolicy: false, // Req. for Socket.io
hsts: {
maxAge: 31536000,
includeSubDomains: true,
preload: true
}
});
// Rate limiting
export const createRateLimit = rateLimit({
windowMs: config.rateLimit.windowMs,
max: config.rateLimit.maxRequests,
message: {
error: 'Too many requests from this IP, please try again later.',
retryAfter: Math.ceil(config.rateLimit.windowMs / 1000)
},
standardHeaders: true,
legacyHeaders: false,
handler: (req: Request, res: Response) => {
securityLogger.rateLimitExceeded(
req.ip || req.connection.remoteAddress || 'unknown',
req.path
);
res.status(429).json({
error: 'Too many requests from this IP, please try again later.',
retryAfter: Math.ceil(config.rateLimit.windowMs / 1000)
});
}
});
// Request logging
export const requestLogger = (req: Request, res: Response, next: NextFunction) => {
next();
};
// Sanitize input
export const sanitizeInput = (req: Request, res: Response, next: NextFunction) => {
// Recursively sanitize string values
const sanitizeValue = (value: any): any => {
if (typeof value === 'string') {
return value
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '') // Remove script tags
.replace(/<[^>]*>/g, '') // Remove HTML tags
.trim();
}
if (Array.isArray(value)) {
return value.map(sanitizeValue);
}
if (value && typeof value === 'object') {
const sanitized: any = {};
for (const [key, val] of Object.entries(value)) {
sanitized[key] = sanitizeValue(val);
}
return sanitized;
}
return value;
};
req.body = sanitizeValue(req.body);
req.query = sanitizeValue(req.query);
req.params = sanitizeValue(req.params);
next();
};

110
server/socket.ts Normal file
View File

@@ -0,0 +1,110 @@
import { Server as SocketIOServer, Socket } from 'socket.io';
import { upsertPixels, getAllPixels, db } from './database';
import { Cursor, Pixel } from '../shared/types';
import passport from 'passport';
function generateRandomColor(): string {
const colors = [
'#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7',
'#DDA0DD', '#98D8C8', '#F7DC6F', '#BB8FCE', '#85C1E2'
];
return colors[Math.floor(Math.random() * colors.length)];
}
interface UserSocket extends Socket {
userId: string;
username: string;
color: string;
user?: any;
}
// Helper function to get user data from db
function getUserById(userId: number) {
try {
const stmt = db.prepare('SELECT * FROM users WHERE id = ?');
return stmt.get(userId);
} catch (error) {
console.error('Error fetching user:', error);
return null;
}
}
export function setupSocketIO(io: SocketIOServer): void {
const connectedUsers = new Map<string, { x: number; y: number; color: string; username: string; userId: number }>();
io.on('connection', (socket: Socket) => {
const userSocket = socket as UserSocket;
socket.on('join-canvas', (token: string) => {
try {
const decoded = JSON.parse(Buffer.from(token, 'base64').toString());
if (Date.now() - decoded.timestamp > 5 * 60 * 1000) {
socket.emit('error', 'Token expired');
socket.disconnect();
return;
}
const userData = getUserById(decoded.userId);
if (userData) {
userSocket.user = userData;
const userId = `auth_${userData.id}`;
const username = userData.username || userData.display_name;
const color = generateRandomColor();
connectedUsers.set(userId, {
x: 0,
y: 0,
color: color,
username: username,
userId: userData.id.toString()
});
socket.emit('canvas-state', getAllPixels());
socket.emit('cursor-update', Array.from(connectedUsers.values()));
socket.emit('auth-success', { user: userData });
socket.broadcast.emit('cursor-update', Array.from(connectedUsers.values()));
socket.on('cursor-move', (cursor: Cursor) => {
const user = connectedUsers.get(userId);
if (user) {
user.x = cursor.x;
user.y = cursor.y;
socket.broadcast.emit('cursor-update', Array.from(connectedUsers.values()));
}
});
socket.on('paint-pixels', async (pixels: Pixel[]) => {
try {
if (!userSocket.user) {
socket.emit('error', 'Authentication required to paint pixels');
return;
}
upsertPixels(pixels);
socket.broadcast.emit('canvas-update', { pixels });
} catch (error) {
console.error('Error saving pixels:', error);
socket.emit('error', 'Failed to save pixels');
}
});
socket.on('disconnect', () => {
connectedUsers.delete(userId);
socket.broadcast.emit('cursor-update', Array.from(connectedUsers.values()));
});
} else {
console.error('User not found for token:', decoded.userId);
socket.emit('error', 'Invalid user');
socket.disconnect();
}
} catch (error) {
console.error('Token verification failed:', error);
socket.emit('error', 'Invalid token');
socket.disconnect();
}
});
});
}

48
shared/types.ts Normal file
View File

@@ -0,0 +1,48 @@
export interface User {
id: string;
username: string;
color: string;
}
export interface Pixel {
x: number;
y: number;
color: string;
}
export interface Cursor {
userId: string;
username: string;
x: number;
y: number;
color: string;
}
export interface CanvasUpdate {
pixels: Pixel[];
}
export interface LoginRequest {
username: string;
password: string;
}
export interface RegisterRequest {
username: string;
password: string;
}
export interface AuthResponse {
token: string;
user: User;
}
export interface SocketEvents {
'join-canvas': (token: string) => void;
'cursor-move': (cursor: Cursor) => void;
'paint-pixels': (pixels: Pixel[]) => void;
'canvas-update': (update: CanvasUpdate) => void;
'cursor-update': (cursors: Cursor[]) => void;
'canvas-state': (pixels: Pixel[]) => void;
'error': (message: string) => void;
}

21
tsconfig.json Normal file
View File

@@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["server/**/*", "shared/**/*"],
"exclude": ["node_modules", "client", "dist"]
}