Phase 5: Frontend Wizard & Editor — 5-step generation wizard, review workflow

- New 5-step wizard flow at /generate/ (Upload → Configure → Outline → Generate → Edit)
- wizardSlice with localStorage persistence for cross-session state
- Upload page: drag & drop files, brief text input, file type badges
- Configure page: client/deck selectors, slide count slider, tone, language, instructions
- Outline review page: split-view with source content + outline editor, template selection
- Progress page: SSE streaming + polling fallback for real-time job progress
- Review workflow: status badge (Draft/In Review/Approved) with popover transitions
- Backend review endpoints: PUT status, POST comment, GET review info with audit logging
- Wizard API helpers: clients, master decks, file upload/decompose, job status/cancel

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-02-26 16:31:28 +00:00
parent a0d73b3b63
commit ad65f6fe2d
13 changed files with 1880 additions and 1 deletions

View file

@ -14,6 +14,7 @@ from api.v1.admin.audit_router import AUDIT_ROUTER
from api.v1.admin.brand_config_router import BRAND_CONFIG_ROUTER
from api.v1.admin.master_decks_router import MASTER_DECKS_ROUTER
from api.v1.ppt.endpoints.jobs import JOBS_ROUTER
from api.v1.ppt.endpoints.review import REVIEW_ROUTER
from api.middlewares.audit_middleware import AuditMiddleware
@ -33,6 +34,7 @@ app.include_router(AUTH_ROUTER)
app.include_router(ADMIN_ROUTER)
app.include_router(API_V1_PPT_ROUTER)
app.include_router(JOBS_ROUTER)
app.include_router(REVIEW_ROUTER)
app.include_router(API_V1_WEBHOOK_ROUTER)
app.include_router(API_V1_MOCK_ROUTER)

View file

@ -0,0 +1,131 @@
"""Presentation review workflow endpoints."""
import uuid
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from models.sql.presentation import PresentationModel
from models.sql.user import UserModel
from services import audit_service
from services.database import get_async_session
from utils.auth_dependencies import get_current_user
REVIEW_ROUTER = APIRouter(prefix="/api/v1/ppt", tags=["Review"])
VALID_STATUSES = {"draft", "in_review", "approved"}
VALID_TRANSITIONS = {
"draft": {"in_review"},
"in_review": {"draft", "approved"},
"approved": {"draft"},
}
class StatusUpdateRequest(BaseModel):
status: str
comment: Optional[str] = None
class CommentRequest(BaseModel):
comment: str
@REVIEW_ROUTER.put("/presentation/{presentation_id}/status")
async def update_status(
presentation_id: uuid.UUID,
body: StatusUpdateRequest,
current_user: UserModel = Depends(get_current_user),
session: AsyncSession = Depends(get_async_session),
):
"""Change presentation review status (draft -> in_review -> approved)."""
if body.status not in VALID_STATUSES:
raise HTTPException(
status_code=400,
detail=f"Invalid status. Must be one of: {', '.join(VALID_STATUSES)}",
)
presentation = await session.get(PresentationModel, presentation_id)
if not presentation:
raise HTTPException(status_code=404, detail="Presentation not found")
current = presentation.status or "draft"
if body.status not in VALID_TRANSITIONS.get(current, set()):
raise HTTPException(
status_code=400,
detail=f"Cannot transition from '{current}' to '{body.status}'",
)
old_status = current
presentation.status = body.status
if body.comment:
presentation.review_comment = body.comment
await session.commit()
await session.refresh(presentation)
# Audit log (fire-and-forget)
audit_service.log(
user_id=current_user.id,
action="status_change",
resource_type="presentation",
resource_id=presentation.id,
client_id=presentation.client_id,
details={
"old_status": old_status,
"new_status": body.status,
"comment": body.comment,
},
)
return {
"id": str(presentation.id),
"status": presentation.status,
"review_comment": presentation.review_comment,
}
@REVIEW_ROUTER.post("/presentation/{presentation_id}/comment")
async def add_comment(
presentation_id: uuid.UUID,
body: CommentRequest,
current_user: UserModel = Depends(get_current_user),
session: AsyncSession = Depends(get_async_session),
):
"""Add a review comment to a presentation."""
presentation = await session.get(PresentationModel, presentation_id)
if not presentation:
raise HTTPException(status_code=404, detail="Presentation not found")
presentation.review_comment = body.comment
await session.commit()
audit_service.log(
user_id=current_user.id,
action="comment",
resource_type="presentation",
resource_id=presentation.id,
client_id=presentation.client_id,
details={"comment": body.comment},
)
return {"ok": True}
@REVIEW_ROUTER.get("/presentation/{presentation_id}/review")
async def get_review_info(
presentation_id: uuid.UUID,
_current_user: UserModel = Depends(get_current_user),
session: AsyncSession = Depends(get_async_session),
):
"""Get review status and comment for a presentation."""
presentation = await session.get(PresentationModel, presentation_id)
if not presentation:
raise HTTPException(status_code=404, detail="Presentation not found")
return {
"id": str(presentation.id),
"status": presentation.status or "draft",
"review_comment": presentation.review_comment,
"title": presentation.title,
}

View file

