feat: add undo/redo buttons & re-generate in presentation page

This commit is contained in:
shiva raj badu 2025-08-30 16:31:11 +05:45
parent cd6fbff1bd
commit 9765749a83
No known key found for this signature in database
8 changed files with 112 additions and 12 deletions

View file

@ -1,5 +1,5 @@
from typing import List
from fastapi import APIRouter, Depends, File, UploadFile
from fastapi import APIRouter, Depends, File, UploadFile, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlmodel import select
@ -42,7 +42,7 @@ async def get_generated_images(sql_session: AsyncSession = Depends(get_async_ses
)
return images
except Exception as e:
return {"error": f"Failed to retrieve generated images: {str(e)}"}
raise HTTPException(status_code=500, detail=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)):
@ -57,7 +57,7 @@ async def upload_image(file: UploadFile = File(...), sql_session: AsyncSession =
"id": str(image_asset.id),
}
except Exception as e:
return {"error": f"Failed to upload image: {str(e)}"}
raise HTTPException(status_code=500, detail=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)):
@ -67,7 +67,7 @@ async def get_uploaded_images(sql_session: AsyncSession = Depends(get_async_sess
)
return images
except Exception as e:
return {"error": f"Failed to retrieve uploaded images: {str(e)}"}
raise HTTPException(status_code=500, detail=f"Failed to retrieve uploaded images: {str(e)}")
@IMAGES_ROUTER.delete("/uploaded-image/{image_id}")
@ -76,13 +76,13 @@ async def delete_image(image_id: uuid.UUID, sql_session: AsyncSession = Depends(
# 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"}
raise HTTPException(status_code=404, detail="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"}
return {"message": "Image deleted successfully"}
except Exception as e:
return {"error": f"Failed to delete image: {str(e)}"}
raise HTTPException(status_code=500, detail=f"Failed to delete image: {str(e)}")

View file

@ -79,7 +79,6 @@ async def delete_presentation(
if not presentation:
raise HTTPException(404, "Presentation not found")
await sql_session.execute(delete(SlideModel).where(SlideModel.presentation == id))
await sql_session.delete(presentation)
await sql_session.commit()
@ -205,6 +204,8 @@ async def stream_presentation(
status_code=400,
detail="Outlines can not be empty",
)
await sql_session.execute(delete(SlideModel).where(SlideModel.presentation == presentation_id))
await sql_session.commit()
image_generation_service = ImageGenerationService(get_images_directory())

View file

@ -471,7 +471,7 @@ const ImageEditor = ({
</div>
) : (
uploadedImages.map((image) => (
<div>
<div key={image.id}>
<div
onClick={() =>
handleImageChange(image.path)

View file

@ -89,9 +89,11 @@ const TiptapTextReplacer: React.FC<TiptapTextReplacerProps> = ({
tiptapContainer.className = Array.from(allClasses).join(" ");
// Replace the element
htmlElement.parentNode?.replaceChild(tiptapContainer, htmlElement);
if(htmlElement.parentNode) {
htmlElement.parentNode.replaceChild(tiptapContainer, htmlElement);
// Mark as processed
htmlElement.innerHTML = "";
}
setProcessedElements((prev) => new Set(prev).add(htmlElement));
// Render TiptapText
const root = ReactDOM.createRoot(tiptapContainer);

View file

@ -4,6 +4,9 @@ import {
SquareArrowOutUpRight,
Play,
Loader2,
Redo2 ,
Undo2,
RefreshCcw,
} from "lucide-react";
import React, { useState } from "react";
import Wrapper from "@/components/Wrapper";
@ -15,7 +18,7 @@ import {
} from "@/components/ui/popover";
import { PresentationGenerationApi } from "../../services/api/presentation-generation";
import { OverlayLoader } from "@/components/ui/overlay-loader";
import { useSelector } from "react-redux";
import { useDispatch, useSelector } from "react-redux";
import Link from "next/link";
@ -30,6 +33,10 @@ import PDFIMAGE from "@/public/pdf.svg";
import PPTXIMAGE from "@/public/pptx.svg";
import Image from "next/image";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
import { usePresentationUndoRedo } from "../hooks/PresentationUndoRedo";
import ToolTip from "@/components/ToolTip";
import { clearPresentationData } from "@/store/slices/presentationGeneration";
import { clearHistory } from "@/store/slices/undoRedoSlice";
const Header = ({
presentation_id,
@ -42,12 +49,15 @@ const Header = ({
const [showLoader, setShowLoader] = useState(false);
const router = useRouter();
const pathname = usePathname();
const dispatch = useDispatch();
const { presentationData, isStreaming } = useSelector(
(state: RootState) => state.presentationGeneration
);
const { onUndo, onRedo, canUndo, canRedo } = usePresentationUndoRedo();
const get_presentation_pptx_model = async (id: string): Promise<PptxPresentationModel> => {
const response = await fetch(`/api/presentation_to_pptx_model?id=${id}`);
const pptx_model = await response.json();
@ -127,6 +137,12 @@ const Header = ({
setShowLoader(false);
}
};
const handleReGenerate = () => {
dispatch(clearPresentationData());
dispatch(clearHistory())
trackEvent(MixpanelEvent.Header_ReGenerate_Button_Clicked, { pathname });
router.push(`/presentation?id=${presentation_id}&stream=true`);
};
const downloadLink = (path: string) => {
// if we have popup access give direct download if not redirect to the path
if (window.opener) {
@ -170,6 +186,33 @@ const Header = ({
const MenuItems = ({ mobile }: { mobile: boolean }) => (
<div className="flex flex-col lg:flex-row items-center gap-4">
{/* undo redo */}
<button onClick={handleReGenerate} disabled={isStreaming || !presentationData} className="text-white disabled:opacity-50" >
Re-Generate
</button>
<div className="flex items-center gap-2 ">
<ToolTip content="Undo">
<button disabled={!canUndo} className="text-white disabled:opacity-50" onClick={() => {
onUndo();
}}>
<Undo2 className="w-6 h-6 " />
</button>
</ToolTip>
<ToolTip content="Redo">
<button disabled={!canRedo} className="text-white disabled:opacity-50" onClick={() => {
onRedo();
}}>
<Redo2 className="w-6 h-6 " />
</button>
</ToolTip>
</div>
{/* Present Button */}
<Button
onClick={() => {

View file

@ -1,3 +1,4 @@
import { useCallback } from "react";
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "@/store/store";
import { finishUndoRedo, redo, undo } from "@/store/slices/undoRedoSlice";
@ -13,6 +14,56 @@ export const usePresentationUndoRedo = () => {
const undoRedoState = useSelector((state: RootState) => state.undoRedo);
const { presentationData } = useSelector((state: RootState) => state.presentationGeneration);
const canUndo = undoRedoState.past.length > 0;
const canRedo = undoRedoState.future.length > 0;
console.log(canUndo, canRedo);
const onUndo = useCallback(() => {
if (!canUndo) return;
const previousState = undoRedoState.past[undoRedoState.past.length - 1];
dispatch(undo());
if (previousState) {
const newSlides = JSON.parse(JSON.stringify(previousState.slides));
dispatch(
setPresentationData({
...presentationData!,
slides: newSlides,
})
);
}
setTimeout(() => {
dispatch(finishUndoRedo());
}, 100);
}, [canUndo, dispatch, presentationData, undoRedoState.past]);
const onRedo = useCallback(() => {
if (!canRedo) return;
const nextState = undoRedoState.future[0];
dispatch(redo());
if (nextState) {
const newSlides = JSON.parse(JSON.stringify(nextState.slides));
dispatch(
setPresentationData({
...presentationData!,
slides: newSlides,
})
);
}
setTimeout(() => {
dispatch(finishUndoRedo());
}, 100);
}, [canRedo, dispatch, presentationData, undoRedoState.future]);
// Handle undo
useKeyboardShortcut(
["z"],
@ -81,4 +132,6 @@ export const usePresentationUndoRedo = () => {
},
[undoRedoState.future, presentationData]
);
return { onUndo, onRedo, canUndo, canRedo };
}

View file

@ -1,5 +1,5 @@
import { useEffect, useRef } from "react";
import { useDispatch, useSelector } from "react-redux";
import { useDispatch } from "react-redux";
import {
clearPresentationData,
setPresentationData,

View file

@ -50,6 +50,7 @@ export enum MixpanelEvent {
ImageEditor_GetPreviousGeneratedImages_API_Call = 'Image Editor Get Previous Generated Images API Call',
ImageEditor_GenerateImage_API_Call = 'Image Editor Generate Image API Call',
ImageEditor_UploadImage_API_Call = 'Image Editor Upload Image API Call',
Header_ReGenerate_Button_Clicked = 'Header ReGenerate Button Clicked',
}
export type MixpanelProps = Record<string, unknown>;