From c5b465eb0259396a2540344d73d1aee8f7ecd4d9 Mon Sep 17 00:00:00 2001 From: shiva raj badu Date: Fri, 29 Aug 2025 20:04:18 +0545 Subject: [PATCH] feat: implement image upload, retrieval & deletetion endpoints, Also implemented on Nextjs --- .../fastapi/api/v1/ppt/endpoints/images.py | 51 +++++++++- servers/fastapi/models/sql/image_asset.py | 1 + .../services/image_generation_service.py | 1 + .../fastapi/services/image_upload_service.py | 53 ++++++++++ .../components/ImageEditor.tsx | 99 ++++++++++++++----- .../services/api/header.ts | 2 +- .../services/api/images.ts | 47 +++++++++ .../services/api/types.ts | 6 ++ servers/nextjs/app/api/upload-image/route.ts | 2 +- 9 files changed, 236 insertions(+), 26 deletions(-) create mode 100644 servers/fastapi/services/image_upload_service.py create mode 100644 servers/nextjs/app/(presentation-generator)/services/api/images.ts diff --git a/servers/fastapi/api/v1/ppt/endpoints/images.py b/servers/fastapi/api/v1/ppt/endpoints/images.py index 55079409..0902201b 100644 --- a/servers/fastapi/api/v1/ppt/endpoints/images.py +++ b/servers/fastapi/api/v1/ppt/endpoints/images.py @@ -1,5 +1,5 @@ from typing import List -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, File, UploadFile from sqlalchemy.ext.asyncio import AsyncSession from sqlmodel import select @@ -8,6 +8,10 @@ from models.sql.image_asset import ImageAsset from services.database import get_async_session from services.image_generation_service import ImageGenerationService from utils.asset_directory_utils import get_images_directory +import os +from utils.asset_directory_utils import get_uploads_directory +import uuid +from services.image_upload_service import ImageUploadService IMAGES_ROUTER = APIRouter(prefix="/images", tags=["Images"]) @@ -34,8 +38,51 @@ async def generate_image( async def get_generated_images(sql_session: AsyncSession = Depends(get_async_session)): try: images = await sql_session.scalars( - select(ImageAsset).order_by(ImageAsset.created_at.desc()) + select(ImageAsset).where(ImageAsset.is_uploaded == False).order_by(ImageAsset.created_at.desc()) ) return images except Exception as e: return {"error": f"Failed to retrieve generated images: {str(e)}"} + +@IMAGES_ROUTER.post("/upload-image") +async def upload_image(file: UploadFile = File(...), sql_session: AsyncSession = Depends(get_async_session)): + try: + service = ImageUploadService(get_uploads_directory()) + image_asset = await service.upload_image(file) + sql_session.add(image_asset) + await sql_session.commit() + return { + "message": "Image uploaded successfully", + "path": image_asset.path, + "id": str(image_asset.id), + } + except Exception as e: + return {"error": f"Failed to upload image: {str(e)}"} + +@IMAGES_ROUTER.get("/uploaded", response_model=List[ImageAsset]) +async def get_uploaded_images(sql_session: AsyncSession = Depends(get_async_session)): + try: + images = await sql_session.scalars( + select(ImageAsset).where(ImageAsset.is_uploaded == True).order_by(ImageAsset.created_at.desc()) + ) + return images + except Exception as e: + return {"error": f"Failed to retrieve uploaded images: {str(e)}"} + + +@IMAGES_ROUTER.delete("/uploaded-image/{image_id}") +async def delete_image(image_id: uuid.UUID, sql_session: AsyncSession = Depends(get_async_session)): + try: + # Fetch the asset to get its actual file path + image = await sql_session.get(ImageAsset, image_id) + if not image: + return {"error": "Image not found"} + + service = ImageUploadService(get_uploads_directory()) + await service.delete_image(image.path) + + await sql_session.delete(image) + await sql_session.commit() + return {"success": True, "message": "Image deleted successfully"} + except Exception as e: + return {"error": f"Failed to delete image: {str(e)}"} diff --git a/servers/fastapi/models/sql/image_asset.py b/servers/fastapi/models/sql/image_asset.py index 4665e031..3efc99c0 100644 --- a/servers/fastapi/models/sql/image_asset.py +++ b/servers/fastapi/models/sql/image_asset.py @@ -15,5 +15,6 @@ class ImageAsset(SQLModel, table=True): DateTime(timezone=True), nullable=False, default=get_current_utc_datetime ), ) + is_uploaded: bool = Field(default=False) path: str extras: Optional[dict] = Field(sa_column=Column(JSON), default=None) diff --git a/servers/fastapi/services/image_generation_service.py b/servers/fastapi/services/image_generation_service.py index 855ff2da..1437db45 100644 --- a/servers/fastapi/services/image_generation_service.py +++ b/servers/fastapi/services/image_generation_service.py @@ -68,6 +68,7 @@ class ImageGenerationService: elif os.path.exists(image_path): return ImageAsset( path=image_path, + is_uploaded=False, extras={ "prompt": prompt.prompt, "theme_prompt": prompt.theme_prompt, diff --git a/servers/fastapi/services/image_upload_service.py b/servers/fastapi/services/image_upload_service.py new file mode 100644 index 00000000..9c39b0d5 --- /dev/null +++ b/servers/fastapi/services/image_upload_service.py @@ -0,0 +1,53 @@ +from fastapi import UploadFile +import os +import uuid + +from models.sql.image_asset import ImageAsset +from utils.asset_directory_utils import get_uploads_directory + + +class ImageUploadService: + """Handles saving uploaded images to disk and returning ImageAsset models.""" + + def __init__(self, output_directory: str | None = None): + # Prefer provided directory, otherwise resolve from app data directory + self.uploads_directory = output_directory or get_uploads_directory() + os.makedirs(self.uploads_directory, exist_ok=True) + + async def upload_image(self, file: UploadFile) -> ImageAsset: + """Save the uploaded file to disk and return an ImageAsset (not committed). + + The caller is responsible for adding the returned ImageAsset to the + database session and committing. + """ + file_extension = os.path.splitext(file.filename)[1] if file.filename else "" + unique_filename = f"{uuid.uuid4()}{file_extension}" + file_path = os.path.join(self.uploads_directory, unique_filename) + + content = await file.read() + with open(file_path, "wb") as buffer: + buffer.write(content) + + image_asset = ImageAsset( + path=file_path, + is_uploaded=True, + extras={ + "original_filename": file.filename, + "content_type": file.content_type, + "file_size": len(content), + }, + ) + + return image_asset + + async def delete_image(self, file_path: str) -> bool: + """Delete an image file from disk by its absolute path.""" + if not file_path: + return False + if not os.path.isabs(file_path): + # Ensure we only operate on absolute paths that we generated + file_path = os.path.join(self.uploads_directory, file_path) + if not os.path.exists(file_path): + return False + os.remove(file_path) + return True \ No newline at end of file diff --git a/servers/nextjs/app/(presentation-generator)/components/ImageEditor.tsx b/servers/nextjs/app/(presentation-generator)/components/ImageEditor.tsx index fb1c8337..43fba715 100644 --- a/servers/nextjs/app/(presentation-generator)/components/ImageEditor.tsx +++ b/servers/nextjs/app/(presentation-generator)/components/ImageEditor.tsx @@ -9,13 +9,15 @@ import { import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; -import { Wand2, Upload } from "lucide-react"; +import { Wand2, Upload, Loader2, Delete, Trash } from "lucide-react"; import { cn } from "@/lib/utils"; import { PresentationGenerationApi } from "../services/api/presentation-generation"; import { Skeleton } from "@/components/ui/skeleton"; import { toast } from "sonner"; import { PreviousGeneratedImagesResponse } from "../services/api/params"; import { trackEvent, MixpanelEvent } from "@/utils/mixpanel"; +import { ImagesApi } from "../services/api/images"; +import { ImageAssetResponse } from "../services/api/types"; interface ImageEditorProps { initialImage: string | null; imageIdx?: number; @@ -49,7 +51,8 @@ const ImageEditor = ({ const [uploadError, setUploadError] = useState(null); const [uploadedImageUrl, setUploadedImageUrl] = useState(null); const [isOpen, setIsOpen] = useState(true); - + const [uploadedImages, setUploadedImages] = useState([]); + const [uploadedImagesLoading, setUploadedImagesLoading] = useState(false); // Focus point and object fit for image editing const [isFocusPointMode, setIsFocusPointMode] = useState(false); const [focusPoint, setFocusPoint] = useState( @@ -82,6 +85,7 @@ const ImageEditor = ({ // Handle close with animation const handleClose = () => { + setIsOpen(false); // Delay the actual close to allow animation to complete setTimeout(() => { @@ -223,35 +227,48 @@ const ImageEditor = ({ setUploadError("Please upload an image file"); return; } - try { setIsUploading(true); setUploadError(null); - - const formData = new FormData(); - formData.append("file", file); - trackEvent(MixpanelEvent.ImageEditor_UploadImage_API_Call); - const response = await fetch("/api/upload-image", { - method: "POST", - body: formData, - }); - - const result = await response.json(); - - if (!response.ok) { - throw new Error(result.error || "Upload failed"); - } - - setUploadedImageUrl(result.filePath); - } catch (err) { + const result = await ImagesApi.uploadImage(file); + setUploadedImageUrl(result.path); + } catch (err:any) { setUploadError("Failed to upload image. Please try again."); - console.error("Upload error:", err); + toast.error(err.message || "Failed to upload image. Please try again."); + console.log("Upload error:", err.message); } finally { setIsUploading(false); } }; + const getUploadedImages = async () => { + try { + setUploadedImagesLoading(true); + const result = await ImagesApi.getUploadedImages(); + setUploadedImages(result); + } catch (err:any) { + toast.error(err.message || "Failed to get uploaded images. Please try again."); + console.log("Get uploaded images error:", err.message); + } finally { + setUploadedImagesLoading(false); + } + }; + const handleTabChange = (value: string) => { + if (value === "upload") { + getUploadedImages(); + } + }; + + const handleDeleteImage = async (image_id: string) => { + try { + const result = await ImagesApi.deleteImage(image_id); + setUploadedImages(uploadedImages.filter((image) => image.id !== image_id)); + toast.success(result.message || "Image deleted successfully"); + } catch (err:any) { + toast.error(err.message || "Failed to delete image. Please try again."); + } + }; return (
handleClose()}> @@ -266,7 +283,7 @@ const ImageEditor = ({
- + AI Generate @@ -445,6 +462,44 @@ const ImageEditor = ({
)} +
+

Uploaded Images:

+
+ {uploadedImagesLoading ? ( +
+ +
+ ) : ( + uploadedImages.map((image) => ( +
+
+ handleImageChange(image.path) + } + className="cursor-pointer group aspect-[4/3] rounded-lg overflow-hidden relative border border-gray-200" + > + { + e.stopPropagation(); + handleDeleteImage(image.id) + }}/> + Uploaded preview +
+
+ + Use + +
+
+ +
+ )) + )} +
+
diff --git a/servers/nextjs/app/(presentation-generator)/services/api/header.ts b/servers/nextjs/app/(presentation-generator)/services/api/header.ts index d84251d5..41ce6911 100644 --- a/servers/nextjs/app/(presentation-generator)/services/api/header.ts +++ b/servers/nextjs/app/(presentation-generator)/services/api/header.ts @@ -12,6 +12,6 @@ export const getHeaderForFormData = () => { return { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "POST, OPTIONS", - "Access-Control-Allow-Headers": "Authorization", + "Access-Control-Allow-Headers": "Content-Type, Authorization", }; }; diff --git a/servers/nextjs/app/(presentation-generator)/services/api/images.ts b/servers/nextjs/app/(presentation-generator)/services/api/images.ts new file mode 100644 index 00000000..113da7a2 --- /dev/null +++ b/servers/nextjs/app/(presentation-generator)/services/api/images.ts @@ -0,0 +1,47 @@ +import { getHeaderForFormData } from "./header"; +import { ApiResponseHandler } from "./api-error-handler"; +import { ImageAssetResponse } from "./types"; + + +export class ImagesApi { + + static async uploadImage(file: File): Promise { + try { + const formData = new FormData(); + formData.append("file", file); + const response = await fetch(`/api/v1/ppt/images/upload-image`, { + method: "POST", + headers: getHeaderForFormData(), + body: formData, + }); + return await ApiResponseHandler.handleResponse(response, "Failed to upload image") as ImageAssetResponse; + } catch (error:any) { + console.log("Upload error:", error.message); + throw error; + } + } + + static async getUploadedImages(): Promise { + try { + const response = await fetch(`/api/v1/ppt/images/uploaded`); + return await ApiResponseHandler.handleResponse(response, "Failed to get uploaded images") as ImageAssetResponse[]; + } catch (error:any) { + console.log("Get uploaded images error:", error); + throw error; + } + } + + static async deleteImage(image_id: string): Promise<{success: boolean, message?: string}> { + try { + const response = await fetch(`/api/v1/ppt/images/uploaded-image/${image_id}`, { + method: "DELETE" + }); + return await ApiResponseHandler.handleResponse(response, "Failed to delete image") as {success: boolean, message?: string}; + } catch (error:any) { + console.log("Delete image error:", error); + throw error; + } + } +} + + diff --git a/servers/nextjs/app/(presentation-generator)/services/api/types.ts b/servers/nextjs/app/(presentation-generator)/services/api/types.ts index dcfd9953..1dd8201f 100644 --- a/servers/nextjs/app/(presentation-generator)/services/api/types.ts +++ b/servers/nextjs/app/(presentation-generator)/services/api/types.ts @@ -22,4 +22,10 @@ export interface ChartAssignmentResponse { export interface DeplotResponse { presentation_id: string; charts: ChartAssignmentResponse; +} + +export interface ImageAssetResponse { + message:string; + path:string; + id:string; } \ No newline at end of file diff --git a/servers/nextjs/app/api/upload-image/route.ts b/servers/nextjs/app/api/upload-image/route.ts index 8504f168..b66b3fc4 100644 --- a/servers/nextjs/app/api/upload-image/route.ts +++ b/servers/nextjs/app/api/upload-image/route.ts @@ -45,4 +45,4 @@ export async function POST(request: NextRequest) { { status: 500 } ); } -} \ No newline at end of file +}