@ -0,0 +1,203 @@
"use client";
import React, { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import {
FileEdit,
Eye,
CheckCircle2,
ChevronDown,
Send,
Loader2,
} from "lucide-react";
import { toast } from "sonner";
import { getHeader } from "../services/api/header";
import { ApiResponseHandler } from "../services/api/api-error-handler";
interface ReviewInfo {
id: string;
status: string;
review_comment: string | null;
title: string | null;
}
const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string; icon: React.ElementType }> = {
draft: { label: "Draft", color: "text-yellow-700", bg: "bg-yellow-50 border-yellow-200", icon: FileEdit },
in_review: { label: "In Review", color: "text-blue-700", bg: "bg-blue-50 border-blue-200", icon: Eye },
approved: { label: "Approved", color: "text-green-700", bg: "bg-green-50 border-green-200", icon: CheckCircle2 },
};
const TRANSITIONS: Record<string, string[]> = {
draft: ["in_review"],
in_review: ["approved", "draft"],
approved: ["draft"],
};
interface ReviewWorkflowProps {
presentationId: string;
}
export default function ReviewWorkflow({ presentationId }: ReviewWorkflowProps) {
const [info, setInfo] = useState<ReviewInfo | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isUpdating, setIsUpdating] = useState(false);
const [comment, setComment] = useState("");
const [popoverOpen, setPopoverOpen] = useState(false);
useEffect(() => {
fetchReviewInfo();
}, [presentationId]);
const fetchReviewInfo = async () => {
try {
const response = await fetch(`/api/v1/ppt/presentation/${presentationId}/review`, {
headers: getHeader(),
});
const data = await ApiResponseHandler.handleResponse(response, "Failed to fetch review info");
setInfo(data);
} catch {
// Silently fail — review info is supplementary
} finally {
setIsLoading(false);
}
};
const handleStatusChange = async (newStatus: string) => {
setIsUpdating(true);
try {
const response = await fetch(`/api/v1/ppt/presentation/${presentationId}/status`, {
method: "PUT",
headers: getHeader(),
body: JSON.stringify({ status: newStatus, comment: comment || null }),
});
const data = await ApiResponseHandler.handleResponse(response, "Failed to update status");
setInfo((prev) => prev ? { ...prev, status: data.status, review_comment: data.review_comment } : prev);
setComment("");
setPopoverOpen(false);
toast.success(`Status changed to ${STATUS_CONFIG[newStatus]?.label || newStatus}`);
} catch (error: any) {
toast.error(error.message || "Failed to update status");
} finally {
setIsUpdating(false);
}
};
const handleAddComment = async () => {
if (!comment.trim()) return;
setIsUpdating(true);
try {
const response = await fetch(`/api/v1/ppt/presentation/${presentationId}/comment`, {
method: "POST",
headers: getHeader(),
body: JSON.stringify({ comment }),
});
await ApiResponseHandler.handleResponse(response, "Failed to add comment");
setInfo((prev) => prev ? { ...prev, review_comment: comment } : prev);
setComment("");
toast.success("Comment added");
} catch (error: any) {
toast.error(error.message || "Failed to add comment");
} finally {
setIsUpdating(false);
}
};
if (isLoading || !info) return null;
const currentStatus = info.status || "draft";
const config = STATUS_CONFIG[currentStatus] || STATUS_CONFIG.draft;
const transitions = TRANSITIONS[currentStatus] || [];
const StatusIcon = config.icon;
return (
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<PopoverTrigger asChild>
<button
className={cn(
"inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full border text-sm font-medium transition-colors",
config.bg,
config.color
)}
>
<StatusIcon className="w-3.5 h-3.5" />
{config.label}
<ChevronDown className="w-3 h-3" />
</button>
</PopoverTrigger>
<PopoverContent className="w-72" align="end">
<div className="space-y-3">
<h4 className="text-sm font-semibold">Review Status</h4>
{/* Current status */}
<div className={cn("flex items-center gap-2 px-3 py-2 rounded-lg border", config.bg)}>
<StatusIcon className={cn("w-4 h-4", config.color)} />
<span className={cn("text-sm font-medium", config.color)}>
{config.label}
</span>
</div>
{/* Review comment */}
{info.review_comment && (
<div className="text-xs text-gray-500 bg-gray-50 rounded p-2">
<span className="font-medium">Last comment:</span> {info.review_comment}
</div>
)}
{/* Transition buttons */}
{transitions.length > 0 && (
<div className="space-y-1.5">
<p className="text-xs text-gray-500">Change status to:</p>
{transitions.map((status) => {
const targetConfig = STATUS_CONFIG[status];
const TargetIcon = targetConfig.icon;
return (
<Button
key={status}
variant="outline"
size="sm"
className="w-full justify-start gap-2"
disabled={isUpdating}
onClick={() => handleStatusChange(status)}
>
{isUpdating ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<TargetIcon className={cn("w-3.5 h-3.5", targetConfig.color)} />
)}
{targetConfig.label}
</Button>
);
})}
</div>
)}
{/* Comment input */}
<div className="space-y-2 pt-1 border-t">
<Textarea
placeholder="Add a review comment..."
value={comment}
onChange={(e) => setComment(e.target.value)}
className="min-h-[60px] text-sm resize-none"
/>
<Button
size="sm"
className="w-full"
disabled={!comment.trim() || isUpdating}
onClick={handleAddComment}
>
<Send className="w-3.5 h-3.5 mr-1" />
Add Comment
</Button>
</div>
</div>
</PopoverContent>
</Popover>
);
}

View file

