feat(nextjs, fastapi): implement previous generated images feature and update slide ID on edit

This commit is contained in:
shiva raj badu 2025-07-21 16:40:13 +05:45
parent c583590c2f
commit 04a1516a25
No known key found for this signature in database
11 changed files with 151 additions and 87 deletions

View file

@ -7,13 +7,17 @@ from services.database import get_sql_session
from utils.llm_calls.edit_slide import get_edited_slide_content
from utils.llm_calls.select_slide_type_on_edit import get_slide_layout_from_prompt
from utils.process_slides import process_old_and_new_slides_and_fetch_assets
from utils.randomizers import get_random_uuid
SLIDE_ROUTER = APIRouter(prefix="/slide", tags=["Slide"])
@SLIDE_ROUTER.post("/edit")
async def edit_slide(id: Annotated[str, Body()], prompt: Annotated[str, Body()]):
async def edit_slide(
id: Annotated[str, Body()],
prompt: Annotated[str, Body()]
):
with get_sql_session() as sql_session:
slide = sql_session.get(SlideModel, id)
@ -37,6 +41,9 @@ async def edit_slide(id: Annotated[str, Body()], prompt: Annotated[str, Body()])
slide.content, edited_slide_content
)
# Always assign a new unique id to the slide
slide.id = get_random_uuid()
with get_sql_session() as sql_session:
sql_session.add(slide)
slide.content = edited_slide_content

View file

