Redesign outline editor: two-column split layout + premium card UX

- OutlinePage: two-column split (slides left 60%, template right 40%),
  sticky header with slide count badge + Add Slide button, #F8F7FF bg
- WizardOutlinePage: outline left, tabbed Brief|Template right panel,
  file-to-slide linking preserved in Brief tab
- OutlineItem: title+bullets preview with click-to-edit, hover-reveal
  drag handle (GripVertical) + AI Rewrite (inline prompt) + Delete,
  streaming states: pulsing left border + bouncing dots for active card

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-03-19 20:48:32 +00:00
parent d10759b4de
commit 455e6e0c00
3 changed files with 476 additions and 376 deletions

View file

@ -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 (
<Wrapper className="py-16 text-center">
<div className="py-16 text-center">
<FileText className="w-12 h-12 text-gray-300 mx-auto mb-4" />
<p className="text-gray-500 mb-4">No presentation in progress.</p>
<Button variant="outline" onClick={() => router.push("/generate/upload")}>
Start Over
</Button>
</Wrapper>
</div>
);
}
const templateName = selectedTemplate
? typeof selectedTemplate === "string"
? selectedTemplate
: selectedTemplate.name
: null;
return (
<div className="h-[calc(100vh-140px)] flex flex-col">
<div className="h-[calc(100vh-140px)] flex flex-col bg-[#F8F7FF]">
<OverlayLoader
show={isGenerating}
text="Preparing presentation..."
@ -160,207 +166,235 @@ export default function WizardOutlinePage() {
duration={30}
/>
<Wrapper className="flex-1 flex flex-col overflow-hidden py-4">
{/* Split View */}
<div className="flex-1 flex flex-col overflow-hidden px-4 py-4 max-w-[1280px] mx-auto w-full">
{/* Two-column split */}
<div className="flex-1 flex gap-4 min-h-0">
{/* LEFT: Source Content */}
<div className="w-[340px] flex-shrink-0 flex flex-col border rounded-xl bg-white overflow-hidden">
<div className="px-4 py-3 border-b bg-gray-50">
<h3 className="text-sm font-semibold flex items-center gap-2">
<FileText className="w-4 h-4" />
Source Content
</h3>
{/* LEFT — Outline Slides (60%) */}
<div className="flex-[3] flex flex-col min-h-0 rounded-2xl bg-white border border-[rgba(81,70,229,0.10)] shadow-[0_1px_8px_rgba(81,70,229,0.06)] overflow-hidden">
{/* Sticky header */}
<div className="flex-shrink-0 flex items-center justify-between px-5 py-3.5 border-b border-[rgba(81,70,229,0.08)] bg-white">
<div className="flex items-center gap-2.5">
<h2 className="text-sm font-semibold text-gray-900">
Presentation Outline
</h2>
{outlines && outlines.length > 0 && (
<span className="inline-flex items-center justify-center px-2 py-0.5 rounded-full bg-[#5146E5]/10 text-[#5146E5] text-xs font-semibold">
{outlines.length} slides
</span>
)}
{isStreaming && (
<span className="inline-flex items-center gap-1.5 text-xs text-[#5146E5]">
<Loader2 className="w-3 h-3 animate-spin" />
Generating...
</span>
)}
</div>
<button
onClick={handleAddSlide}
disabled={isStreaming}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-[#5146E5] text-white hover:bg-[#5146E5]/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Plus className="w-3.5 h-3.5" />
Add Slide
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 text-sm">
{/* Brief text */}
{wizard.briefText && (
<div className="mb-4">
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-2">
Brief
</h4>
<div className="prose prose-sm max-w-none whitespace-pre-wrap text-gray-700">
{wizard.briefText}
</div>
</div>
)}
{/* Uploaded files */}
{wizard.uploadedFiles.length > 0 && (
<div>
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-2">
Uploaded Files
</h4>
<div className="space-y-2">
{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 (
<div
key={i}
className="flex items-center gap-2 p-2 rounded-lg bg-gray-50 text-xs"
>
<FileText className="w-3.5 h-3.5 text-[#5146E5] flex-shrink-0" />
<span className="truncate flex-1">{f.name}</span>
{/* Link to slides popover */}
{outlines && outlines.length > 0 && (
<Popover>
<PopoverTrigger asChild>
<button
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium text-gray-500 hover:text-[#5146E5] hover:bg-[#5146E5]/5 transition-colors flex-shrink-0"
title="Link to slides"
>
<Paperclip className="w-3 h-3" />
{linkedCount > 0 && (
<span className="text-[#5146E5]">
{linkedCount}
</span>
)}
</button>
</PopoverTrigger>
<PopoverContent
side="right"
align="start"
className="w-64 p-3"
>
<p className="text-xs font-semibold text-gray-700 mb-2">
Link to slides
</p>
<div className="space-y-1.5 max-h-48 overflow-y-auto">
{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 (
<label
key={slideIdx}
className="flex items-center gap-2 p-1.5 rounded hover:bg-gray-50 cursor-pointer text-xs"
>
<Checkbox
checked={isLinked}
onCheckedChange={() =>
dispatch(
toggleSlideAttachment({
slideIndex: slideIdx,
fileName: f.name,
})
)
}
className="h-3.5 w-3.5"
/>
<span className="text-gray-600 font-medium w-5 flex-shrink-0">
{slideIdx + 1}.
</span>
<span className="truncate text-gray-700">
{title}
</span>
</label>
);
})}
</div>
</PopoverContent>
</Popover>
)}
</div>
);
})}
</div>
</div>
)}
{/* Decomposed preview */}
{wizard.decomposedFiles.length > 0 && (
<div className="mt-4">
<h4 className="text-xs font-semibold text-gray-500 uppercase mb-2">
Extracted Content
</h4>
{wizard.decomposedFiles.map((doc: any, i: number) => (
<div key={i} className="mb-3 p-2 rounded bg-gray-50">
<p className="text-xs text-gray-500 mb-1">
{doc.file_name || `Document ${i + 1}`}
</p>
<p className="text-xs text-gray-700 line-clamp-6 whitespace-pre-wrap">
{typeof doc === "string"
? doc
: doc.content || doc.text || JSON.stringify(doc).slice(0, 300)}
</p>
</div>
))}
</div>
)}
{!wizard.briefText && wizard.uploadedFiles.length === 0 && (
<p className="text-gray-400 text-center mt-8">No source content</p>
)}
{/* Scrollable slides */}
<div className="flex-1 overflow-y-auto p-4 custom_scrollbar">
<OutlineContent
outlines={outlines}
isLoading={streamState.isLoading}
isStreaming={streamState.isStreaming}
activeSlideIndex={streamState.activeSlideIndex}
highestActiveIndex={streamState.highestActiveIndex}
onDragEnd={handleDragEnd}
onAddSlide={handleAddSlide}
slideAttachments={wizard.slideAttachments}
uploadedFiles={wizard.uploadedFiles}
/>
</div>
</div>
{/* RIGHT: Outline + Template Tabs */}
<div className="flex-1 flex flex-col min-w-0">
{/* RIGHT — Brief + Template (40%) */}
<div className="flex-[2] flex flex-col min-h-0 rounded-2xl bg-white border border-[rgba(81,70,229,0.10)] shadow-[0_1px_8px_rgba(81,70,229,0.06)] overflow-hidden">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
value={rightTab}
onValueChange={setRightTab}
className="flex-1 flex flex-col min-h-0"
>
<TabsList className="grid w-[50%] mx-auto grid-cols-2 mb-4">
<TabsTrigger value="outline">Outline</TabsTrigger>
<TabsTrigger value="template">
<Layers className="w-4 h-4 mr-1" />
<TabsList className="flex-shrink-0 flex w-full rounded-none border-b border-[rgba(81,70,229,0.08)] bg-white h-auto p-0">
<TabsTrigger
value="brief"
className="flex-1 rounded-none border-b-2 border-transparent data-[state=active]:border-[#5146E5] data-[state=active]:text-[#5146E5] py-3.5 text-xs font-semibold text-gray-500 transition-colors"
>
<FileText className="w-3.5 h-3.5 mr-1.5" />
Brief
</TabsTrigger>
<TabsTrigger
value="template"
className="flex-1 rounded-none border-b-2 border-transparent data-[state=active]:border-[#5146E5] data-[state=active]:text-[#5146E5] py-3.5 text-xs font-semibold text-gray-500 transition-colors"
>
<Layers className="w-3.5 h-3.5 mr-1.5" />
Template
{templateName && (
<span className="ml-1.5 w-1.5 h-1.5 rounded-full bg-[#5146E5] inline-block" />
)}
</TabsTrigger>
</TabsList>
<div className="flex-1 overflow-y-auto">
<TabsContent value="outline" className="h-full">
<OutlineContent
outlines={outlines}
isLoading={streamState.isLoading}
isStreaming={streamState.isStreaming}
activeSlideIndex={streamState.activeSlideIndex}
highestActiveIndex={streamState.highestActiveIndex}
onDragEnd={handleDragEnd}
onAddSlide={handleAddSlide}
slideAttachments={wizard.slideAttachments}
uploadedFiles={wizard.uploadedFiles}
/>
</TabsContent>
{/* Brief tab */}
<TabsContent value="brief" className="flex-1 overflow-y-auto p-4 custom_scrollbar mt-0">
{wizard.briefText && (
<div className="mb-5">
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider mb-2">
Brief
</p>
<div className="prose prose-sm max-w-none whitespace-pre-wrap text-gray-700 text-xs leading-relaxed">
{wizard.briefText}
</div>
</div>
)}
<TabsContent value="template" className="h-full">
<TemplateSelection
selectedTemplate={selectedTemplate}
onSelectTemplate={setSelectedTemplate}
/>
</TabsContent>
</div>
{wizard.uploadedFiles.length > 0 && (
<div className="mb-5">
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider mb-2">
Uploaded Files
</p>
<div className="space-y-1.5">
{wizard.uploadedFiles.map((f, i) => {
const linkedCount = Object.values(
wizard.slideAttachments
).filter(names => names.includes(f.name)).length;
return (
<div
key={i}
className="flex items-center gap-2 p-2 rounded-lg bg-gray-50 text-xs"
>
<FileText className="w-3.5 h-3.5 text-[#5146E5] flex-shrink-0" />
<span className="truncate flex-1 text-gray-700">{f.name}</span>
{outlines && outlines.length > 0 && (
<Popover>
<PopoverTrigger asChild>
<button
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium text-gray-500 hover:text-[#5146E5] hover:bg-[#5146E5]/5 transition-colors flex-shrink-0"
title="Link to slides"
>
<Paperclip className="w-3 h-3" />
{linkedCount > 0 && (
<span className="text-[#5146E5]">{linkedCount}</span>
)}
</button>
</PopoverTrigger>
<PopoverContent side="right" align="start" className="w-64 p-3">
<p className="text-xs font-semibold text-gray-700 mb-2">
Link to slides
</p>
<div className="space-y-1.5 max-h-48 overflow-y-auto">
{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 (
<label
key={slideIdx}
className="flex items-center gap-2 p-1.5 rounded hover:bg-gray-50 cursor-pointer text-xs"
>
<Checkbox
checked={isLinked}
onCheckedChange={() =>
dispatch(
toggleSlideAttachment({
slideIndex: slideIdx,
fileName: f.name,
})
)
}
className="h-3.5 w-3.5"
/>
<span className="text-gray-600 font-medium w-5 flex-shrink-0">
{slideIdx + 1}.
</span>
<span className="truncate text-gray-700">{title}</span>
</label>
);
})}
</div>
</PopoverContent>
</Popover>
)}
</div>
);
})}
</div>
</div>
)}
{wizard.decomposedFiles.length > 0 && (
<div>
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-wider mb-2">
Extracted Content
</p>
{wizard.decomposedFiles.map((doc: any, i: number) => (
<div key={i} className="mb-3 p-2.5 rounded-lg bg-gray-50 border border-gray-100">
<p className="text-[10px] text-gray-400 mb-1">
{doc.file_name || `Document ${i + 1}`}
</p>
<p className="text-xs text-gray-700 line-clamp-5 whitespace-pre-wrap">
{typeof doc === "string"
? doc
: doc.content || doc.text || JSON.stringify(doc).slice(0, 300)}
</p>
</div>
))}
</div>
)}
{!wizard.briefText && wizard.uploadedFiles.length === 0 && (
<p className="text-gray-400 text-xs text-center mt-10">No source content</p>
)}
</TabsContent>
{/* Template tab */}
<TabsContent value="template" className="flex-1 overflow-y-auto p-4 custom_scrollbar mt-0">
<TemplateSelection
selectedTemplate={selectedTemplate}
onSelectTemplate={setSelectedTemplate}
/>
</TabsContent>
</Tabs>
</div>
</div>
{/* Bottom Bar */}
<div className="pt-4 border-t mt-4 flex justify-between items-center">
{/* Bottom action bar */}
<div className="flex-shrink-0 pt-4 mt-4 border-t border-[rgba(81,70,229,0.08)] flex items-center justify-between gap-4">
<Button
variant="outline"
onClick={handleBack}
className="rounded-full px-6 py-6"
className="rounded-full px-6 py-5 border-[rgba(81,70,229,0.20)] text-gray-600 hover:border-[#5146E5] hover:text-[#5146E5]"
>
<ChevronLeft className="w-4 h-4 mr-1" />
Back
</Button>
<div className="flex items-center gap-3">
{streamState.isStreaming && (
<span className="inline-flex items-center gap-1 text-sm text-blue-600">
<Loader2 className="w-4 h-4 animate-spin" />
Streaming outlines...
<div className="flex items-center gap-3 flex-1 justify-end">
{!selectedTemplate && !isStreaming && outlines && outlines.length > 0 && (
<p className="text-xs text-gray-400">
Select a template to continue
</p>
)}
{templateName && !isStreaming && (
<span className="text-xs text-gray-500">
Template:{" "}
<span className="font-medium text-[#5146E5]">{templateName}</span>
</span>
)}
<Button
@ -371,7 +405,7 @@ export default function WizardOutlinePage() {
!outlines ||
outlines.length === 0
}
className="px-8 py-6 bg-[#5146E5] hover:bg-[#5146E5]/90 text-white font-semibold text-base rounded-full"
className="px-8 py-5 bg-[#5146E5] hover:bg-[#5146E5]/90 text-white font-semibold text-sm rounded-full shadow-[0_4px_16px_rgba(81,70,229,0.30)] disabled:opacity-50 disabled:cursor-not-allowed transition-all"
>
{isGenerating ? (
<>
@ -380,14 +414,14 @@ export default function WizardOutlinePage() {
</>
) : (
<>
Approve & Generate
<ChevronRight className="w-5 h-5 ml-1" />
Approve &amp; Generate
<ChevronRight className="w-4 h-4 ml-1.5" />
</>
)}
</Button>
</div>
</div>
</Wrapper>
</div>
</div>
);
}

View file

@ -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<string>("")
const throttleRef = useRef<number | null>(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<ReturnType<typeof setTimeout> | 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 (
<div className="mb-2">
{/* Main Title Row */}
<div id={`outline-item-${index}`} className="mb-2.5">
<div
ref={setNodeRef}
style={style}
className={`flex items-start gap-2 md:gap-4 p-2 sm:pr-4 border border-black/10 bg-purple-100/10 rounded-[8px] ${isDragging ? "opacity-50" : ""}`}
className={[
"group relative flex items-start rounded-xl bg-white border transition-all duration-200",
isDragging
? "opacity-50 shadow-lg scale-[1.01]"
: "shadow-[0_1px_4px_rgba(0,0,0,0.06)] hover:shadow-[0_4px_16px_rgba(81,70,229,0.10)] hover:-translate-y-px",
isActiveStreaming
? "border-l-[3px] border-l-[#5146E5] border-[#5146E5]/30"
: "border-[rgba(81,70,229,0.12)]",
].join(" ")}
>
{/* Drag Handle with Number - Make it smaller on mobile */}
{/* Drag Handle */}
<div
{...attributes}
{...listeners}
className="min-w-8 sm:min-w-10 w-10 sm:w-14 h-10 sm:h-14 bg-blue-400/10 rounded-[8px] flex items-center justify-center relative cursor-grab"
className="flex-shrink-0 self-stretch flex items-center justify-center w-7 cursor-grab opacity-0 group-hover:opacity-100 transition-opacity"
>
<div className="grid grid-cols-2 gap-[2px]">
<div className="w-[3px] h-[3px] bg-black/80 rounded-full" />
<div className="w-[3px] h-[3px] bg-black/80 rounded-full" />
<div className="w-[3px] h-[3px] bg-black/80 rounded-full" />
<div className="w-[3px] h-[3px] bg-black/80 rounded-full" />
</div>
<span className="text-black/80 text-md sm:text-lg font-medium ml-1">{index}</span>
<GripVertical className="w-4 h-4 text-[#5146E5]/40" />
</div>
{/* Main Title Input - Add onFocus handler */}
<div id={`outline-item-${index}`} className="flex flex-col basis-full gap-2">
{/* Editable Markdown Content */}
{/* Slide Number Badge */}
<div className="flex-shrink-0 pt-3.5 pr-2.5">
<span className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-[#5146E5] text-white text-[10px] font-bold leading-none select-none">
{index}
</span>
</div>
{/* Content */}
<div className="flex-1 py-3 min-w-0">
{isStreaming ? (
isActiveStreaming ? (
<div
className="text-sm flex-1 font-normal prose prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: renderedHtml || "" }}
/>
<div>
<div className="flex gap-1 mb-2">
{[0, 150, 300].map(delay => (
<span
key={delay}
style={{ animationDelay: `${delay}ms` }}
className="w-1.5 h-1.5 rounded-full bg-[#5146E5] animate-bounce"
/>
))}
</div>
<div
className="text-sm prose prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: renderedHtml }}
/>
</div>
) : stableHtml ? (
<div
className="text-sm flex-1 font-normal prose prose-sm max-w-none"
className="text-sm prose prose-sm max-w-none"
dangerouslySetInnerHTML={{ __html: stableHtml }}
/>
) : (
<p className="text-sm flex-1 font-normal">{slideOutline.content || ''}</p>
<div className="flex gap-2 items-center">
{[80, 120, 60].map((w, i) => (
<div key={i} className="h-3 bg-gray-200 rounded animate-pulse" style={{ width: w }} />
))}
</div>
)
) : isEditing ? (
<div>
<MarkdownEditor
key={index}
content={slideOutline.content || ""}
onChange={handleSlideChange}
/>
<button
onClick={() => setIsEditing(false)}
className="mt-2 text-xs font-medium text-[#5146E5] hover:text-[#5146E5]/70 transition-colors"
>
Done editing
</button>
</div>
) : (
<MarkdownEditor
key={index}
content={slideOutline.content || ''}
onChange={(content) => handleSlideChange(content)}
/>
<div
className="cursor-text"
onClick={() => setIsEditing(true)}
>
{title ? (
<p className="font-semibold text-gray-900 text-sm leading-snug">{title}</p>
) : (
<p className="text-sm text-gray-400 italic">Empty slide click to edit</p>
)}
{bullets.length > 0 && (
<ul className="mt-1.5 space-y-0.5">
{bullets.map((b, i) => (
<li key={i} className="flex items-start gap-1.5 text-xs text-gray-500">
<span className="mt-[5px] w-1 h-1 rounded-full bg-[#5146E5]/40 flex-shrink-0" />
<span className="line-clamp-1">{b}</span>
</li>
))}
</ul>
)}
</div>
)}
{/* Inline AI Rewrite prompt */}
{showRewrite && !isStreaming && (
<div className="mt-2.5 flex items-center gap-2 px-2.5 py-2 rounded-lg bg-[#F8F7FF] border border-[#5146E5]/15">
<Sparkles className="w-3.5 h-3.5 text-[#5146E5] flex-shrink-0" />
<input
autoFocus
value={rewritePrompt}
onChange={e => 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("") }
}}
/>
<button
onClick={() => { setShowRewrite(false); setRewritePrompt("") }}
className="text-gray-400 hover:text-gray-600 transition-colors"
>
<X className="w-3 h-3" />
</button>
</div>
)}
{/* Attached file badges */}
{attachedFiles && attachedFiles.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-1.5">
{attachedFiles.map((fileName) => (
<div className="flex flex-wrap gap-1.5 mt-2">
{attachedFiles.map(fileName => (
<span
key={fileName}
className="inline-flex items-center gap-1 rounded-full bg-[#5146E5]/5 border border-[#5146E5]/15 px-2 py-0.5 text-[10px] text-[#5146E5]/80"
@ -181,26 +226,33 @@ export function OutlineItem({
))}
</div>
)}
</div>
{/* Action Buttons */}
<div className="flex gap-1 sm:gap-2 items-center">
<ToolTip content="Delete Slide">
{/* Hover action buttons */}
{!isStreaming && !isEditing && (
<div className="flex-shrink-0 flex items-center gap-1 pr-2 pt-2.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => setShowRewrite(v => !v)}
title="AI Rewrite"
className="p-1.5 rounded-lg text-[#5146E5]/50 hover:text-[#5146E5] hover:bg-[#5146E5]/10 transition-colors"
>
<Sparkles className="w-3.5 h-3.5" />
</button>
<button
onClick={handleSlideDelete}
className="p-1.5 sm:p-2 bg-gray-200/50 hover:bg-gray-200 rounded-lg transition-colors"
title="Delete slide"
className="p-1.5 rounded-lg text-gray-400 hover:text-red-500 hover:bg-red-50 transition-colors"
>
<Trash2 className="w-4 h-4 sm:w-5 sm:h-5 text-black/70" />
<Trash2 className="w-3.5 h-3.5" />
</button>
</ToolTip>
</div>
</div>
)}
{/* Active streaming pulse overlay */}
{isActiveStreaming && (
<div className="absolute inset-0 rounded-xl pointer-events-none bg-[#5146E5]/[0.03] animate-pulse" />
)}
</div>
</div>
)
}

View file

@ -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<string>(TABS.OUTLINE);
// Restore selectedTemplate from Redux (persists across navigation)
// Custom templates are stored as string (templateId), built-ins as TemplateLayoutsWithSettings
const [selectedTemplate, setSelectedTemplate] = useState<TemplateLayoutsWithSettings | string | null>(() => {
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 <EmptyStateView />;
}
if (!presentation_id) return <EmptyStateView />;
const isStreaming = streamState.isStreaming || streamState.isLoading;
return (
<div className="h-[calc(100vh-72px)]">
<div className="h-[calc(100vh-72px)] bg-[#F8F7FF]">
<OverlayLoader
show={loadingState.isLoading}
text={loadingState.message}
@ -74,57 +62,83 @@ const OutlinePage: React.FC = () => {
duration={loadingState.duration}
/>
<Wrapper className="h-full flex flex-col w-full">
<div className="flex-grow overflow-y-hidden w-[1200px] mx-auto">
<Tabs value={activeTab} onValueChange={setActiveTab} className="h-full flex flex-col">
<TabsList className="grid w-[50%] mx-auto my-4 grid-cols-2">
<TabsTrigger value={TABS.OUTLINE}>Outline & Content</TabsTrigger>
<TabsTrigger value={TABS.LAYOUTS}>Select Template</TabsTrigger>
</TabsList>
<div className="h-full flex flex-col max-w-[1280px] mx-auto px-4">
{/* Two-column split */}
<div className="flex-1 flex gap-4 min-h-0 py-4">
<div className="flex-grow w-full mx-auto">
<TabsContent value={TABS.OUTLINE} className="h-[calc(100vh-16rem)] overflow-y-auto custom_scrollbar"
{/* LEFT — Slide Outline List */}
<div className="flex-[3] flex flex-col min-h-0 rounded-2xl bg-white border border-[rgba(81,70,229,0.10)] shadow-[0_1px_8px_rgba(81,70,229,0.06)] overflow-hidden">
{/* Sticky header */}
<div className="flex-shrink-0 flex items-center justify-between px-5 py-3.5 border-b border-[rgba(81,70,229,0.08)] bg-white">
<div className="flex items-center gap-2.5">
<h2 className="text-sm font-semibold text-gray-900">
Presentation Outline
</h2>
{outlines && outlines.length > 0 && (
<span className="inline-flex items-center justify-center px-2 py-0.5 rounded-full bg-[#5146E5]/10 text-[#5146E5] text-xs font-semibold">
{outlines.length} slides
</span>
)}
</div>
<button
onClick={handleAddSlide}
disabled={isStreaming}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-[#5146E5] text-white hover:bg-[#5146E5]/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<div>
<OutlineContent
outlines={outlines}
isLoading={streamState.isLoading}
isStreaming={streamState.isStreaming}
activeSlideIndex={streamState.activeSlideIndex}
highestActiveIndex={streamState.highestActiveIndex}
onDragEnd={handleDragEnd}
onAddSlide={handleAddSlide}
/>
</div>
</TabsContent>
<TabsContent value={TABS.LAYOUTS} className="h-[calc(100vh-16rem)] overflow-y-auto custom_scrollbar">
<div>
<TemplateSelection
selectedTemplate={selectedTemplate}
onSelectTemplate={handleSelectTemplate}
/>
</div>
</TabsContent>
<Plus className="w-3.5 h-3.5" />
Add Slide
</button>
</div>
</Tabs>
</div>
{/* Fixed Button */}
<div className="py-4 border-t border-gray-200">
<div className="max-w-[1200px] mx-auto">
<GenerateButton
outlineCount={outlines.length}
loadingState={loadingState}
streamState={streamState}
selectedTemplate={selectedTemplate}
onSubmit={handleSubmit}
/>
{/* Scrollable card list */}
<div className="flex-1 overflow-y-auto p-4 custom_scrollbar">
<OutlineContent
outlines={outlines}
isLoading={streamState.isLoading}
isStreaming={streamState.isStreaming}
activeSlideIndex={streamState.activeSlideIndex}
highestActiveIndex={streamState.highestActiveIndex}
onDragEnd={handleDragEnd}
onAddSlide={handleAddSlide}
/>
</div>
</div>
{/* RIGHT — Template Selection */}
<div className="flex-[2] flex flex-col min-h-0 rounded-2xl bg-white border border-[rgba(81,70,229,0.10)] shadow-[0_1px_8px_rgba(81,70,229,0.06)] overflow-hidden">
<div className="flex-shrink-0 flex items-center gap-2 px-5 py-3.5 border-b border-[rgba(81,70,229,0.08)]">
<Layers className="w-4 h-4 text-[#5146E5]" />
<h2 className="text-sm font-semibold text-gray-900">Select Template</h2>
{selectedTemplate && (
<span className="ml-auto text-xs text-[#5146E5] font-medium truncate max-w-[140px]">
{typeof selectedTemplate === "string"
? selectedTemplate
: selectedTemplate.name}
</span>
)}
</div>
<div className="flex-1 overflow-y-auto p-4 custom_scrollbar">
<TemplateSelection
selectedTemplate={selectedTemplate}
onSelectTemplate={handleSelectTemplate}
/>
</div>
</div>
</div>
</Wrapper>
{/* Bottom generate bar */}
<div className="flex-shrink-0 py-4 border-t border-[rgba(81,70,229,0.08)]">
<GenerateButton
outlineCount={outlines?.length ?? 0}
loadingState={loadingState}
streamState={streamState}
selectedTemplate={selectedTemplate}
onSubmit={handleSubmit}
/>
</div>
</div>
</div>
);
};
export default OutlinePage;
export default OutlinePage;