feat: implement image upload, retrieval & deletetion endpoints, Also implemented on Nextjs

This commit is contained in:
shiva raj badu 2025-08-29 20:04:18 +05:45
parent 4a996d1426
commit c5b465eb02
No known key found for this signature in database
9 changed files with 236 additions and 26 deletions

View file

@ -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)}"}

View file

@ -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)

View file

@ -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,

View file

@ -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

View file

@ -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<string | null>(null);
const [uploadedImageUrl, setUploadedImageUrl] = useState<string | null>(null);
const [isOpen, setIsOpen] = useState(true);
const [uploadedImages, setUploadedImages] = useState<ImageAssetResponse[]>([]);
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 (
<div className="image-editor-container">
<Sheet open={isOpen} onOpenChange={() => handleClose()}>
@ -266,7 +283,7 @@ const ImageEditor = ({
</SheetHeader>
<div className="mt-6">
<Tabs defaultValue="generate" className="w-full">
<Tabs defaultValue="generate" className="w-full" onValueChange={handleTabChange}>
<TabsList className="grid bg-blue-100 border border-blue-300 w-full grid-cols-3 mx-auto">
<TabsTrigger className="font-medium" value="generate">
AI Generate
@ -445,6 +462,44 @@ const ImageEditor = ({
</div>
</div>
)}
<div>
<h3 className="text-sm font-medium mb-2">Uploaded Images:</h3>
<div className="grid grid-cols-2 gap-4">
{uploadedImagesLoading ? (
<div className="flex items-center justify-center">
<Loader2 className="w-4 h-4 animate-spin" />
</div>
) : (
uploadedImages.map((image) => (
<div>
<div
onClick={() =>
handleImageChange(image.path)
}
className="cursor-pointer group aspect-[4/3] rounded-lg overflow-hidden relative border border-gray-200"
>
<Trash className="absolute group-hover:opacity-100 opacity-0 transition-opacity z-10 w-4 h-4 top-2 right-2 text-red-500" onClick={(e) =>{
e.stopPropagation();
handleDeleteImage(image.id)
}}/>
<img
src={image.path}
alt="Uploaded preview"
className="w-full h-full object-cover group-hover:scale-105 transition-transform"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-all duration-200" />
<div className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<span className="bg-white/90 px-3 py-1 rounded-full text-xs font-medium">
Use
</span>
</div>
</div>
</div>
))
)}
</div>
</div>
</div>
</TabsContent>
<TabsContent value="edit" className="mt-4 space-y-4">

View file

@ -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",
};
};

View file

@ -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<ImageAssetResponse> {
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<ImageAssetResponse[]> {
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;
}
}
}

View file

@ -22,4 +22,10 @@ export interface ChartAssignmentResponse {
export interface DeplotResponse {
presentation_id: string;
charts: ChartAssignmentResponse;
}
export interface ImageAssetResponse {
message:string;
path:string;
id:string;
}

View file

@ -45,4 +45,4 @@ export async function POST(request: NextRequest) {
{ status: 500 }
);
}
}
}