@ -22,7 +22,7 @@ export const PresentationGrid = ({
const router = useRouter();
const handleCreateNewPresentation = () => {
if (type === "slide") {
router.push("/upload");
router.push("/generate/upload");
} else {
router.push("/editor");
}

View file

@ -0,0 +1,302 @@
"use client";
import React, { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "@/store/store";
import {
setSelectedClient,
setSelectedDeck,
setSlideCount,
setInstructions,
setTone,
setLanguage,
setWizardStep,
setJobId,
setPresentationId as setWizardPresentationId,
} from "@/store/slices/wizardSlice";
import {
setPresentationId,
clearOutlines,
} from "@/store/slices/presentationGeneration";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { ChevronLeft, ChevronRight, Layers } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { OverlayLoader } from "@/components/ui/overlay-loader";
import Wrapper from "@/components/Wrapper";
import { WizardApi, ClientOption, MasterDeckOption } from "../../services/api/wizard";
const TONE_OPTIONS = [
{ value: "professional", label: "Professional" },
{ value: "casual", label: "Casual" },
{ value: "educational", label: "Educational" },
{ value: "sales_pitch", label: "Sales Pitch" },
{ value: "default", label: "Neutral" },
];
const LANGUAGE_OPTIONS = [
"English",
"Spanish (Espanol)",
"French (Francais)",
"German (Deutsch)",
"Portuguese (Portugues)",
"Italian (Italiano)",
"Russian (Русский)",
"Chinese (Simplified)",
"Japanese (日本語)",
"Korean (한국어)",
"Arabic (العربية)",
"Hindi (हिन्दी)",
];
export default function WizardConfigurePage() {
const router = useRouter();
const dispatch = useDispatch();
const wizard = useSelector((s: RootState) => s.wizard);
const [clients, setClients] = useState<ClientOption[]>([]);
const [masterDecks, setMasterDecks] = useState<MasterDeckOption[]>([]);
const [loadingClients, setLoadingClients] = useState(true);
const [loadingDecks, setLoadingDecks] = useState(false);
const [isGenerating, setIsGenerating] = useState(false);
// Fetch clients on mount
useEffect(() => {
WizardApi.getClients()
.then(setClients)
.finally(() => setLoadingClients(false));
}, []);
// Fetch master decks when client changes
useEffect(() => {
if (!wizard.selectedClientId) {
setMasterDecks([]);
return;
}
setLoadingDecks(true);
WizardApi.getMasterDecks(wizard.selectedClientId)
.then(setMasterDecks)
.finally(() => setLoadingDecks(false));
}, [wizard.selectedClientId]);
const handleBack = () => {
dispatch(setWizardStep(1));
router.push("/generate/upload");
};
const handleGenerateOutline = async () => {
if (!wizard.briefText.trim() && wizard.uploadedFiles.length === 0) {
toast.error("No content provided. Please go back and add a brief or files.");
return;
}
try {
setIsGenerating(true);
// Gather file server paths
const filePaths = wizard.uploadedFiles
.map((f) => f.serverPath)
.filter(Boolean) as string[];
// Create presentation (outline mode)
const result = await WizardApi.createPresentation({
content: wizard.briefText,
n_slides: wizard.slideCount,
file_paths: filePaths,
language: wizard.language,
tone: wizard.tone,
instructions: wizard.instructions,
client_id: wizard.selectedClientId ?? undefined,
master_deck_id: wizard.selectedDeckId ?? undefined,
});
dispatch(setPresentationId(result.id));
dispatch(setWizardPresentationId(result.id));
dispatch(clearOutlines());
dispatch(setWizardStep(3));
router.push("/generate/outline");
} catch (error: any) {
toast.error("Failed to generate outline", {
description: error.message || "Please try again.",
});
} finally {
setIsGenerating(false);
}
};
return (
<Wrapper className="py-8 max-w-3xl">
<OverlayLoader
show={isGenerating}
text="Generating outline..."
showProgress
duration={30}
/>
<h1 className="text-2xl font-semibold mb-1">Configure Presentation</h1>
<p className="text-sm text-gray-500 mb-8">
Set up your presentation preferences before generating the outline.
</p>
<div className="space-y-6">
{/* Client Selector */}
<div>
<Label className="mb-2 block">Client</Label>
<Select
value={wizard.selectedClientId ?? ""}
onValueChange={(v) => dispatch(setSelectedClient(v || null))}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={loadingClients ? "Loading..." : "Select a client"} />
</SelectTrigger>
<SelectContent>
{clients.map((c) => (
<SelectItem key={c.id} value={c.id}>
{c.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Master Deck Selector */}
<div>
<Label className="mb-2 block">Master Deck (Template)</Label>
{!wizard.selectedClientId ? (
<p className="text-sm text-gray-400">Select a client first to see available decks.</p>
) : loadingDecks ? (
<p className="text-sm text-gray-400">Loading decks...</p>
) : masterDecks.length === 0 ? (
<p className="text-sm text-gray-400">No master decks available for this client.</p>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{masterDecks.map((deck) => (
<button
key={deck.id}
onClick={() => dispatch(setSelectedDeck(deck.id))}
className={cn(
"flex flex-col items-center gap-2 p-4 rounded-xl border-2 transition-all text-left",
wizard.selectedDeckId === deck.id
? "border-[#5146E5] bg-[#5146E5]/5"
: "border-gray-200 hover:border-gray-300"
)}
>
<div className="w-full aspect-video bg-gray-100 rounded-lg flex items-center justify-center overflow-hidden">
{deck.thumbnail_url ? (
<img src={deck.thumbnail_url} alt={deck.name} className="w-full h-full object-cover" />
) : (
<Layers className="w-8 h-8 text-gray-300" />
)}
</div>
<span className="text-sm font-medium truncate w-full">{deck.name}</span>
<span className="text-xs text-gray-400">{deck.layout_count} layouts</span>
</button>
))}
</div>
)}
</div>
{/* Slide Count */}
<div>
<Label className="mb-2 block">
Number of Slides: <span className="font-semibold text-[#5146E5]">{wizard.slideCount}</span>
</Label>
<Slider
value={[wizard.slideCount]}
onValueChange={([v]) => dispatch(setSlideCount(v))}
min={5}
max={40}
step={1}
className="w-full"
/>
<div className="flex justify-between text-xs text-gray-400 mt-1">
<span>5</span>
<span>40</span>
</div>
</div>
{/* Language */}
<div>
<Label className="mb-2 block">Language</Label>
<Select
value={wizard.language}
onValueChange={(v) => dispatch(setLanguage(v))}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select language" />
</SelectTrigger>
<SelectContent>
{LANGUAGE_OPTIONS.map((lang) => (
<SelectItem key={lang} value={lang}>
{lang}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Tone */}
<div>
<Label className="mb-2 block">Tone</Label>
<Select
value={wizard.tone}
onValueChange={(v) => dispatch(setTone(v))}
>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{TONE_OPTIONS.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Instructions */}
<div>
<Label className="mb-2 block">Additional Instructions</Label>
<Textarea
placeholder='e.g. "Focus on Q4 results", "Skip market comparison", "Emphasize growth metrics"'
value={wizard.instructions}
onChange={(e) => dispatch(setInstructions(e.target.value))}
className="min-h-[100px] resize-y"
/>
</div>
</div>
{/* Navigation */}
<div className="mt-8 flex justify-between">
<Button
variant="outline"
onClick={handleBack}
className="rounded-full px-6 py-6"
>
<ChevronLeft className="w-4 h-4 mr-1" />
Back
</Button>
<Button
onClick={handleGenerateOutline}
disabled={isGenerating}
className="px-8 py-6 bg-[#5146E5] hover:bg-[#5146E5]/90 text-white font-semibold text-base rounded-full"
>
Generate Outline
<ChevronRight className="w-5 h-5 ml-1" />
</Button>
</div>
</Wrapper>
);
}

View file

@ -0,0 +1,100 @@
"use client";
import React from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Check, Upload, Settings, ListChecks, Loader2, PenTool } from "lucide-react";
import { cn } from "@/lib/utils";
import Wrapper from "@/components/Wrapper";
import HeaderNav from "@/app/(presentation-generator)/components/HeaderNab";
const WIZARD_STEPS = [
{ step: 1, label: "Upload", path: "/generate/upload", icon: Upload },
{ step: 2, label: "Configure", path: "/generate/configure", icon: Settings },
{ step: 3, label: "Outline", path: "/generate/outline", icon: ListChecks },
{ step: 4, label: "Generate", path: "/generate/progress", icon: Loader2 },
{ step: 5, label: "Edit", path: "/presentation", icon: PenTool },
] as const;
function getActiveStep(pathname: string): number {
for (const s of WIZARD_STEPS) {
if (pathname.startsWith(s.path)) return s.step;
}
return 1;
}
export default function GenerateLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const activeStep = getActiveStep(pathname);
return (
<div className="min-h-screen flex flex-col bg-gray-50">
{/* Header */}
<div className="bg-[#5146E5] w-full shadow-lg sticky top-0 z-50">
<Wrapper>
<div className="flex items-center justify-between py-1">
<Link href="/dashboard">
<img src="/logo-white.png" alt="DeckForge" className="h-16" />
</Link>
<HeaderNav />
</div>
</Wrapper>
</div>
{/* Step Indicator */}
<div className="bg-white border-b">
<Wrapper className="py-4">
<div className="flex items-center justify-center max-w-2xl mx-auto">
{WIZARD_STEPS.map((s, idx) => {
const isActive = activeStep === s.step;
const isCompleted = activeStep > s.step;
const Icon = s.icon;
return (
<React.Fragment key={s.step}>
{idx > 0 && (
<div
className={cn(
"flex-1 h-0.5 mx-2",
isCompleted ? "bg-[#5146E5]" : "bg-gray-200"
)}
/>
)}
<div className="flex flex-col items-center gap-1">
<div
className={cn(
"w-9 h-9 rounded-full flex items-center justify-center text-sm font-medium transition-colors",
isCompleted && "bg-[#5146E5] text-white",
isActive && "bg-[#5146E5] text-white ring-4 ring-[#5146E5]/20",
!isActive && !isCompleted && "bg-gray-100 text-gray-400"
)}
>
{isCompleted ? (
<Check className="w-4 h-4" />
) : (
<Icon className="w-4 h-4" />
)}
</div>
<span
className={cn(
"text-xs font-medium",
isActive ? "text-[#5146E5]" : isCompleted ? "text-gray-700" : "text-gray-400"
)}
>
{s.label}
</span>
</div>
</React.Fragment>
);
})}
</div>
</Wrapper>
</div>
{/* Page Content */}
<div className="flex-1">
{children}
</div>
</div>
);
}

View file

@ -0,0 +1,310 @@
"use client";
import React, { useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "@/store/store";
import {
setWizardStep,
setJobId,
setPresentationId as setWizardPresentationId,
WizardOutlineItem,
} from "@/store/slices/wizardSlice";
import { clearPresentationData } from "@/store/slices/presentationGeneration";
import { useOutlineStreaming } from "../../outline/hooks/useOutlineStreaming";
import { useOutlineManagement } from "../../outline/hooks/useOutlineManagement";
import OutlineContent from "../../outline/components/OutlineContent";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
ChevronLeft,
ChevronRight,
FileText,
Layers,
Loader2,
} from "lucide-react";
import { toast } from "sonner";
import { OverlayLoader } from "@/components/ui/overlay-loader";
import Wrapper from "@/components/Wrapper";
import { PresentationGenerationApi } from "../../services/api/presentation-generation";
import { WizardApi } from "../../services/api/wizard";
import TemplateSelection from "../../outline/components/TemplateSelection";
import { TemplateLayoutsWithSettings } from "@/app/presentation-templates/utils";
import { getCustomTemplateDetails } from "@/app/hooks/useCustomTemplates";
export default function WizardOutlinePage() {
const router = useRouter();
const dispatch = useDispatch();
const wizard = useSelector((s: RootState) => s.wizard);
const { presentation_id, outlines } = useSelector(
(s: RootState) => s.presentationGeneration
);
const [selectedTemplate, setSelectedTemplate] = useState<
TemplateLayoutsWithSettings | string | null
>(null);
const [activeTab, setActiveTab] = useState("outline");
const [isGenerating, setIsGenerating] = useState(false);
// Reuse existing hooks
const streamState = useOutlineStreaming(presentation_id);
const { handleDragEnd, handleAddSlide } = useOutlineManagement(outlines);
const handleBack = () => {
dispatch(setWizardStep(2));
router.push("/generate/configure");
};
const handleApproveAndGenerate = async () => {
if (!outlines || outlines.length === 0) {
toast.error("No outlines to generate from.");
return;
}
if (!selectedTemplate) {
setActiveTab("template");
toast.error("Please select a template/layout first.");
return;
}
try {
setIsGenerating(true);
// Build layout object (same pattern as usePresentationGeneration)
let layout;
if (typeof selectedTemplate === "string") {
const customDetail = await getCustomTemplateDetails(selectedTemplate);
if (!customDetail || customDetail.layouts.length === 0) {
toast.error("Failed to load custom template layouts");
return;
}
layout = {
name: customDetail.id,
ordered: false,
slides: customDetail.layouts.map((l) => ({
id: customDetail.id.startsWith("custom-")
? `${customDetail.id}:${l.layoutId}`
: `custom-${customDetail.id}:${l.layoutId}`,
name: l.layoutName,
description: l.layoutDescription,
templateID: customDetail.id,
templateName: customDetail.name,
json_schema: l.schemaJSON,
})),
};
} else {
layout = {
name: selectedTemplate.id,
ordered: false,
slides: selectedTemplate.layouts.map((l) => ({
id: l.layoutId,
name: l.layoutName,
description: l.layoutDescription,
templateID: selectedTemplate.id,
templateName: selectedTemplate.name,
json_schema: l.schemaJSON,
})),
};
}
// Prepare (same as existing flow)
const response = await PresentationGenerationApi.presentationPrepare({
presentation_id,
outlines,
layout,
});
if (response) {
dispatch(clearPresentationData());
dispatch(setWizardPresentationId(presentation_id));
dispatch(setWizardStep(4));
router.push(
`/presentation?id=${presentation_id}&stream=true&type=standard`
);
}
} catch (error: any) {
toast.error("Generation failed", {
description: error.message || "Please try again.",
});
} finally {
setIsGenerating(false);
}
};
if (!presentation_id) {
return (
<Wrapper 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>
);
}
return (
<div className="h-[calc(100vh-140px)] flex flex-col">
<OverlayLoader
show={isGenerating}
text="Preparing presentation..."
showProgress
duration={30}
/>
<Wrapper className="flex-1 flex flex-col overflow-hidden py-4">
{/* Split View */}
<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>
</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) => (
<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]" />
<span className="truncate flex-1">{f.name}</span>
</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>
)}
</div>
</div>
{/* RIGHT: Outline + Template Tabs */}
<div className="flex-1 flex flex-col min-w-0">
<Tabs
value={activeTab}
onValueChange={setActiveTab}
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" />
Template
</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}
/>
</TabsContent>
<TabsContent value="template" className="h-full">
<TemplateSelection
selectedTemplate={selectedTemplate}
onSelectTemplate={setSelectedTemplate}
/>
</TabsContent>
</div>
</Tabs>
</div>
</div>
{/* Bottom Bar */}
<div className="pt-4 border-t mt-4 flex justify-between items-center">
<Button
variant="outline"
onClick={handleBack}
className="rounded-full px-6 py-6"
>
<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...
</span>
)}
<Button
onClick={handleApproveAndGenerate}
disabled={
isGenerating ||
streamState.isStreaming ||
!outlines ||
outlines.length === 0
}
className="px-8 py-6 bg-[#5146E5] hover:bg-[#5146E5]/90 text-white font-semibold text-base rounded-full"
>
{isGenerating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Generating...
</>
) : (
<>
Approve & Generate
<ChevronRight className="w-5 h-5 ml-1" />
</>
)}
</Button>
</div>
</div>
</Wrapper>
</div>
);
}

