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:
parent
a0d73b3b63
commit
ad65f6fe2d
13 changed files with 1880 additions and 1 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
131
backend/api/v1/ppt/endpoints/review.py
Normal file
131
backend/api/v1/ppt/endpoints/review.py
Normal 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,
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
100
frontend/app/(presentation-generator)/generate/layout.tsx
Normal file
100
frontend/app/(presentation-generator)/generate/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
310
frontend/app/(presentation-generator)/generate/outline/page.tsx
Normal file
310
frontend/app/(presentation-generator)/generate/outline/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
238
frontend/app/(presentation-generator)/generate/progress/page.tsx
Normal file
238
frontend/app/(presentation-generator)/generate/progress/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
273
frontend/app/(presentation-generator)/generate/upload/page.tsx
Normal file
273
frontend/app/(presentation-generator)/generate/upload/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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={() => {
|
||||
|
|
|
|||
143
frontend/app/(presentation-generator)/services/api/wizard.ts
Normal file
143
frontend/app/(presentation-generator)/services/api/wizard.ts
Normal 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");
|
||||
}
|
||||
}
|
||||
171
frontend/store/slices/wizardSlice.ts
Normal file
171
frontend/store/slices/wizardSlice.ts
Normal 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;
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue