From c97b8fd636fe6df483f7c1a1dae5ac7b2d751bcf Mon Sep 17 00:00:00 2001 From: natnaelat Date: Sun, 26 Oct 2025 18:31:27 -0400 Subject: [PATCH 01/17] Add image upload feature --- backend/src/routers/images.py | 118 ++++++++++++-- .../src/components/ImageUploadSection.tsx | 151 ++++++++++++++++++ frontend/src/pages/BookEditor.tsx | 15 +- 3 files changed, 263 insertions(+), 21 deletions(-) create mode 100644 frontend/src/components/ImageUploadSection.tsx diff --git a/backend/src/routers/images.py b/backend/src/routers/images.py index 207bb381..235a93f7 100644 --- a/backend/src/routers/images.py +++ b/backend/src/routers/images.py @@ -1,5 +1,8 @@ from io import BytesIO from typing import Annotated +import uuid +import imghdr +from pathlib import Path from fastapi import APIRouter, Depends, File, HTTPException, UploadFile from minio import Minio @@ -15,49 +18,140 @@ access_key=settings.MINIO_ROOT_USER, secret_key=settings.MINIO_ROOT_PASSWORD, secure=False, -) # Secure=False indicates the connection is not TLS/SSL +) + +# Allowed image formats +ALLOWED_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'} +MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB + + +def validate_image(contents: bytes, filename: str) -> tuple[bool, str]: + """Validate that the file is actually an image""" + # Check file extension + extension = Path(filename).suffix.lower() + if extension not in ALLOWED_EXTENSIONS: + return False, f"File type not allowed. Allowed types: {', '.join(ALLOWED_EXTENSIONS)}" + + # Check file size + if len(contents) > MAX_FILE_SIZE: + return False, f"File size exceeds {MAX_FILE_SIZE / (1024*1024)}MB limit" + + # Verify file content matches extension (for common formats) + if extension != '.svg': # SVG can't be validated with imghdr + image_type = imghdr.what(None, contents) + if image_type is None: + return False, "File content does not appear to be a valid image" + + # Map imghdr types to extensions + type_map = {'jpeg': ['.jpg', '.jpeg'], + 'png': ['.png'], 'gif': ['.gif']} + if image_type in type_map: + if extension not in type_map[image_type]: + return False, f"File extension {extension} does not match content type {image_type}" + + return True, "" @image_router.post("/images", tags=["images"]) async def upload_image( - user: Annotated[User, Depends(get_user)], image: UploadFile = File(...) + user: Annotated[User, Depends(get_user)], + image: UploadFile = File(...) ) -> Image: - try: - bucket_name = settings.MINIO_DEFAULT_BUCKET - name = image.filename or f"image-{image.content_type}" + # Read file contents contents = await image.read() + + # Validate image + is_valid, error_msg = validate_image(contents, image.filename or "") + if not is_valid: + raise HTTPException(status_code=400, detail=error_msg) + + # Generate secure filename with UUID + original_extension = Path(image.filename or "").suffix.lower() + secure_filename = f"{uuid.uuid4()}{original_extension}" + + # Upload to MinIO + bucket_name = settings.MINIO_DEFAULT_BUCKET temp_file = BytesIO(contents) + client.put_object( bucket_name=bucket_name, - object_name=name, + object_name=secure_filename, data=temp_file, length=len(contents), content_type=image.content_type or "application/octet-stream", ) temp_file.close() + + # Generate URL endpoint = settings.MINIO_ENDPOINT - image_url = f"{endpoint}/{bucket_name}/{name}" + image_url = f"{endpoint}/{bucket_name}/{secure_filename}" + + # Store in database with original filename for reference image_obj = await db.image.create( { - "name": name, + "name": image.filename or secure_filename, "image_url": image_url, } ) + return image_obj + + except HTTPException: + raise except Exception as e: - print(e) - raise HTTPException(status_code=400, detail="Invalid image data") + print(f"Upload error: {e}") + raise HTTPException(status_code=500, detail="Failed to upload image") @image_router.get("/image/{image_id}", tags=["images"]) -async def get_image(image_id: int) -> Image: +async def get_image( + image_id: int, + user: Annotated[User, Depends(get_user)] # Require authentication +) -> Image: try: image = await db.image.find_unique(where={"id": image_id}) if image is None: raise HTTPException(status_code=404, detail="Image not found") return image + except HTTPException: + raise except Exception as e: - print(e) + print(f"Get image error: {e}") raise HTTPException(status_code=500, detail="Internal server error") + + +@image_router.delete("/image/{image_id}", tags=["images"]) +async def delete_image( + image_id: int, + user: Annotated[User, Depends(get_user)] +) -> dict: + """Delete an image from both database and MinIO""" + try: + # Get image record + image = await db.image.find_unique(where={"id": image_id}) + if image is None: + raise HTTPException(status_code=404, detail="Image not found") + + # Extract filename from URL + filename = image.image_url.split('/')[-1] + bucket_name = settings.MINIO_DEFAULT_BUCKET + + # Delete from MinIO + try: + client.remove_object(bucket_name, filename) + except Exception as e: + print(f"MinIO deletion error: {e}") + # Continue even if MinIO deletion fails + + # Delete from database + await db.image.delete(where={"id": image_id}) + + return {"message": "Image deleted successfully"} + + except HTTPException: + raise + except Exception as e: + print(f"Delete error: {e}") + raise HTTPException(status_code=500, detail="Failed to delete image") diff --git a/frontend/src/components/ImageUploadSection.tsx b/frontend/src/components/ImageUploadSection.tsx new file mode 100644 index 00000000..5d8cfbd4 --- /dev/null +++ b/frontend/src/components/ImageUploadSection.tsx @@ -0,0 +1,151 @@ +import { useRef, useState } from 'react'; + +export function ImageUploadSection({ tempImage, setTempImage }: { tempImage: string; setTempImage: (value: string) => void }) { + const fileInputRef = useRef(null); + const [uploadError, setUploadError] = useState(""); + const [isUploading, setIsUploading] = useState(false); + + const uploadToMinIO = async (file: File): Promise => { + const formData = new FormData(); + formData.append('image', file); + + try { + const response = await fetch('/images', { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + throw new Error('Upload failed'); + } + + const data = await response.json(); + return data.image_url; // MinIO file URL from your backend + } catch (error) { + throw new Error('Failed to upload to MinIO'); + } + }; + + const handleFileSelect = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + // Validate file type + if (!file.type.startsWith('image/')) { + setUploadError('Please select a valid image file'); + return; + } + + // Validate file size (e.g., 10MB limit) + const maxSize = 10 * 1024 * 1024; // 10MB + if (file.size > maxSize) { + setUploadError('File size must be less than 10MB'); + return; + } + + setUploadError(""); + setIsUploading(true); + + try { + const minioUrl = await uploadToMinIO(file); + setTempImage(minioUrl); + } catch (error) { + setUploadError(error instanceof Error ? error.message : 'Upload failed'); + } finally { + setIsUploading(false); + if (fileInputRef.current) { + fileInputRef.current.value = ''; // Reset file input + } + } + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + e.currentTarget.classList.add('bg-green-100'); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.currentTarget.classList.remove('bg-green-100'); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + e.currentTarget.classList.remove('bg-green-100'); + + const file = e.dataTransfer.files?.[0]; + if (file) { + const event = { + target: { files: [file] } + } as unknown as React.ChangeEvent; + handleFileSelect(event); + } + }; + + return ( +
+
image url or path:
+ +
+ {/* URL Input */} + setTempImage(e.target.value)} + placeholder="Enter image URL or upload a file" + disabled={isUploading} + /> + + {/* Divider */} +
+
+ or +
+
+ + {/* Upload Area */} +
!isUploading && fileInputRef.current?.click()} + > + +
+ {isUploading ? ( + <> +
⏳ Uploading...
+
Please wait
+ + ) : ( + <> +
📁 Click to upload or drag and drop
+
PNG, JPG, GIF up to 10MB
+ + )} +
+
+ + {/* Error Message */} + {uploadError && ( +
+ {uploadError} +
+ )} + + +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/BookEditor.tsx b/frontend/src/pages/BookEditor.tsx index 1e162251..b8f3d05d 100644 --- a/frontend/src/pages/BookEditor.tsx +++ b/frontend/src/pages/BookEditor.tsx @@ -8,6 +8,7 @@ import { ErrorBoundary } from "react-error-boundary"; import { BookPreview } from "../components/ActivityBookList"; import Editor from "@monaco-editor/react"; import { useTheme } from "../context/ThemeContext"; +import { ImageUploadSection } from "../components/ImageUploadSection"; interface PropsFormProps { tempProps: string; @@ -725,15 +726,11 @@ function BookImageEditor({ {/* Content area - Takes remaining space */}
{page.image.includes("/") || page.image === "Image" ? ( -
-
image url or path:
- setTempImage(e.target.value)} - /> -
- ) : ( + + ) : ( <> {/* Tab Navigation - Fixed */}
From e94d4d5c9bffecca7e24138d3315732ff90f867e Mon Sep 17 00:00:00 2001 From: natnaelat Date: Sun, 26 Oct 2025 19:42:55 -0400 Subject: [PATCH 02/17] Fix code formatting with Prettier --- README.md | 25 +++++-- .../src/components/ImageUploadSection.tsx | 66 +++++++++++-------- frontend/src/pages/BookEditor.tsx | 10 +-- 3 files changed, 61 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 4e84a116..8548939b 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,9 @@ cp .env.example .env ``` ## Run backend + (use python3.12) + ```bash python -m venv .venv source .venv/bin/activate @@ -19,12 +21,14 @@ uvicorn src.main:app --reload --port 8080 ``` ## Updating Schema Database + ```bash cd backend prisma migrate dev ``` To startup a local database, you can use the following command: + ```bash cd backend docker compose up -d @@ -51,28 +55,35 @@ black . --check ``` To take a backup of the production database, you can use the following command: + ```bash pg_dump -h endeavour.cs.vt.edu -p 30030 -U postgres -d codekids > codekids-db-$(date +%Y-%m-%d).bak ``` + ### to install postgres client 16 on ubuntu -https://dev.to/johndotowl/postgresql-16-installation-on-ubuntu-2204-51ia +https://dev.to/johndotowl/postgresql-16-installation-on-ubuntu-2204-51ia ### to load a backup to your local database + ```bash psql postgres://postgres:password@localhost:5432/codekids < /tmp/codekids-db-2025-04-14.bak ``` + ### if u have an existing database , you will have to clear the docker volume + ```bash docker stop backend-db-1 ###(name of your container- using docker ps -q) docker rm backend-db-1 ###(container id) docker volume rm backend_postgres-data ###(docker volume ls) docker compose up -d ###(create a new volume) ``` - ###to setup prisma - ```bash - cd backend - npx prisma studio - - ``` \ No newline at end of file + +###to setup prisma + +```bash +cd backend +npx prisma studio + +``` diff --git a/frontend/src/components/ImageUploadSection.tsx b/frontend/src/components/ImageUploadSection.tsx index 5d8cfbd4..05bd2ac6 100644 --- a/frontend/src/components/ImageUploadSection.tsx +++ b/frontend/src/components/ImageUploadSection.tsx @@ -1,45 +1,53 @@ -import { useRef, useState } from 'react'; - -export function ImageUploadSection({ tempImage, setTempImage }: { tempImage: string; setTempImage: (value: string) => void }) { +import { useRef, useState } from "react"; + +export function ImageUploadSection({ + tempImage, + setTempImage, +}: { + tempImage: string; + setTempImage: (value: string) => void; +}) { const fileInputRef = useRef(null); const [uploadError, setUploadError] = useState(""); const [isUploading, setIsUploading] = useState(false); const uploadToMinIO = async (file: File): Promise => { const formData = new FormData(); - formData.append('image', file); + formData.append("image", file); try { - const response = await fetch('/images', { - method: 'POST', + const response = await fetch("/images", { + method: "POST", body: formData, }); if (!response.ok) { - throw new Error('Upload failed'); + throw new Error("Upload failed"); } const data = await response.json(); return data.image_url; // MinIO file URL from your backend } catch (error) { - throw new Error('Failed to upload to MinIO'); + throw new Error("Failed to upload to MinIO"); } }; - const handleFileSelect = async (event: React.ChangeEvent) => { + const handleFileSelect = async ( + event: React.ChangeEvent, + ) => { const file = event.target.files?.[0]; if (!file) return; // Validate file type - if (!file.type.startsWith('image/')) { - setUploadError('Please select a valid image file'); + if (!file.type.startsWith("image/")) { + setUploadError("Please select a valid image file"); return; } // Validate file size (e.g., 10MB limit) const maxSize = 10 * 1024 * 1024; // 10MB if (file.size > maxSize) { - setUploadError('File size must be less than 10MB'); + setUploadError("File size must be less than 10MB"); return; } @@ -50,32 +58,32 @@ export function ImageUploadSection({ tempImage, setTempImage }: { tempImage: str const minioUrl = await uploadToMinIO(file); setTempImage(minioUrl); } catch (error) { - setUploadError(error instanceof Error ? error.message : 'Upload failed'); + setUploadError(error instanceof Error ? error.message : "Upload failed"); } finally { setIsUploading(false); if (fileInputRef.current) { - fileInputRef.current.value = ''; // Reset file input + fileInputRef.current.value = ""; // Reset file input } } }; const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); - e.currentTarget.classList.add('bg-green-100'); + e.currentTarget.classList.add("bg-green-100"); }; const handleDragLeave = (e: React.DragEvent) => { - e.currentTarget.classList.remove('bg-green-100'); + e.currentTarget.classList.remove("bg-green-100"); }; const handleDrop = (e: React.DragEvent) => { e.preventDefault(); - e.currentTarget.classList.remove('bg-green-100'); - + e.currentTarget.classList.remove("bg-green-100"); + const file = e.dataTransfer.files?.[0]; if (file) { const event = { - target: { files: [file] } + target: { files: [file] }, } as unknown as React.ChangeEvent; handleFileSelect(event); } @@ -84,7 +92,7 @@ export function ImageUploadSection({ tempImage, setTempImage }: { tempImage: str return (
image url or path:
- +
{/* URL Input */} !isUploading && fileInputRef.current?.click()} > @@ -130,8 +138,12 @@ export function ImageUploadSection({ tempImage, setTempImage }: { tempImage: str ) : ( <> -
📁 Click to upload or drag and drop
-
PNG, JPG, GIF up to 10MB
+
+ 📁 Click to upload or drag and drop +
+
+ PNG, JPG, GIF up to 10MB +
)}
@@ -143,9 +155,7 @@ export function ImageUploadSection({ tempImage, setTempImage }: { tempImage: str {uploadError}
)} - -
); -} \ No newline at end of file +} diff --git a/frontend/src/pages/BookEditor.tsx b/frontend/src/pages/BookEditor.tsx index 0679b471..aafab6f8 100644 --- a/frontend/src/pages/BookEditor.tsx +++ b/frontend/src/pages/BookEditor.tsx @@ -727,11 +727,11 @@ function BookImageEditor({ {/* Content area - Takes remaining space */}
{page.image.includes("/") || page.image === "Image" ? ( - - ) : ( + + ) : ( <> {/* Tab Navigation - Fixed */}
From 49459be08442ef3e8a64c9b6e48441ddbfc2b788 Mon Sep 17 00:00:00 2001 From: natnaelat Date: Sun, 26 Oct 2025 19:47:56 -0400 Subject: [PATCH 03/17] Fix Python formatting with Black --- backend/src/routers/images.py | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/backend/src/routers/images.py b/backend/src/routers/images.py index 235a93f7..c6872dd4 100644 --- a/backend/src/routers/images.py +++ b/backend/src/routers/images.py @@ -21,7 +21,7 @@ ) # Allowed image formats -ALLOWED_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg'} +ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"} MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB @@ -30,32 +30,36 @@ def validate_image(contents: bytes, filename: str) -> tuple[bool, str]: # Check file extension extension = Path(filename).suffix.lower() if extension not in ALLOWED_EXTENSIONS: - return False, f"File type not allowed. Allowed types: {', '.join(ALLOWED_EXTENSIONS)}" + return ( + False, + f"File type not allowed. Allowed types: {', '.join(ALLOWED_EXTENSIONS)}", + ) # Check file size if len(contents) > MAX_FILE_SIZE: return False, f"File size exceeds {MAX_FILE_SIZE / (1024*1024)}MB limit" # Verify file content matches extension (for common formats) - if extension != '.svg': # SVG can't be validated with imghdr + if extension != ".svg": # SVG can't be validated with imghdr image_type = imghdr.what(None, contents) if image_type is None: return False, "File content does not appear to be a valid image" # Map imghdr types to extensions - type_map = {'jpeg': ['.jpg', '.jpeg'], - 'png': ['.png'], 'gif': ['.gif']} + type_map = {"jpeg": [".jpg", ".jpeg"], "png": [".png"], "gif": [".gif"]} if image_type in type_map: if extension not in type_map[image_type]: - return False, f"File extension {extension} does not match content type {image_type}" + return ( + False, + f"File extension {extension} does not match content type {image_type}", + ) return True, "" @image_router.post("/images", tags=["images"]) async def upload_image( - user: Annotated[User, Depends(get_user)], - image: UploadFile = File(...) + user: Annotated[User, Depends(get_user)], image: UploadFile = File(...) ) -> Image: try: # Read file contents @@ -107,8 +111,7 @@ async def upload_image( @image_router.get("/image/{image_id}", tags=["images"]) async def get_image( - image_id: int, - user: Annotated[User, Depends(get_user)] # Require authentication + image_id: int, user: Annotated[User, Depends(get_user)] # Require authentication ) -> Image: try: image = await db.image.find_unique(where={"id": image_id}) @@ -123,10 +126,7 @@ async def get_image( @image_router.delete("/image/{image_id}", tags=["images"]) -async def delete_image( - image_id: int, - user: Annotated[User, Depends(get_user)] -) -> dict: +async def delete_image(image_id: int, user: Annotated[User, Depends(get_user)]) -> dict: """Delete an image from both database and MinIO""" try: # Get image record @@ -135,7 +135,7 @@ async def delete_image( raise HTTPException(status_code=404, detail="Image not found") # Extract filename from URL - filename = image.image_url.split('/')[-1] + filename = image.image_url.split("/")[-1] bucket_name = settings.MINIO_DEFAULT_BUCKET # Delete from MinIO From 1b21cb8b9b8ad6349a1dc5250e2b46dca39aec00 Mon Sep 17 00:00:00 2001 From: natnaelat Date: Mon, 27 Oct 2025 15:22:17 -0400 Subject: [PATCH 04/17] Getting rid of extra package.json --- package-lock.json | 83 ----------------------------------------------- package.json | 5 --- 2 files changed, 88 deletions(-) delete mode 100644 package-lock.json delete mode 100644 package.json diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index 02b00fb1..00000000 --- a/package-lock.json +++ /dev/null @@ -1,83 +0,0 @@ -{ - "name": "codekids", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "dependencies": { - "react-split": "^2.0.14" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/react": { - "version": "19.0.0", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, - "node_modules/react-split": { - "version": "2.0.14", - "resolved": "https://registry.npmjs.org/react-split/-/react-split-2.0.14.tgz", - "integrity": "sha512-bKWydgMgaKTg/2JGQnaJPg51T6dmumTWZppFgEbbY0Fbme0F5TuatAScCLaqommbGQQf/ZT1zaejuPDriscISA==", - "license": "MIT", - "dependencies": { - "prop-types": "^15.5.7", - "split.js": "^1.6.0" - }, - "peerDependencies": { - "react": "*" - } - }, - "node_modules/split.js": { - "version": "1.6.5", - "resolved": "https://registry.npmjs.org/split.js/-/split.js-1.6.5.tgz", - "integrity": "sha512-mPTnGCiS/RiuTNsVhCm9De9cCAUsrNFFviRbADdKiiV+Kk8HKp/0fWu7Kr8pi3/yBmsqLFHuXGT9UUZ+CNLwFw==", - "license": "MIT" - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index f9a5cc79..00000000 --- a/package.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "dependencies": { - "react-split": "^2.0.14" - } -} From 9a57adaf1f530825c8f087d09df729fe59bc1242 Mon Sep 17 00:00:00 2001 From: natnaelat Date: Sun, 2 Nov 2025 22:35:27 -0500 Subject: [PATCH 05/17] Add image upload functionality with MinIO integration --- backend/src/routers/images.py | 8 +- .../src/components/ImageUploadSection.tsx | 84 +++++++++++++++++-- frontend/src/pages/BookEditor.tsx | 19 ++--- 3 files changed, 88 insertions(+), 23 deletions(-) diff --git a/backend/src/routers/images.py b/backend/src/routers/images.py index c6872dd4..9a3a9c63 100644 --- a/backend/src/routers/images.py +++ b/backend/src/routers/images.py @@ -46,7 +46,8 @@ def validate_image(contents: bytes, filename: str) -> tuple[bool, str]: return False, "File content does not appear to be a valid image" # Map imghdr types to extensions - type_map = {"jpeg": [".jpg", ".jpeg"], "png": [".png"], "gif": [".gif"]} + type_map = {"jpeg": [".jpg", ".jpeg"], + "png": [".png"], "gif": [".gif"]} if image_type in type_map: if extension not in type_map[image_type]: return ( @@ -90,7 +91,7 @@ async def upload_image( # Generate URL endpoint = settings.MINIO_ENDPOINT - image_url = f"{endpoint}/{bucket_name}/{secure_filename}" + image_url = f"http://{endpoint}/{bucket_name}/{secure_filename}" # Store in database with original filename for reference image_obj = await db.image.create( @@ -111,7 +112,8 @@ async def upload_image( @image_router.get("/image/{image_id}", tags=["images"]) async def get_image( - image_id: int, user: Annotated[User, Depends(get_user)] # Require authentication + # Require authentication + image_id: int, user: Annotated[User, Depends(get_user)] ) -> Image: try: image = await db.image.find_unique(where={"id": image_id}) diff --git a/frontend/src/components/ImageUploadSection.tsx b/frontend/src/components/ImageUploadSection.tsx index 05bd2ac6..8d8d7594 100644 --- a/frontend/src/components/ImageUploadSection.tsx +++ b/frontend/src/components/ImageUploadSection.tsx @@ -1,4 +1,7 @@ import { useRef, useState } from "react"; +// OpenAPI is the API client config, not related to OpenAI +// Remove this import if it's causing issues - we'll handle auth differently +// import { OpenAPI } from "../api"; export function ImageUploadSection({ tempImage, @@ -13,21 +16,82 @@ export function ImageUploadSection({ const uploadToMinIO = async (file: File): Promise => { const formData = new FormData(); - formData.append("image", file); + formData.append("image", file); // Backend expects "image" try { - const response = await fetch("/images", { + // Backend is running on port 8080 + const baseUrl = "http://localhost:8080"; + + // Check multiple token storage locations + // The backend's login returns a user object with a token field + const token = + localStorage.getItem("token") || + localStorage.getItem("userToken") || + localStorage.getItem("authToken") || + sessionStorage.getItem("token"); + + // Also check if user object is stored + const userStr = localStorage.getItem("user") || sessionStorage.getItem("user"); + let tokenFromUser = null; + if (userStr) { + try { + const user = JSON.parse(userStr); + tokenFromUser = user.token; + } catch (e) { + console.error("Failed to parse user object:", e); + } + } + + const finalToken = token || tokenFromUser; + + if (!finalToken) { + throw new Error("No authentication token found. Please log in."); + } + + const headers: HeadersInit = { + // Backend expects token in X-API-Key header + "X-API-Key": finalToken, + }; + + console.log("Uploading to:", `${baseUrl}/images`); + console.log("Token:", finalToken ? `Present (${finalToken.substring(0, 10)}...)` : "Missing"); + + const response = await fetch(`${baseUrl}/images`, { method: "POST", + headers, body: formData, + credentials: "include", }); + console.log("Response status:", response.status); + if (!response.ok) { - throw new Error("Upload failed"); + const errorText = await response.text(); + console.error("Upload failed:", errorText); + + // Parse error details if available + try { + const errorJson = JSON.parse(errorText); + throw new Error(errorJson.detail || `Upload failed: ${response.status}`); + } catch { + throw new Error(`Upload failed: ${response.status} - ${errorText}`); + } } const data = await response.json(); - return data.image_url; // MinIO file URL from your backend + console.log("Upload response:", data); + + // Backend returns an Image object with image_url property + if (!data.image_url) { + throw new Error("No image URL in response"); + } + + return data.image_url; } catch (error) { + console.error("Upload error:", error); + if (error instanceof Error) { + throw new Error(`Failed to upload: ${error.message}`); + } throw new Error("Failed to upload to MinIO"); } }; @@ -55,9 +119,12 @@ export function ImageUploadSection({ setIsUploading(true); try { + console.log("Uploading file:", file.name, "Size:", file.size); const minioUrl = await uploadToMinIO(file); + console.log("Upload successful, URL:", minioUrl); setTempImage(minioUrl); } catch (error) { + console.error("Upload failed:", error); setUploadError(error instanceof Error ? error.message : "Upload failed"); } finally { setIsUploading(false); @@ -115,11 +182,10 @@ export function ImageUploadSection({ onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} - className={`w-full border-2 border-dashed border-primary-green rounded-xl p-4 text-center cursor-pointer transition-colors min-w-0 ${ - isUploading + className={`w-full border-2 border-dashed border-primary-green rounded-xl p-4 text-center cursor-pointer transition-colors min-w-0 ${isUploading ? "bg-gray-100 cursor-not-allowed opacity-60" : "hover:bg-green-50" - }`} + }`} onClick={() => !isUploading && fileInputRef.current?.click()} > - {uploadError} + ❌ {uploadError}
)}
); -} +} \ No newline at end of file diff --git a/frontend/src/pages/BookEditor.tsx b/frontend/src/pages/BookEditor.tsx index aafab6f8..a6192abf 100644 --- a/frontend/src/pages/BookEditor.tsx +++ b/frontend/src/pages/BookEditor.tsx @@ -568,11 +568,10 @@ function PageNavigator({ {pages.slice(0, 8).map((page) => (