From fb1f55c7696bf393f6b3135ad87d8fad87f03ad6 Mon Sep 17 00:00:00 2001 From: Stephanie Gredell Date: Fri, 12 Dec 2025 04:27:01 -0800 Subject: [PATCH 1/2] Add drawing pad app with eraser tool and canvas resize improvements --- frontend/src/config/apps.ts | 10 ++ frontend/src/pages/DrawingPadApp.tsx | 240 +++++++++++++++++++++++++++ 2 files changed, 250 insertions(+) create mode 100644 frontend/src/pages/DrawingPadApp.tsx diff --git a/frontend/src/config/apps.ts b/frontend/src/config/apps.ts index 8930fd7..7821940 100644 --- a/frontend/src/config/apps.ts +++ b/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 SpeechSoundsApp = lazy(() => import('../pages/SpeechSoundsApp').then(module => ({ default: module.SpeechSoundsApp }))); 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 = { id: string; @@ -42,5 +43,14 @@ export const APPS: App[] = [ link: '/tic-tac-toe', disabled: false, component: TicTacToeApp + }, + { + id: 'drawingpad', + name: 'Drawing Pad', + description: 'Draw and create your own artwork!', + cta: 'Start Drawing', + link: '/drawing-pad', + disabled: false, + component: DrawingPadApp } ]; diff --git a/frontend/src/pages/DrawingPadApp.tsx b/frontend/src/pages/DrawingPadApp.tsx new file mode 100644 index 0000000..dab0cce --- /dev/null +++ b/frontend/src/pages/DrawingPadApp.tsx @@ -0,0 +1,240 @@ +import { useRef, useState, useCallback, useEffect } from 'react'; + +export function DrawingPadApp() { + const canvasRef = useRef(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) => { + 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) => { + 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 ( +
+
+
+
+ +
+ {colors.map((c) => ( +
+ setColor(e.target.value)} + className="w-10 h-10 cursor-pointer" + /> +
+ +
+ + setBrushSize(Number(e.target.value))} + className="w-32" + /> + {brushSize}px +
+ +
+ + + +
+ + + + +
+ +
+ +
+
+
+ ); +} From 5dc6525eafe77a0ac1e4e5c34029b7e46b9245f1 Mon Sep 17 00:00:00 2001 From: Stephanie Gredell Date: Fri, 12 Dec 2025 04:27:45 -0800 Subject: [PATCH 2/2] Remove color-by-number feature references --- backend/src/index.ts | 19 ++++++++++++++++++- frontend/src/pages/AdminPage.tsx | 1 + frontend/src/services/apiClient.ts | 10 +++++++++- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/backend/src/index.ts b/backend/src/index.ts index bcc92a9..78d9134 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -2,6 +2,8 @@ import express from 'express'; import cookieParser from 'cookie-parser'; import cors from 'cors'; import { createServer } from 'http'; +import path from 'path'; +import { fileURLToPath } from 'url'; import { validateEnv, env } from './config/env.js'; import { runMigrations } from './db/migrate.js'; import { createInitialAdmin } from './setup/initialSetup.js'; @@ -18,6 +20,9 @@ import { errorHandler } from './middleware/errorHandler.js'; import { apiLimiter } from './middleware/rateLimiter.js'; import { createWebSocketServer } from './services/websocket.service.js'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + async function startServer() { try { console.log('🚀 Starting Kiddos Backend...\n'); @@ -39,8 +44,20 @@ async function startServer() { origin: env.corsOrigin, credentials: true })); - app.use(express.json()); + app.use(express.json({ limit: '10mb' })); + app.use(express.urlencoded({ extended: true, limit: '10mb' })); 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); // Health check (for DigitalOcean) diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index f662714..c05da80 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -106,6 +106,7 @@ export function AdminPage() { Create magic codes for child settings

+ ); diff --git a/frontend/src/services/apiClient.ts b/frontend/src/services/apiClient.ts index 276c8e9..ddd1c72 100644 --- a/frontend/src/services/apiClient.ts +++ b/frontend/src/services/apiClient.ts @@ -6,6 +6,15 @@ const api = axios.create({ 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 failedQueue: any[] = []; @@ -200,4 +209,3 @@ export const magicCodeApi = { export const speechSoundsApi = { clearPronunciationsCache: () => api.delete('/speech-sounds/cache') }; -