Browse Source

Add drawing pad feature.

Drawing pad
remove-time-limit
codegirl007 1 month ago committed by GitHub
parent
commit
1392e50ff0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 19
      backend/src/index.ts
  2. 10
      frontend/src/config/apps.ts
  3. 1
      frontend/src/pages/AdminPage.tsx
  4. 240
      frontend/src/pages/DrawingPadApp.tsx
  5. 10
      frontend/src/services/apiClient.ts

19
backend/src/index.ts

@ -2,6 +2,8 @@ import express from 'express';
import cookieParser from 'cookie-parser'; import cookieParser from 'cookie-parser';
import cors from 'cors'; import cors from 'cors';
import { createServer } from 'http'; import { createServer } from 'http';
import path from 'path';
import { fileURLToPath } from 'url';
import { validateEnv, env } from './config/env.js'; import { validateEnv, env } from './config/env.js';
import { runMigrations } from './db/migrate.js'; import { runMigrations } from './db/migrate.js';
import { createInitialAdmin } from './setup/initialSetup.js'; import { createInitialAdmin } from './setup/initialSetup.js';
@ -18,6 +20,9 @@ import { errorHandler } from './middleware/errorHandler.js';
import { apiLimiter } from './middleware/rateLimiter.js'; import { apiLimiter } from './middleware/rateLimiter.js';
import { createWebSocketServer } from './services/websocket.service.js'; import { createWebSocketServer } from './services/websocket.service.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
async function startServer() { async function startServer() {
try { try {
console.log('🚀 Starting Kiddos Backend...\n'); console.log('🚀 Starting Kiddos Backend...\n');
@ -39,8 +44,20 @@ async function startServer() {
origin: env.corsOrigin, origin: env.corsOrigin,
credentials: true credentials: true
})); }));
app.use(express.json()); app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
app.use(cookieParser()); app.use(cookieParser());
// Serve uploaded files statically (use absolute path)
const uploadsPath = path.join(__dirname, '../uploads');
app.use('/uploads', (req, res, next) => {
// Set CORS headers for image files to allow pixel data reading
// Use * for static files since they don't need credentials
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET');
next();
}, express.static(uploadsPath));
app.use('/api', apiLimiter); app.use('/api', apiLimiter);
// Health check (for DigitalOcean) // Health check (for DigitalOcean)

10
frontend/src/config/apps.ts

@ -4,6 +4,7 @@ import React, { lazy } from 'react';
const VideoApp = lazy(() => import('../pages/VideoApp').then(module => ({ default: module.VideoApp }))); const VideoApp = lazy(() => import('../pages/VideoApp').then(module => ({ default: module.VideoApp })));
const SpeechSoundsApp = lazy(() => import('../pages/SpeechSoundsApp').then(module => ({ default: module.SpeechSoundsApp }))); const SpeechSoundsApp = lazy(() => import('../pages/SpeechSoundsApp').then(module => ({ default: module.SpeechSoundsApp })));
const TicTacToeApp = lazy(() => import('../pages/TicTacToeApp').then(module => ({ default: module.TicTacToeApp }))); const TicTacToeApp = lazy(() => import('../pages/TicTacToeApp').then(module => ({ default: module.TicTacToeApp })));
const DrawingPadApp = lazy(() => import('../pages/DrawingPadApp').then(module => ({ default: module.DrawingPadApp })));
export type App = { export type App = {
id: string; id: string;
@ -42,5 +43,14 @@ export const APPS: App[] = [
link: '/tic-tac-toe', link: '/tic-tac-toe',
disabled: false, disabled: false,
component: TicTacToeApp component: TicTacToeApp
},
{
id: 'drawingpad',
name: 'Drawing Pad',
description: 'Draw and create your own artwork!',
cta: 'Start Drawing',
link: '/drawing-pad',
disabled: false,
component: DrawingPadApp
} }
]; ];

1
frontend/src/pages/AdminPage.tsx

@ -106,6 +106,7 @@ export function AdminPage() {
Create magic codes for child settings Create magic codes for child settings
</p> </p>
</Link> </Link>
</div> </div>
</div> </div>
); );

240
frontend/src/pages/DrawingPadApp.tsx

@ -0,0 +1,240 @@
import { useRef, useState, useCallback, useEffect } from 'react';
export function DrawingPadApp() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [isDrawing, setIsDrawing] = useState(false);
const [color, setColor] = useState('#000000');
const [brushSize, setBrushSize] = useState(5);
const [isEraser, setIsEraser] = useState(false);
const startDrawing = useCallback((e: React.PointerEvent<HTMLCanvasElement>) => {
setIsDrawing(true);
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
ctx.beginPath();
ctx.moveTo(x, y);
}, []);
const draw = useCallback((e: React.PointerEvent<HTMLCanvasElement>) => {
if (!isDrawing) return;
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
ctx.lineTo(x, y);
ctx.lineWidth = brushSize;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
if (isEraser) {
// Eraser mode: use destination-out to erase
ctx.globalCompositeOperation = 'destination-out';
ctx.strokeStyle = 'rgba(0,0,0,1)'; // Color doesn't matter for erasing
} else {
// Drawing mode: normal composite operation
ctx.globalCompositeOperation = 'source-over';
ctx.strokeStyle = color;
}
ctx.stroke();
}, [isDrawing, color, brushSize, isEraser]);
const stopDrawing = useCallback(() => {
setIsDrawing(false);
}, []);
const clearCanvas = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
ctx.globalCompositeOperation = 'source-over';
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Restore white background
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
}, []);
const saveDrawing = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const dataUrl = canvas.toDataURL('image/png');
const link = document.createElement('a');
link.download = `drawing-${Date.now()}.png`;
link.href = dataUrl;
link.click();
}, []);
// Set canvas size
const setupCanvas = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const newWidth = rect.width;
const newHeight = rect.height;
// Check if dimensions actually changed
if (canvas.width === newWidth && canvas.height === newHeight) {
return; // No resize needed
}
// Save current canvas content before resizing (resizing clears the canvas)
const ctx = canvas.getContext('2d');
if (!ctx) return;
let savedImageData: ImageData | null = null;
if (canvas.width > 0 && canvas.height > 0) {
savedImageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
}
// Resize canvas (this clears it)
canvas.width = newWidth;
canvas.height = newHeight;
// Restore white background
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Restore saved content if we had any
if (savedImageData) {
// Draw the saved content back (it might be smaller, so center it or scale it)
ctx.putImageData(savedImageData, 0, 0);
}
}, []);
// Set canvas size on mount and resize
const canvasRefCallback = useCallback((canvas: HTMLCanvasElement | null) => {
canvasRef.current = canvas;
if (canvas) {
setupCanvas();
}
}, [setupCanvas]);
useEffect(() => {
const handleResize = () => {
setupCanvas();
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [setupCanvas]);
const colors = [
'#000000', '#FF0000', '#00FF00', '#0000FF', '#FFFF00', '#FF00FF', '#00FFFF',
'#FFA500', '#800080', '#FFC0CB', '#A52A2A', '#808080', '#FFFFFF'
];
return (
<div className="min-h-[calc(100vh-60px)] bg-background p-4">
<div className="max-w-6xl mx-auto">
<div className="mb-4 flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm font-semibold">Color:</label>
<div className="flex gap-2">
{colors.map((c) => (
<button
key={c}
onClick={() => setColor(c)}
className={`w-8 h-8 rounded border-2 ${
color === c ? 'border-primary border-4' : 'border-gray-300'
}`}
style={{ backgroundColor: c }}
aria-label={`Select color ${c}`}
/>
))}
</div>
<input
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
className="w-10 h-10 cursor-pointer"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-sm font-semibold">Brush Size:</label>
<input
type="range"
min="1"
max="50"
value={brushSize}
onChange={(e) => setBrushSize(Number(e.target.value))}
className="w-32"
/>
<span className="text-sm w-8">{brushSize}px</span>
</div>
<div className="flex items-center gap-2">
<label className="text-sm font-semibold">Tool:</label>
<button
onClick={() => setIsEraser(false)}
className={`px-3 py-1 rounded-md text-sm font-semibold transition-all ${
!isEraser
? 'bg-primary text-primary-foreground'
: 'bg-gray-200 text-gray-800 hover:bg-gray-300'
}`}
>
🖌 Draw
</button>
<button
onClick={() => setIsEraser(!isEraser)}
className={`px-3 py-1 rounded-md text-sm font-semibold transition-all ${
isEraser
? 'bg-gray-600 text-white hover:bg-gray-700'
: 'bg-gray-200 text-gray-800 hover:bg-gray-300'
}`}
>
Eraser
</button>
</div>
<button
onClick={clearCanvas}
className="px-4 py-2 bg-destructive text-white rounded-md font-semibold text-sm hover:bg-destructive/90"
>
Clear
</button>
<button
onClick={saveDrawing}
className="px-4 py-2 bg-primary text-primary-foreground rounded-md font-semibold text-sm hover:bg-primary/90"
>
Save
</button>
</div>
<div className="bg-white border-2 border-gray-300 rounded-lg overflow-hidden">
<canvas
ref={canvasRefCallback}
className={`w-full h-[600px] touch-none ${
isEraser ? 'cursor-grab' : 'cursor-crosshair'
}`}
onPointerDown={startDrawing}
onPointerMove={draw}
onPointerUp={stopDrawing}
onPointerLeave={stopDrawing}
onPointerCancel={stopDrawing}
/>
</div>
</div>
</div>
);
}

10
frontend/src/services/apiClient.ts

@ -6,6 +6,15 @@ const api = axios.create({
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }
}); });
// Helper to create API instance without default JSON header (for FormData)
const createFormDataApi = () => {
return axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:8080/api',
withCredentials: true
// No default Content-Type - let axios set it automatically for FormData
});
};
let isRefreshing = false; let isRefreshing = false;
let failedQueue: any[] = []; let failedQueue: any[] = [];
@ -200,4 +209,3 @@ export const magicCodeApi = {
export const speechSoundsApi = { export const speechSoundsApi = {
clearPronunciationsCache: () => api.delete('/speech-sounds/cache') clearPronunciationsCache: () => api.delete('/speech-sounds/cache')
}; };

Loading…
Cancel
Save