diff --git a/frontend/app/(presentation-generator)/generate/outline/page.tsx b/frontend/app/(presentation-generator)/generate/outline/page.tsx index 2cc3bd7..f581b12 100644 --- a/frontend/app/(presentation-generator)/generate/outline/page.tsx +++ b/frontend/app/(presentation-generator)/generate/outline/page.tsx @@ -24,6 +24,7 @@ import { Layers, Loader2, Paperclip, + Plus, } from "lucide-react"; import { toast } from "sonner"; import { @@ -51,13 +52,14 @@ export default function WizardOutlinePage() { const [selectedTemplate, setSelectedTemplate] = useState< TemplateLayoutsWithSettings | string | null >(null); - const [activeTab, setActiveTab] = useState("outline"); + const [rightTab, setRightTab] = useState("brief"); const [isGenerating, setIsGenerating] = useState(false); - // Reuse existing hooks const streamState = useOutlineStreaming(presentation_id); const { handleDragEnd, handleAddSlide } = useOutlineManagement(outlines); + const isStreaming = streamState.isStreaming || streamState.isLoading; + const handleBack = () => { dispatch(setWizardStep(2)); router.push("/generate/configure"); @@ -70,7 +72,7 @@ export default function WizardOutlinePage() { } if (!selectedTemplate) { - setActiveTab("template"); + setRightTab("template"); toast.error("Please select a template/layout first."); return; } @@ -78,7 +80,6 @@ export default function WizardOutlinePage() { try { setIsGenerating(true); - // Build layout object (same pattern as usePresentationGeneration) let layout; if (typeof selectedTemplate === "string") { const customDetail = await getCustomTemplateDetails(selectedTemplate); @@ -89,7 +90,7 @@ export default function WizardOutlinePage() { layout = { name: customDetail.id, ordered: false, - slides: customDetail.layouts.map((l) => ({ + slides: customDetail.layouts.map(l => ({ id: customDetail.id.startsWith("custom-") ? `${customDetail.id}:${l.layoutId}` : `custom-${customDetail.id}:${l.layoutId}`, @@ -104,7 +105,7 @@ export default function WizardOutlinePage() { layout = { name: selectedTemplate.id, ordered: false, - slides: selectedTemplate.layouts.map((l) => ({ + slides: selectedTemplate.layouts.map(l => ({ id: l.layoutId, name: l.layoutName, description: l.layoutDescription, @@ -115,7 +116,6 @@ export default function WizardOutlinePage() { }; } - // Prepare (same as existing flow) const response = await PresentationGenerationApi.presentationPrepare({ presentation_id, outlines, @@ -141,18 +141,24 @@ export default function WizardOutlinePage() { if (!presentation_id) { return ( - +

No presentation in progress.

- +
); } + const templateName = selectedTemplate + ? typeof selectedTemplate === "string" + ? selectedTemplate + : selectedTemplate.name + : null; + return ( -
+
- - {/* Split View */} +
+ {/* Two-column split */}
- {/* LEFT: Source Content */} -
-
-

- - Source Content -

+ + {/* LEFT — Outline Slides (60%) */} +
+ {/* Sticky header */} +
+
+

+ Presentation Outline +

+ {outlines && outlines.length > 0 && ( + + {outlines.length} slides + + )} + {isStreaming && ( + + + Generating... + + )} +
+
-
- {/* Brief text */} - {wizard.briefText && ( -
-

- Brief -

-
- {wizard.briefText} -
-
- )} - {/* Uploaded files */} - {wizard.uploadedFiles.length > 0 && ( -
-

- Uploaded Files -

-
- {wizard.uploadedFiles.map((f, i) => { - // Count how many slides this file is linked to - const linkedCount = Object.values( - wizard.slideAttachments - ).filter((names) => names.includes(f.name)).length; - - return ( -
- - {f.name} - - {/* Link to slides popover */} - {outlines && outlines.length > 0 && ( - - - - - -

- Link to slides -

-
- {outlines.map((outline, slideIdx) => { - const title = - (outline.content || "") - .split("\n")[0] - ?.replace(/^#+\s*/, "") - .trim() || `Slide ${slideIdx + 1}`; - const isLinked = ( - wizard.slideAttachments[slideIdx] || [] - ).includes(f.name); - - return ( - - ); - })} -
-
-
- )} -
- ); - })} -
-
- )} - - {/* Decomposed preview */} - {wizard.decomposedFiles.length > 0 && ( -
-

- Extracted Content -

- {wizard.decomposedFiles.map((doc: any, i: number) => ( -
-

- {doc.file_name || `Document ${i + 1}`} -

-

- {typeof doc === "string" - ? doc - : doc.content || doc.text || JSON.stringify(doc).slice(0, 300)} -

-
- ))} -
- )} - - {!wizard.briefText && wizard.uploadedFiles.length === 0 && ( -

No source content

- )} + {/* Scrollable slides */} +
+
- {/* RIGHT: Outline + Template Tabs */} -
+ {/* RIGHT — Brief + Template (40%) */} +
- - Outline - - + + + + Brief + + + Template + {templateName && ( + + )} -
- - - + {/* Brief tab */} + + {wizard.briefText && ( +
+

+ Brief +

+
+ {wizard.briefText} +
+
+ )} - - - -
+ {wizard.uploadedFiles.length > 0 && ( +
+

+ Uploaded Files +

+
+ {wizard.uploadedFiles.map((f, i) => { + const linkedCount = Object.values( + wizard.slideAttachments + ).filter(names => names.includes(f.name)).length; + + return ( +
+ + {f.name} + + {outlines && outlines.length > 0 && ( + + + + + +

+ Link to slides +

+
+ {outlines.map((outline, slideIdx) => { + const title = + (outline.content || "") + .split("\n")[0] + ?.replace(/^#+\s*/, "") + .trim() || `Slide ${slideIdx + 1}`; + const isLinked = ( + wizard.slideAttachments[slideIdx] || [] + ).includes(f.name); + + return ( + + ); + })} +
+
+
+ )} +
+ ); + })} +
+
+ )} + + {wizard.decomposedFiles.length > 0 && ( +
+

+ Extracted Content +

+ {wizard.decomposedFiles.map((doc: any, i: number) => ( +
+

+ {doc.file_name || `Document ${i + 1}`} +

+

+ {typeof doc === "string" + ? doc + : doc.content || doc.text || JSON.stringify(doc).slice(0, 300)} +

+
+ ))} +
+ )} + + {!wizard.briefText && wizard.uploadedFiles.length === 0 && ( +

No source content

+ )} + + + {/* Template tab */} + + +
- {/* Bottom Bar */} -
+ {/* Bottom action bar */} +
-
- {streamState.isStreaming && ( - - - Streaming outlines... +
+ {!selectedTemplate && !isStreaming && outlines && outlines.length > 0 && ( +

+ Select a template to continue → +

+ )} + {templateName && !isStreaming && ( + + Template:{" "} + {templateName} )}
- +
); } diff --git a/frontend/app/(presentation-generator)/outline/components/OutlineItem.tsx b/frontend/app/(presentation-generator)/outline/components/OutlineItem.tsx index 8843a37..3ab6a87 100644 --- a/frontend/app/(presentation-generator)/outline/components/OutlineItem.tsx +++ b/frontend/app/(presentation-generator)/outline/components/OutlineItem.tsx @@ -1,27 +1,35 @@ +"use client" + import { useSortable } from "@dnd-kit/sortable" import { CSS } from "@dnd-kit/utilities" -import { Paperclip, Trash2 } from "lucide-react" +import { GripVertical, Paperclip, Sparkles, Trash2, X } from "lucide-react" import { RootState } from "@/store/store" import { useDispatch, useSelector } from "react-redux" import { deleteSlideOutline, setOutlines } from "@/store/slices/presentationGeneration" -import ToolTip from "@/components/ToolTip" import MarkdownEditor from "../../components/MarkdownEditor" import { useEffect, useMemo, useRef, useState } from "react" import { marked } from "marked" - interface OutlineItemProps { - slideOutline: { - content: string, - }, + slideOutline: { content: string } index: number isStreaming: boolean isActiveStreaming?: boolean isStableStreaming?: boolean - /** File names attached to this slide */ attachedFiles?: string[] } +function parseContent(content: string): { title: string; bullets: string[] } { + const lines = (content || "").split("\n").map(l => l.trim()).filter(Boolean) + if (!lines.length) return { title: "", bullets: [] } + const title = lines[0].replace(/^#+\s*/, "").trim() + const bullets = lines.slice(1) + .map(l => l.replace(/^[-*+]\s*/, "").replace(/^#+\s*/, "").trim()) + .filter(Boolean) + .slice(0, 4) + return { title, bullets } +} + export function OutlineItem({ index, slideOutline, @@ -30,147 +38,184 @@ export function OutlineItem({ isStableStreaming = false, attachedFiles, }: OutlineItemProps) { - const { - outlines, - } = useSelector((state: RootState) => state.presentationGeneration); + const { outlines } = useSelector((state: RootState) => state.presentationGeneration) const dispatch = useDispatch() + const [isEditing, setIsEditing] = useState(false) + const [showRewrite, setShowRewrite] = useState(false) + const [rewritePrompt, setRewritePrompt] = useState("") useEffect(() => { if (isStreaming && slideOutline) { - const outlineItem = document.getElementById(`outline-item-${index}`); - if (outlineItem) { - outlineItem.scrollIntoView({ - behavior: "smooth", - block: "center", - inline: "nearest", - }); - } + document.getElementById(`outline-item-${index}`)?.scrollIntoView({ + behavior: "smooth", block: "center", + }) } - }, [outlines.length]); + }, [outlines.length]) - const handleSlideChange = (newOutline:any) => { - if (isStreaming) return; - const newData = outlines?.map((each, idx) => { - if (idx === index - 1) { - return { - content: newOutline - } - } - return each; - }); - - if (!newData) return; - dispatch(setOutlines(newData)); + const handleSlideChange = (newOutline: string) => { + if (isStreaming) return + const newData = outlines?.map((each, idx) => + idx === index - 1 ? { content: newOutline } : each + ) + if (newData) dispatch(setOutlines(newData)) } - - // DnD sortable configuration - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: index }) - - const style = { - transform: CSS.Transform.toString(transform), - transition, - } const handleSlideDelete = () => { - if (isStreaming) return; + if (isStreaming) return dispatch(deleteSlideOutline({ index: index - 1 })) - } - // Throttled markdown rendering only for the active streaming item to avoid flicker - const [renderedHtml, setRenderedHtml] = useState("") - const throttleRef = useRef(null) + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = + useSortable({ id: index }) + const style = { transform: CSS.Transform.toString(transform), transition } + + // Throttled streaming render + const [renderedHtml, setRenderedHtml] = useState("") + const throttleRef = useRef | null>(null) useEffect(() => { if (!isStreaming || !isActiveStreaming) return - const content = slideOutline.content || "" - // Throttle updates to ~60ms to reduce reflows/flicker - if (throttleRef.current) { - window.clearTimeout(throttleRef.current) - } - throttleRef.current = window.setTimeout(() => { - try { - setRenderedHtml(marked.parse(content) as string) - } catch { - setRenderedHtml("") - } + if (throttleRef.current) clearTimeout(throttleRef.current) + throttleRef.current = setTimeout(() => { + try { setRenderedHtml(marked.parse(slideOutline.content || "") as string) } + catch { setRenderedHtml("") } }, 60) - return () => { - if (throttleRef.current) { - window.clearTimeout(throttleRef.current) - } - } + return () => { if (throttleRef.current) clearTimeout(throttleRef.current) } }, [isStreaming, isActiveStreaming, slideOutline.content]) - // Memoized stable HTML for previous (already completed) items during streaming const stableHtml = useMemo(() => { - if (!isStreaming || isActiveStreaming) return null - if (!isStableStreaming) return null - try { - return marked.parse(slideOutline.content || "") as string - } catch { - return null - } + if (!isStreaming || isActiveStreaming || !isStableStreaming) return null + try { return marked.parse(slideOutline.content || "") as string } + catch { return null } }, [isStreaming, isActiveStreaming, isStableStreaming, slideOutline.content]) + const { title, bullets } = parseContent(slideOutline.content || "") + return ( -
- {/* Main Title Row */} +
- {/* Drag Handle with Number - Make it smaller on mobile */} + {/* Drag Handle */}
-
-
-
-
-
-
- {index} +
- {/* Main Title Input - Add onFocus handler */} -
- {/* Editable Markdown Content */} + {/* Slide Number Badge */} +
+ + {index} + +
+ + {/* Content */} +
{isStreaming ? ( isActiveStreaming ? ( -
+
+
+ {[0, 150, 300].map(delay => ( + + ))} +
+
+
) : stableHtml ? (
) : ( -

{slideOutline.content || ''}

+
+ {[80, 120, 60].map((w, i) => ( +
+ ))} +
) + ) : isEditing ? ( +
+ + +
) : ( - handleSlideChange(content)} - /> +
setIsEditing(true)} + > + {title ? ( +

{title}

+ ) : ( +

Empty slide — click to edit

+ )} + {bullets.length > 0 && ( +
    + {bullets.map((b, i) => ( +
  • + + {b} +
  • + ))} +
+ )} +
+ )} + + {/* Inline AI Rewrite prompt */} + {showRewrite && !isStreaming && ( +
+ + setRewritePrompt(e.target.value)} + placeholder="Rewrite this slide as..." + className="flex-1 text-xs bg-transparent outline-none text-gray-700 placeholder-gray-400" + onKeyDown={e => { + if (e.key === "Escape") { setShowRewrite(false); setRewritePrompt("") } + }} + /> + +
)} {/* Attached file badges */} {attachedFiles && attachedFiles.length > 0 && ( -
- {attachedFiles.map((fileName) => ( +
+ {attachedFiles.map(fileName => ( )} -
- {/* Action Buttons */} -
- - + {/* Hover action buttons */} + {!isStreaming && !isEditing && ( +
+ - -
+
+ )} + + {/* Active streaming pulse overlay */} + {isActiveStreaming && ( +
+ )}
- - -
) } - diff --git a/frontend/app/(presentation-generator)/outline/components/OutlinePage.tsx b/frontend/app/(presentation-generator)/outline/components/OutlinePage.tsx index db9428f..60d78fa 100644 --- a/frontend/app/(presentation-generator)/outline/components/OutlinePage.tsx +++ b/frontend/app/(presentation-generator)/outline/components/OutlinePage.tsx @@ -1,16 +1,12 @@ "use client"; import React, { useState, useEffect } from "react"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { RootState } from "@/store/store"; import { useSelector, useDispatch } from "react-redux"; import { OverlayLoader } from "@/components/ui/overlay-loader"; -import Wrapper from "@/components/Wrapper"; import OutlineContent from "./OutlineContent"; import EmptyStateView from "./EmptyStateView"; import GenerateButton from "./GenerateButton"; - -import { TABS, Template } from "../types/index"; import { useOutlineStreaming } from "../hooks/useOutlineStreaming"; import { useOutlineManagement } from "../hooks/useOutlineManagement"; import { usePresentationGeneration } from "../hooks/usePresentationGeneration"; @@ -18,6 +14,7 @@ import TemplateSelection from "./TemplateSelection"; import { TemplateLayoutsWithSettings } from "@/app/presentation-templates/utils"; import { templates } from "@/app/presentation-templates"; import { setSelectedTemplateId } from "@/store/slices/presentationGeneration"; +import { Layers, Plus } from "lucide-react"; const OutlinePage: React.FC = () => { const dispatch = useDispatch(); @@ -25,48 +22,39 @@ const OutlinePage: React.FC = () => { (state: RootState) => state.presentationGeneration ); - const [activeTab, setActiveTab] = useState(TABS.OUTLINE); - - // Restore selectedTemplate from Redux (persists across navigation) - // Custom templates are stored as string (templateId), built-ins as TemplateLayoutsWithSettings - const [selectedTemplate, setSelectedTemplate] = useState(() => { + const [selectedTemplate, setSelectedTemplate] = useState< + TemplateLayoutsWithSettings | string | null + >(() => { if (!selectedTemplateId) return null; - // Check if it matches a built-in template - const builtin = templates.find((t) => t.id === selectedTemplateId); - if (builtin) return builtin; - // Otherwise it's a custom template id (string) - return selectedTemplateId; + const builtin = templates.find(t => t.id === selectedTemplateId); + return builtin || selectedTemplateId; }); const handleSelectTemplate = (template: TemplateLayoutsWithSettings | string) => { setSelectedTemplate(template); - // Persist to Redux - const id = typeof template === 'string' ? template : template.id; + const id = typeof template === "string" ? template : template.id; dispatch(setSelectedTemplateId(id)); }; - // Custom hooks + const streamState = useOutlineStreaming(presentation_id); const { handleDragEnd, handleAddSlide } = useOutlineManagement(outlines); const { loadingState, handleSubmit } = usePresentationGeneration( presentation_id, outlines, selectedTemplate, - setActiveTab + () => {} ); - // Also sync if selectedTemplateId changes externally (e.g. after clearing) useEffect(() => { - if (!selectedTemplateId) { - setSelectedTemplate(null); - } + if (!selectedTemplateId) setSelectedTemplate(null); }, [selectedTemplateId]); - if (!presentation_id) { - return ; - } + if (!presentation_id) return ; + + const isStreaming = streamState.isStreaming || streamState.isLoading; return ( -
+
{ duration={loadingState.duration} /> - -
- - - Outline & Content - Select Template - +
+ {/* Two-column split */} +
-
- + {/* Sticky header */} +
+
+

+ Presentation Outline +

+ {outlines && outlines.length > 0 && ( + + {outlines.length} slides + + )} +
+
- -
- {/* Fixed Button */} -
-
- + {/* Scrollable card list */} +
+ +
+
+ + {/* RIGHT — Template Selection */} +
+
+ +

Select Template

+ {selectedTemplate && ( + + {typeof selectedTemplate === "string" + ? selectedTemplate + : selectedTemplate.name} + + )} +
+
+ +
- + + {/* Bottom generate bar */} +
+ +
+
); }; -export default OutlinePage; \ No newline at end of file +export default OutlinePage;