View file

@ -0,0 +1,238 @@
"use client";
import React, { useEffect, useRef, useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "@/store/store";
import {
setWizardStep,
setJobId,
setPresentationId as setWizardPresentationId,
} from "@/store/slices/wizardSlice";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import {
Loader2,
CheckCircle2,
XCircle,
RotateCcw,
ChevronLeft,
StopCircle,
} from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import Wrapper from "@/components/Wrapper";
import { WizardApi, JobStatus } from "../../services/api/wizard";
export default function WizardProgressPage() {
const router = useRouter();
const dispatch = useDispatch();
const { jobId, presentationId } = useSelector((s: RootState) => s.wizard);
const [progress, setProgress] = useState(0);
const [message, setMessage] = useState("Initializing...");
const [status, setStatus] = useState<string>("pending");
const [error, setError] = useState<string | null>(null);
const eventSourceRef = useRef<EventSource | null>(null);
const pollingRef = useRef<NodeJS.Timeout | null>(null);
// Auto-navigate when completed
useEffect(() => {
if (status === "completed" && presentationId) {
const timer = setTimeout(() => {
dispatch(setWizardStep(5));
router.push(`/presentation?id=${presentationId}`);
}, 1500);
return () => clearTimeout(timer);
}
}, [status, presentationId, dispatch, router]);
// Connect to SSE stream or fall back to polling
useEffect(() => {
if (!jobId) return;
const connectSSE = () => {
const es = new EventSource(`/api/v1/ppt/jobs/${jobId}/stream`);
eventSourceRef.current = es;
es.addEventListener("response", (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === "progress") {
setProgress(data.progress ?? 0);
setMessage(data.message ?? "Processing...");
setStatus(data.status ?? "processing");
if (data.status === "completed") {
if (data.presentation_id) {
dispatch(setWizardPresentationId(data.presentation_id));
}
es.close();
}
if (data.status === "failed") {
setError(data.message || "Generation failed");
es.close();
}
}
if (data.type === "error") {
setError(data.detail || "Unknown error");
es.close();
}
} catch {
// ignore parse errors
}
});
es.onerror = () => {
es.close();
// Fall back to polling
startPolling();
};
};
const startPolling = () => {
if (pollingRef.current) return;
pollingRef.current = setInterval(async () => {
try {
const job = await WizardApi.getJobStatus(jobId);
setProgress(job.progress);
setMessage(job.progress_message || "Processing...");
setStatus(job.status);
if (job.status === "completed") {
if (job.presentation_id) {
dispatch(setWizardPresentationId(job.presentation_id));
}
clearInterval(pollingRef.current!);
pollingRef.current = null;
}
if (job.status === "failed") {
setError(job.error_message || "Generation failed");
clearInterval(pollingRef.current!);
pollingRef.current = null;
}
} catch {
// continue polling
}
}, 3000);
};
connectSSE();
return () => {
eventSourceRef.current?.close();
if (pollingRef.current) {
clearInterval(pollingRef.current);
pollingRef.current = null;
}
};
}, [jobId, dispatch]);
const handleCancel = async () => {
if (!jobId) return;
try {
await WizardApi.cancelJob(jobId);
dispatch(setJobId(null));
dispatch(setWizardStep(3));
router.push("/generate/outline");
} catch {
toast.error("Failed to cancel job");
}
};
const handleRetry = () => {
dispatch(setJobId(null));
dispatch(setWizardStep(3));
router.push("/generate/outline");
};
const isTerminal = status === "completed" || status === "failed";
if (!jobId) {
return (
<Wrapper className="py-16 text-center">
<p className="text-gray-500 mb-4">No generation in progress.</p>
<Button variant="outline" onClick={() => router.push("/generate/outline")}>
<ChevronLeft className="w-4 h-4 mr-1" />
Back to Outline
</Button>
</Wrapper>
);
}
return (
<Wrapper className="py-16 max-w-2xl">
<div className="text-center">
{/* Icon */}
<div className="mb-6">
{status === "completed" ? (
<CheckCircle2 className="w-16 h-16 text-green-500 mx-auto" />
) : status === "failed" ? (
<XCircle className="w-16 h-16 text-red-500 mx-auto" />
) : (
<Loader2 className="w-16 h-16 text-[#5146E5] mx-auto animate-spin" />
)}
</div>
{/* Title */}
<h1 className="text-2xl font-semibold mb-2">
{status === "completed"
? "Presentation Ready!"
: status === "failed"
? "Generation Failed"
: "Generating Presentation"}
</h1>
{/* Message */}
<p
className={cn(
"text-sm mb-8",
status === "failed" ? "text-red-600" : "text-gray-500"
)}
>
{error || message}
</p>
{/* Progress Bar */}
<div className="mb-2">
<Progress value={progress} className="h-3" />
</div>
<p className="text-sm text-gray-400 mb-8">{progress}%</p>
{/* Actions */}
<div className="flex justify-center gap-3">
{!isTerminal && (
<Button
variant="outline"
onClick={handleCancel}
className="rounded-full px-6"
>
<StopCircle className="w-4 h-4 mr-1" />
Cancel
</Button>
)}
{status === "failed" && (
<Button
onClick={handleRetry}
className="rounded-full px-6 bg-[#5146E5] hover:bg-[#5146E5]/90 text-white"
>
<RotateCcw className="w-4 h-4 mr-1" />
Retry
</Button>
)}
{status === "completed" && presentationId && (
<Button
onClick={() => {
dispatch(setWizardStep(5));
router.push(`/presentation?id=${presentationId}`);
}}
className="rounded-full px-8 bg-[#5146E5] hover:bg-[#5146E5]/90 text-white"
>
Open Presentation
</Button>
)}
</div>
</div>
</Wrapper>
);
}

