Initial Commit
This commit is contained in:
13
client/index.html
Normal file
13
client/index.html
Normal 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
23
client/package.json
Normal 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
65
client/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
301
client/src/components/Canvas.tsx
Normal file
301
client/src/components/Canvas.tsx
Normal 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'
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
193
client/src/components/Controls.tsx
Normal file
193
client/src/components/Controls.tsx
Normal 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)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
28
client/src/components/LoginButton.tsx
Normal file
28
client/src/components/LoginButton.tsx
Normal 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
|
||||
};
|
||||
94
client/src/components/LoginModal.tsx
Normal file
94
client/src/components/LoginModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
158
client/src/components/ShareLinkModal.tsx
Normal file
158
client/src/components/ShareLinkModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
87
client/src/context/AuthContext.tsx
Normal file
87
client/src/context/AuthContext.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
122
client/src/context/SocketContext.tsx
Normal file
122
client/src/context/SocketContext.tsx
Normal 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
20
client/src/index.css
Normal 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
10
client/src/main.tsx
Normal 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
16
client/src/types.ts
Normal 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
26
client/tsconfig.json
Normal 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
10
client/tsconfig.node.json
Normal 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
22
client/vite.config.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user