@ -26,7 +26,6 @@ const EditableLayoutWrapper: React.FC<EditableLayoutWrapperProps> = ({
children,
slideIndex,
slideData,
isEditMode = true,
}) => {
const dispatch = useDispatch();
const containerRef = useRef<HTMLDivElement>(null);
@ -109,8 +108,6 @@ const EditableLayoutWrapper: React.FC<EditableLayoutWrapperProps> = ({
* Checks if two URLs match using various comparison strategies
*/
const isMatchingUrl = (url1: string, url2: string): boolean => {
console.log('url1', url1);
console.log('url2', url2);
if (!url1 || !url2) return false;
// Direct match
@ -154,7 +151,7 @@ const EditableLayoutWrapper: React.FC<EditableLayoutWrapperProps> = ({
* Finds and processes images in the DOM, making them editable
*/
const findAndProcessImages = () => {
if (!containerRef.current || !isEditMode) return;
if (!containerRef.current) return;
const imgElements = containerRef.current.querySelectorAll('img:not([data-editable-processed])');
const newEditableElements: EditableElement[] = [];

View file

@ -16,7 +16,8 @@ import {
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";
interface ImageEditorProps {
initialImage: string | null;
imageIdx?: number;
@ -32,7 +33,6 @@ interface ImageEditorProps {
const ImageEditor = ({
initialImage,
imageIdx = 0,
slideIndex,
promptContent,
properties,
onClose,
@ -41,6 +41,7 @@ const ImageEditor = ({
}: ImageEditorProps) => {
// State management
const [previewImages, setPreviewImages] = useState(initialImage);
const [previousGeneratedImages, setPreviousGeneratedImages] = useState<PreviousGeneratedImagesResponse[]>([]);
const [prompt, setPrompt] = useState<string>("");
const [isGenerating, setIsGenerating] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -65,7 +66,6 @@ const ImageEditor = ({
properties[imageIdx].initialObjectFit) ||
"cover"
);
console.log("previewImages", previewImages);
// Refs
const imageRef = useRef<HTMLImageElement>(null);
@ -77,6 +77,13 @@ const ImageEditor = ({
setPreviewImages(initialImage);
}, [initialImage]);
useEffect(() => {
if (isOpen && !previousGeneratedImages.length) {
getPreviousGeneratedImage();
}
}, [isOpen, previousGeneratedImages]);
// Handle close with animation
const handleClose = () => {
setIsOpen(false);
@ -86,6 +93,18 @@ const ImageEditor = ({
}, 300); // Match the Sheet animation duration
};
const getPreviousGeneratedImage = async () => {
try {
const response = await PresentationGenerationApi.getPreviousGeneratedImages();
setPreviousGeneratedImages(response);
} catch (error) {
toast.error("Failed to get previous generated images. Please try again.");
console.error("error in getting previous generated images", error);
setError("Failed to get previous generated images. Please try again.");
}
}
// Close toolbar when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@ -331,6 +350,18 @@ const ImageEditor = ({
</div>
}
</div>
{previousGeneratedImages.length > 0 && (
<div className="mt-4">
<h3 className="text-sm font-medium mb-2">Previous Generated Images</h3>
<div className="grid grid-cols-2 gap-4">
{previousGeneratedImages.map((image) => (
<div onClick={() => handleImageChange(image.path)} key={image.id} className="aspect-[4/3] w-full overflow-hidden rounded-lg border cursor-pointer hover:border-blue-500 transition-colors" >
<img src={image.path} alt={image.extras.prompt} className="w-full h-full object-cover" />
</div>
))}
</div>
</div>
)}
</div>
</TabsContent>

View file

@ -19,7 +19,6 @@ interface TiptapTextProps {
className?: string;
placeholder?: string;
tag?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' | 'p' | 'span' | 'div';
disabled?: boolean;
}
const TiptapText: React.FC<TiptapTextProps> = ({
@ -28,7 +27,6 @@ const TiptapText: React.FC<TiptapTextProps> = ({
className = "",
placeholder = "Enter text...",
tag = 'div',
disabled = false
}) => {
const editor = useEditor({
extensions: [StarterKit, Markdown, Underline],
@ -45,7 +43,7 @@ const TiptapText: React.FC<TiptapTextProps> = ({
onContentChange(markdown);
}
},
editable: !disabled,
editable: true,
immediatelyRender: false,
});
@ -62,56 +60,54 @@ const TiptapText: React.FC<TiptapTextProps> = ({
return (
<div className="relative z-50 w-full">
{!disabled && (
<BubbleMenu editor={editor} tippyOptions={{ duration: 100 }}>
<div className="flex bg-white rounded-lg shadow-lg p-2 gap-1 border border-gray-200 z-50">
<button
onClick={() => editor?.chain().focus().toggleBold().run()}
className={`p-1 rounded hover:bg-gray-100 transition-colors ${editor?.isActive("bold") ? "bg-blue-100 text-blue-600" : ""
}`}
title="Bold"
>
<Bold className="h-4 w-4" />
</button>
<button
onClick={() => editor?.chain().focus().toggleItalic().run()}
className={`p-1 rounded hover:bg-gray-100 transition-colors ${editor?.isActive("italic") ? "bg-blue-100 text-blue-600" : ""
}`}
title="Italic"
>
<Italic className="h-4 w-4" />
</button>
<button
onClick={() => editor?.chain().focus().toggleUnderline().run()}
className={`p-1 rounded hover:bg-gray-100 transition-colors ${editor?.isActive("underline") ? "bg-blue-100 text-blue-600" : ""
}`}
title="Underline"
>
<UnderlinedIcon className="h-4 w-4" />
</button>
<button
onClick={() => editor?.chain().focus().toggleStrike().run()}
className={`p-1 rounded hover:bg-gray-100 transition-colors ${editor?.isActive("strike") ? "bg-blue-100 text-blue-600" : ""
}`}
title="Strikethrough"
>
<Strikethrough className="h-4 w-4" />
</button>
<button
onClick={() => editor?.chain().focus().toggleCode().run()}
className={`p-1 rounded hover:bg-gray-100 transition-colors ${editor?.isActive("code") ? "bg-blue-100 text-blue-600" : ""
}`}
title="Code"
>
<Code className="h-4 w-4" />
</button>
</div>
</BubbleMenu>
)}
<BubbleMenu editor={editor} tippyOptions={{ duration: 100 }}>
<div className="flex bg-white rounded-lg shadow-lg p-2 gap-1 border border-gray-200 z-50">
<button
onClick={() => editor?.chain().focus().toggleBold().run()}
className={`p-1 rounded hover:bg-gray-100 transition-colors ${editor?.isActive("bold") ? "bg-blue-100 text-blue-600" : ""
}`}
title="Bold"
>
<Bold className="h-4 w-4" />
</button>
<button
onClick={() => editor?.chain().focus().toggleItalic().run()}
className={`p-1 rounded hover:bg-gray-100 transition-colors ${editor?.isActive("italic") ? "bg-blue-100 text-blue-600" : ""
}`}
title="Italic"
>
<Italic className="h-4 w-4" />
</button>
<button
onClick={() => editor?.chain().focus().toggleUnderline().run()}
className={`p-1 rounded hover:bg-gray-100 transition-colors ${editor?.isActive("underline") ? "bg-blue-100 text-blue-600" : ""
}`}
title="Underline"
>
<UnderlinedIcon className="h-4 w-4" />
</button>
<button
onClick={() => editor?.chain().focus().toggleStrike().run()}
className={`p-1 rounded hover:bg-gray-100 transition-colors ${editor?.isActive("strike") ? "bg-blue-100 text-blue-600" : ""
}`}
title="Strikethrough"
>
<Strikethrough className="h-4 w-4" />
</button>
<button
onClick={() => editor?.chain().focus().toggleCode().run()}
className={`p-1 rounded hover:bg-gray-100 transition-colors ${editor?.isActive("code") ? "bg-blue-100 text-blue-600" : ""
}`}
title="Code"
>
<Code className="h-4 w-4" />
</button>
</div>
</BubbleMenu>
<EditorContent
editor={editor}
className={`tiptap-text-editor w-full ${!disabled ? 'min-h-[1.5em]' : ''}`}
className={`tiptap-text-editor w-full`}
style={{
// Ensure the editor maintains the same visual appearance
lineHeight: 'inherit',

View file

@ -9,7 +9,6 @@ interface TiptapTextReplacerProps {
slideData?: any;
slideIndex?: number;
onContentChange?: (content: string, path: string, slideIndex?: number) => void;
isEditMode?: boolean;
}
const TiptapTextReplacer: React.FC<TiptapTextReplacerProps> = ({
@ -17,13 +16,12 @@ const TiptapTextReplacer: React.FC<TiptapTextReplacerProps> = ({
slideData,
slideIndex,
onContentChange = () => { },
isEditMode = true
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const [processedElements, setProcessedElements] = useState(new Set<HTMLElement>());
useEffect(() => {
if (!isEditMode || !containerRef.current) return;
if (!containerRef.current) return;
const container = containerRef.current;
@ -49,7 +47,7 @@ const TiptapTextReplacer: React.FC<TiptapTextReplacerProps> = ({
const trimmedText = directTextContent.trim();
// Check if element has meaningful text content
if (!trimmedText || trimmedText.length < 2) return;
if (!trimmedText || trimmedText.length <= 2) return;
// Skip elements that contain other elements with text (to avoid double processing)
if (hasTextChildren(htmlElement)) return;
@ -93,30 +91,29 @@ const TiptapTextReplacer: React.FC<TiptapTextReplacerProps> = ({
);
}
});
// Replace the element
htmlElement.parentNode?.replaceChild(tiptapContainer, htmlElement);
// Mark as processed
setProcessedElements(prev => new Set(prev).add(htmlElement));
// Render TiptapText
const root = ReactDOM.createRoot(tiptapContainer);
root.render(
<TiptapText
content={trimmedText}
key={JSON.stringify(slideData)}
content={dataPath.originalText}
onContentChange={(content: string) => {
if (dataPath && onContentChange) {
onContentChange(content, dataPath, slideIndex);
onContentChange(content, dataPath.path, slideIndex);
}
}}
placeholder="Enter text..."
disabled={!isEditMode}
/>
);
});
};
// Function to check if element is inside an ignored element tree
const isInIgnoredElementTree = (element: HTMLElement): boolean => {
// List of element types that should be ignored entirely with all their children
@ -224,28 +221,30 @@ const TiptapTextReplacer: React.FC<TiptapTextReplacerProps> = ({
};
// Helper function to find data path for text content
const findDataPath = (data: any, targetText: string, path = ''): string => {
if (!data || typeof data !== 'object') return '';
const findDataPath = (data: any, targetText: string, path = ''): {
path: string;
originalText: string;
} => {
if (!data || typeof data !== 'object') return { path: '', originalText: '' };
for (const [key, value] of Object.entries(data)) {
const currentPath = path ? `${path}.${key}` : key;
if (typeof value === 'string' && value.trim() === targetText.trim()) {
return currentPath;
return { path: currentPath, originalText: value };
}
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
const result = findDataPath(value[i], targetText, `${currentPath}[${i}]`);
if (result) return result;
if (result.path) return result;
}
} else if (typeof value === 'object' && value !== null) {
const result = findDataPath(value, targetText, currentPath);
if (result) return result;
if (result.path) return result;
}
}
return '';
return { path: '', originalText: '' };
};
// Replace text elements after a short delay to ensure DOM is ready
@ -254,10 +253,10 @@ const TiptapTextReplacer: React.FC<TiptapTextReplacerProps> = ({
return () => {
clearTimeout(timer);
};
}, [slideData, isEditMode, slideIndex]);
}, [slideData, slideIndex]);
return (
<div ref={containerRef} key={slideData} className="tiptap-text-replacer">
<div ref={containerRef} className="tiptap-text-replacer">
{children}
</div>
);

View file

@ -52,13 +52,13 @@ export const useGroupLayouts = () => {
<EditableLayoutWrapper
slideIndex={slide.index}
slideData={slide.content}
isEditMode={isEditMode}
>
<TiptapTextReplacer
key={slide.id}
slideData={slide.content}
slideIndex={slide.index}
isEditMode={isEditMode}
onContentChange={(content: string, dataPath: string, slideIndex?: number) => {
// Dispatch Redux action to update slide content
if (dataPath && slideIndex !== undefined) {
@ -70,12 +70,12 @@ export const useGroupLayouts = () => {
}
}}
>
<Layout key={`layout-${slide.index}-${JSON.stringify(slide.content)}`} data={slide.content} />
<Layout data={slide.content} />
</TiptapTextReplacer>
</EditableLayoutWrapper>
);
}
return <Layout key={`layout-${slide.index}-${JSON.stringify(slide.content)}`} data={slide.content} />;
return <Layout data={slide.content} />;
};
}, [getGroupLayout, dispatch]);

View file

@ -41,7 +41,7 @@ const OutlineContent: React.FC<OutlineContentProps> = ({
);
return (
<div className="space-y-6">
<div className="space-y-6 font-roboto">
<div className="flex items-center justify-between">
<h5 className="text-lg font-medium">
Presentation Outline
@ -53,11 +53,10 @@ const OutlineContent: React.FC<OutlineContentProps> = ({
</div>
)}
</div>
{/* Skeleton loading state */}
{isLoading && (
<div className="space-y-4">
{[...Array(5)].map((_, index) => (
{[...Array(6)].map((_, index) => (
<div key={index} className="animate-pulse">
<div className="flex items-start space-x-3 p-4 border rounded-lg bg-white">
<div className="w-6 h-6 bg-gray-200 rounded-full flex-shrink-0"></div>

View file

@ -17,3 +17,14 @@ export interface IconSearch {
limit: number;
}
export interface PreviousGeneratedImagesResponse {
extras: {
prompt: string;
theme_prompt: string | null;
},
created_at: string;
id: string;
path: string;
}

View file

@ -1,6 +1,5 @@
import { getHeader, getHeaderForFormData } from "./header";
import { IconSearch, ImageGenerate, ImageSearch } from "./params";
import { IconSearch, ImageGenerate, ImageSearch, PreviousGeneratedImagesResponse } from "./params";
export class PresentationGenerationApi {
@ -70,6 +69,7 @@ export class PresentationGenerationApi {
prompt: string
) {
try {
const response = await fetch(
`/api/v1/ppt/slide/edit`,
{
@ -78,6 +78,7 @@ export class PresentationGenerationApi {
body: JSON.stringify({
id: slide_id,
prompt,
}),
cache: "no-cache",
}
@ -189,6 +190,27 @@ export class PresentationGenerationApi {
throw error;
}
}
static getPreviousGeneratedImages = async():Promise<PreviousGeneratedImagesResponse[]>=>{
try {
const response = await fetch(
`/api/v1/ppt/images/generated`,
{
method: "GET",
headers: getHeader(),
}
);
if (response.ok) {
const data = await response.json();
return data;
} else {
throw new Error(`Failed to get previous generated images: ${response.statusText}`);
}
} catch (error) {
console.error("error in getting previous generated images", error);
throw error;
}
}
static async searchIcons(iconSearch: IconSearch) {
try {
const response = await fetch(

View file

@ -8,6 +8,7 @@ export interface PresentationResponse {
id: string;
title: string;
created_at: string;
updated_at: string;
data: any | null;
file: string;
n_slides: number;

View file

@ -26,6 +26,7 @@ const DashboardPage: React.FC = () => {
setIsLoading(true);
setError(null);
const data = await DashboardApi.getPresentations();
data.sort((a: any, b: any) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
setPresentations(data);
} catch (err) {
setError(null);