View file

@ -0,0 +1,273 @@
"use client";
import React, { useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "@/store/store";
import {
setUploadedFiles,
setBriefText,
setDecomposedFiles,
setWizardStep,
WizardUploadedFile,
} from "@/store/slices/wizardSlice";
import { Upload, X, FileText, ChevronRight, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { OverlayLoader } from "@/components/ui/overlay-loader";
import Wrapper from "@/components/Wrapper";
import { WizardApi } from "../../services/api/wizard";
const ACCEPTED_TYPES = [
"application/pdf",
"text/plain",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"text/csv",
"image/png",
"image/jpeg",
"image/webp",
];
const ACCEPT_STRING = ".pdf,.txt,.pptx,.docx,.xlsx,.csv,.png,.jpg,.jpeg,.webp";
function formatFileSize(bytes: number): string {
if (!bytes || bytes === 0) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
}
function getTypeBadge(type: string): string {
if (type.includes("pdf")) return "PDF";
if (type.includes("word")) return "DOCX";
if (type.includes("presentation")) return "PPTX";
if (type.includes("spreadsheet") || type.includes("csv")) return "DATA";
if (type.includes("image")) return "IMG";
if (type.includes("text")) return "TXT";
return "FILE";
}
export default function WizardUploadPage() {
const router = useRouter();
const dispatch = useDispatch();
const { uploadedFiles, briefText } = useSelector((s: RootState) => s.wizard);
const [localFiles, setLocalFiles] = useState<File[]>([]);
const [isDragging, setIsDragging] = useState(false);
const [isProcessing, setIsProcessing] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const droppedFiles = Array.from(e.dataTransfer.files);
addFiles(droppedFiles);
};
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const selected = Array.from(e.target.files || []);
addFiles(selected);
// Reset input so same file can be re-selected
if (fileInputRef.current) fileInputRef.current.value = "";
};
const addFiles = (newFiles: File[]) => {
const invalid = newFiles.filter((f) => !ACCEPTED_TYPES.includes(f.type));
if (invalid.length > 0) {
toast.error("Unsupported file type", {
description: "Please upload PDF, DOCX, PPTX, Excel, CSV, or image files.",
});
return;
}
setLocalFiles((prev) => [...prev, ...newFiles]);
const newMeta: WizardUploadedFile[] = newFiles.map((f) => ({
name: f.name,
size: f.size,
type: f.type,
}));
dispatch(setUploadedFiles([...uploadedFiles, ...newMeta]));
toast.success(`${newFiles.length} file(s) added`);
};
const removeFile = (index: number) => {
setLocalFiles((prev) => prev.filter((_, i) => i !== index));
dispatch(setUploadedFiles(uploadedFiles.filter((_, i) => i !== index)));
};
const allFiles = uploadedFiles; // display list from Redux
const handleNext = async () => {
if (!briefText.trim() && allFiles.length === 0) {
toast.error("Please enter a brief or upload documents");
return;
}
try {
setIsProcessing(true);
// Upload files if there are local files to upload
if (localFiles.length > 0) {
const serverPaths = await WizardApi.uploadFiles(localFiles);
// Decompose documents
const decomposed = await WizardApi.decomposeFiles(serverPaths);
dispatch(setDecomposedFiles(decomposed));
// Update server paths on uploaded files
const updated = uploadedFiles.map((f, i) => ({
...f,
serverPath: serverPaths[i] ?? f.serverPath,
}));
dispatch(setUploadedFiles(updated));
setLocalFiles([]); // Clear local references
}
dispatch(setWizardStep(2));
router.push("/generate/configure");
} catch (error: any) {
toast.error("Upload failed", {
description: error.message || "Please try again.",
});
} finally {
setIsProcessing(false);
}
};
return (
<Wrapper className="py-8 max-w-4xl">
<OverlayLoader
show={isProcessing}
text="Processing documents..."
showProgress
duration={90}
extra_info={allFiles.length > 3 ? "Large documents may take a minute." : ""}
/>
<h1 className="text-2xl font-semibold mb-1">Upload Your Brief</h1>
<p className="text-sm text-gray-500 mb-6">
Upload your presentation brief (DOCX) and any supporting files, or type your content below.
</p>
{/* Drag & Drop Zone */}
<div
onClick={() => fileInputRef.current?.click()}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
"border-2 border-dashed rounded-xl cursor-pointer transition-all",
"flex flex-col items-center justify-center py-12 px-6",
isDragging
? "border-[#5146E5] bg-[#5146E5]/5"
: "border-gray-300 bg-white hover:border-gray-400"
)}
>
<Upload
className={cn(
"w-10 h-10 mb-3",
isDragging ? "text-[#5146E5]" : "text-gray-400"
)}
/>
<p className="text-gray-600 font-medium mb-1">
{isDragging ? "Drop files here" : "Drag & drop files here"}
</p>
<p className="text-gray-400 text-sm mb-4">
DOCX, PDF, PPTX, Excel, CSV, Images
</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
fileInputRef.current?.click();
}}
>
<Plus className="w-4 h-4 mr-1" /> Browse Files
</Button>
<input
ref={fileInputRef}
type="file"
accept={ACCEPT_STRING}
multiple
onChange={handleFileInput}
className="hidden"
/>
</div>
{/* File List */}
{allFiles.length > 0 && (
<div className="mt-4 space-y-2">
<h3 className="text-sm font-medium text-gray-600">
Files ({allFiles.length})
</h3>
{allFiles.map((f, idx) => (
<div
key={`${f.name}-${idx}`}
className="flex items-center gap-3 bg-white rounded-lg border px-4 py-3"
>
<FileText className="w-5 h-5 text-[#5146E5] flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{f.name}</p>
<p className="text-xs text-gray-400">{formatFileSize(f.size)}</p>
</div>
<span className="text-[10px] font-semibold uppercase px-2 py-0.5 rounded bg-gray-100 text-gray-500">
{getTypeBadge(f.type)}
</span>
<button
onClick={() => removeFile(idx)}
className="p-1 text-gray-400 hover:text-red-500 transition-colors"
>
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
{/* Brief Text */}
<div className="mt-6">
<label className="block text-sm font-medium text-gray-700 mb-2">
Or describe your presentation
</label>
<Textarea
placeholder="Enter your presentation topic, key points, or paste content here..."
value={briefText}
onChange={(e) => dispatch(setBriefText(e.target.value))}
className="min-h-[120px] resize-y"
/>
</div>
{/* Next Button */}
<div className="mt-8 flex justify-end">
<Button
onClick={handleNext}
disabled={isProcessing}
className="px-8 py-6 bg-[#5146E5] hover:bg-[#5146E5]/90 text-white font-semibold text-base rounded-full"
>
Next: Configure
<ChevronRight className="w-5 h-5 ml-1" />
</Button>
</div>
</Wrapper>
);
}

View file

@ -29,6 +29,7 @@ import { toast } from "sonner";
import Announcement from "@/components/Announcement";
import { PptxPresentationModel } from "@/types/pptx_models";
import HeaderNav from "../../components/HeaderNab";
import ReviewWorkflow from "../../components/ReviewWorkflow";
import PDFIMAGE from "@/public/pdf.svg";
import PPTXIMAGE from "@/public/pptx.svg";
import Image from "next/image";
@ -202,6 +203,9 @@ const Header = ({
</div>
{/* Review Status */}
<ReviewWorkflow presentationId={presentation_id} />
{/* Present Button */}
<Button
onClick={() => {

View file

@ -0,0 +1,143 @@
import { getHeader, getHeaderForFormData } from "./header";
import { ApiResponseHandler } from "./api-error-handler";
export interface ClientOption {
id: string;
name: string;
}
export interface MasterDeckOption {
id: string;
name: string;
thumbnail_url?: string;
layout_count: number;
}
export interface JobStatus {
id: string;
job_type: string;
status: string;
progress: number;
progress_message: string | null;
error_message: string | null;
presentation_id: string | null;
created_at: string | null;
started_at: string | null;
completed_at: string | null;
}
export class WizardApi {
/** Fetch clients available to the current user */
static async getClients(): Promise<ClientOption[]> {
try {
const response = await fetch("/api/v1/admin/clients", {
method: "GET",
headers: getHeader(),
});
const data = await ApiResponseHandler.handleResponse(response, "Failed to fetch clients");
return data.items ?? data;
} catch (error) {
console.error("Error fetching clients:", error);
return [];
}
}
/** Fetch master decks, optionally filtered by client */
static async getMasterDecks(clientId?: string): Promise<MasterDeckOption[]> {
try {
const params = clientId ? `?client_id=${clientId}` : "";
const response = await fetch(`/api/v1/admin/master-decks${params}`, {
method: "GET",
headers: getHeader(),
});
const data = await ApiResponseHandler.handleResponse(response, "Failed to fetch master decks");
return data.items ?? data;
} catch (error) {
console.error("Error fetching master decks:", error);
return [];
}
}
/** Upload files for the wizard */
static async uploadFiles(files: File[]): Promise<string[]> {
const formData = new FormData();
files.forEach((file) => formData.append("files", file));
const response = await fetch("/api/v1/ppt/files/upload", {
method: "POST",
headers: getHeaderForFormData(),
body: formData,
cache: "no-cache",
});
return await ApiResponseHandler.handleResponse(response, "Failed to upload files");
}
/** Decompose uploaded documents */
static async decomposeFiles(filePaths: string[]): Promise<any[]> {
const response = await fetch("/api/v1/ppt/files/decompose", {
method: "POST",
headers: getHeader(),
body: JSON.stringify({ file_paths: filePaths }),
cache: "no-cache",
});
return await ApiResponseHandler.handleResponse(response, "Failed to decompose files");
}
/** Create presentation and start generation via ARQ job queue */
static async createPresentationAsync(params: {
content: string;
n_slides: number;
file_paths: string[];
language: string;
tone: string;
instructions: string;
client_id?: string;
master_deck_id?: string;
}) {
const response = await fetch("/api/v1/ppt/presentation/generate/async", {
method: "POST",
headers: getHeader(),
body: JSON.stringify(params),
cache: "no-cache",
});
return await ApiResponseHandler.handleResponse(response, "Failed to start generation");
}
/** Poll job status */
static async getJobStatus(jobId: string): Promise<JobStatus> {
const response = await fetch(`/api/v1/ppt/jobs/${jobId}`, {
method: "GET",
headers: getHeader(),
});
return await ApiResponseHandler.handleResponse(response, "Failed to fetch job status");
}
/** Cancel a job */
static async cancelJob(jobId: string): Promise<void> {
const response = await fetch(`/api/v1/ppt/jobs/${jobId}`, {
method: "DELETE",
headers: getHeader(),
});
await ApiResponseHandler.handleResponse(response, "Failed to cancel job");
}
/** Create presentation (outline-only, like existing flow) */
static async createPresentation(params: {
content: string;
n_slides: number;
file_paths: string[];
language: string;
tone: string;
instructions: string;
client_id?: string;
master_deck_id?: string;
}) {
const response = await fetch("/api/v1/ppt/presentation/create", {
method: "POST",
headers: getHeader(),
body: JSON.stringify(params),
cache: "no-cache",
});
return await ApiResponseHandler.handleResponse(response, "Failed to create presentation");
}
}

View file

@ -0,0 +1,171 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
export interface WizardUploadedFile {
name: string;
size: number;
type: string;
/** Server path after upload */
serverPath?: string;
}
export interface WizardOutlineItem {
id: string;
title: string;
description: string;
layoutType?: string;
/** Attachment IDs mapped to this slide */
attachmentIds?: string[];
}
export type WizardStep = 1 | 2 | 3 | 4 | 5;
interface WizardState {
currentStep: WizardStep;
/** Step 1: Upload */
uploadedFiles: WizardUploadedFile[];
briefText: string;
/** Step 2: Configure */
selectedClientId: string | null;
selectedDeckId: string | null;
slideCount: number;
instructions: string;
tone: string;
language: string;
/** Step 3: Outline */
outlines: WizardOutlineItem[];
/** Step 4: Generation */
jobId: string | null;
/** Step 5: Editor */
presentationId: string | null;
/** Decomposed document data from server */
decomposedFiles: any[];
}
const STORAGE_KEY = "deckforge_wizard";
function loadFromStorage(): Partial<WizardState> | null {
if (typeof window === "undefined") return null;
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw);
} catch {
// ignore
}
return null;
}
function saveToStorage(state: WizardState) {
if (typeof window === "undefined") return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch {
// ignore
}
}
const defaultState: WizardState = {
currentStep: 1,
uploadedFiles: [],
briefText: "",
selectedClientId: null,
selectedDeckId: null,
slideCount: 12,
instructions: "",
tone: "professional",
language: "English",
outlines: [],
jobId: null,
presentationId: null,
decomposedFiles: [],
};
const persisted = loadFromStorage();
const initialState: WizardState = persisted
? { ...defaultState, ...persisted }
: defaultState;
const wizardSlice = createSlice({
name: "wizard",
initialState,
reducers: {
setWizardStep: (state, action: PayloadAction<WizardStep>) => {
state.currentStep = action.payload;
saveToStorage(state);
},
setUploadedFiles: (state, action: PayloadAction<WizardUploadedFile[]>) => {
state.uploadedFiles = action.payload;
saveToStorage(state);
},
setBriefText: (state, action: PayloadAction<string>) => {
state.briefText = action.payload;
saveToStorage(state);
},
setSelectedClient: (state, action: PayloadAction<string | null>) => {
state.selectedClientId = action.payload;
// Reset deck when client changes
state.selectedDeckId = null;
saveToStorage(state);
},
setSelectedDeck: (state, action: PayloadAction<string | null>) => {
state.selectedDeckId = action.payload;
saveToStorage(state);
},
setSlideCount: (state, action: PayloadAction<number>) => {
state.slideCount = action.payload;
saveToStorage(state);
},
setInstructions: (state, action: PayloadAction<string>) => {
state.instructions = action.payload;
saveToStorage(state);
},
setTone: (state, action: PayloadAction<string>) => {
state.tone = action.payload;
saveToStorage(state);
},
setLanguage: (state, action: PayloadAction<string>) => {
state.language = action.payload;
saveToStorage(state);
},
setOutlines: (state, action: PayloadAction<WizardOutlineItem[]>) => {
state.outlines = action.payload;
saveToStorage(state);
},
setJobId: (state, action: PayloadAction<string | null>) => {
state.jobId = action.payload;
saveToStorage(state);
},
setPresentationId: (state, action: PayloadAction<string | null>) => {
state.presentationId = action.payload;
saveToStorage(state);
},
setDecomposedFiles: (state, action: PayloadAction<any[]>) => {
state.decomposedFiles = action.payload;
saveToStorage(state);
},
resetWizard: (state) => {
Object.assign(state, defaultState);
if (typeof window !== "undefined") {
localStorage.removeItem(STORAGE_KEY);
}
},
},
});
export const {
setWizardStep,
setUploadedFiles,
setBriefText,
setSelectedClient,
setSelectedDeck,
setSlideCount,
setInstructions,
setTone,
setLanguage,
setOutlines,
setJobId,
setPresentationId,
setDecomposedFiles,
resetWizard,
} = wizardSlice.actions;
export default wizardSlice.reducer;

View file

@ -6,6 +6,7 @@ import userConfigReducer from "./slices/userConfig";
import undoRedoReducer from "./slices/undoRedoSlice";
import authReducer from "./slices/authSlice";
import adminReducer from "./slices/adminSlice";
import wizardReducer from "./slices/wizardSlice";
export const store = configureStore({
reducer: {
@ -15,6 +16,7 @@ export const store = configureStore({
undoRedo: undoRedoReducer,
auth: authReducer,
admin: adminReducer,
wizard: wizardReducer,
},
});