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:
parent
d10759b4de
commit
455e6e0c00
3 changed files with 476 additions and 376 deletions
|
|
@ -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 & Generate
|
||||
<ChevronRight className="w-4 h-4 ml-1.5" />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Wrapper>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue