feat: implement image upload, retrieval & deletetion endpoints, Also implemented on Nextjs
This commit is contained in:
parent
4a996d1426
commit
c5b465eb02
9 changed files with 236 additions and 26 deletions
|
|
@ -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)}"}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
53
servers/fastapi/services/image_upload_service.py
Normal file
53
servers/fastapi/services/image_upload_service.py
Normal 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
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -22,4 +22,10 @@ export interface ChartAssignmentResponse {
|
|||
export interface DeplotResponse {
|
||||
presentation_id: string;
|
||||
charts: ChartAssignmentResponse;
|
||||
}
|
||||
|
||||
export interface ImageAssetResponse {
|
||||
message:string;
|
||||
path:string;
|
||||
id:string;
|
||||
}
|
||||
|
|
@ -45,4 +45,4 @@ export async function POST(request: NextRequest) {
|
|||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue