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/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/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/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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
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')
};
-