feat(nextjs, fastapi): implement previous generated images feature and update slide ID on edit
This commit is contained in:
parent
c583590c2f
commit
04a1516a25
11 changed files with 151 additions and 87 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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[] = [];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue