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

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