presenton/servers/nextjs/app/(presentation-generator)/presentation/components/PresentationHeader.tsx

499 lines
17 KiB
TypeScript

"use client";
import { Button } from "@/components/ui/button";
import {
Play,
Loader2,
Redo2,
Undo2,
RotateCcw,
ArrowRightFromLine,
ArrowUpRight,
Pencil,
Check,
X,
} from "lucide-react";
import React, { useEffect, useRef, useState } from "react";
import { useRouter, usePathname } from "next/navigation";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { PresentationGenerationApi } from "../../services/api/presentation-generation";
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "@/store/store";
import { toast } from "sonner";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
import { usePresentationUndoRedo } from "../hooks/PresentationUndoRedo";
import ToolTip from "@/components/ToolTip";
import {
clearPresentationData,
updateTitle,
} from "@/store/slices/presentationGeneration";
import { clearHistory } from "@/store/slices/undoRedoSlice";
import { Separator } from "@/components/ui/separator";
import ThemeSelector from "./ThemeSelector";
import { DEFAULT_THEMES } from "../../(dashboard)/theme/components/ThemePanel/constants";
import ThemeApi from "../../services/api/theme";
import { Theme } from "../../services/api/types";
import MarkdownRenderer from "@/components/MarkDownRender";
import { cn } from "@/lib/utils";
const MAX_EXPORT_TITLE_LENGTH = 40;
const buildSafeExportFileName = (
rawTitle: string | null | undefined,
extension: "pdf" | "pptx"
) => {
const normalizedTitle = (rawTitle || "presentation").trim();
const titleWithoutExtension = normalizedTitle.replace(
/\.(pdf|pptx)$/i,
""
);
let safeBase = titleWithoutExtension
// Replace all punctuation/special chars (including dots) with dashes
.replace(/[^a-zA-Z0-9\s_-]+/g, "-")
// Replace whitespace with single dashes
.replace(/\s+/g, "-")
// Collapse repeated separators
.replace(/[-_]{2,}/g, "-")
// Trim separators from both ends
.replace(/^[-_]+|[-_]+$/g, "");
if (!safeBase) {
safeBase = "presentation";
}
if (safeBase.length > MAX_EXPORT_TITLE_LENGTH) {
safeBase = safeBase.slice(0, MAX_EXPORT_TITLE_LENGTH).replace(/[-_]+$/g, "");
}
if (!safeBase) {
safeBase = "presentation";
}
return `${safeBase}.${extension}`;
};
const PresentationHeader = ({
presentation_id,
isPresentationSaving,
currentSlide,
}: {
presentation_id: string;
isPresentationSaving: boolean;
currentSlide?: number;
}) => {
const [open, setOpen] = useState(false);
const router = useRouter();
const [isExporting, setIsExporting] = useState(false);
const [themes, setThemes] = useState<Theme[]>([]);
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [draftTitle, setDraftTitle] = useState("");
const titleInputRef = useRef<HTMLInputElement>(null);
/** Avoid committing on blur when Save/Cancel was used (focus/click ordering) */
const titleBlurIntentRef = useRef<"none" | "save" | "cancel">("none");
const pathname = usePathname();
const dispatch = useDispatch();
const { presentationData, isStreaming } = useSelector(
(state: RootState) => state.presentationGeneration
);
useEffect(() => {
const load = async () => {
try {
const [customThemes] = await Promise.all([
ThemeApi.getThemes(),
]);
setThemes([...customThemes, ...DEFAULT_THEMES]);
} catch (e: any) {
toast.error(e?.message || "Failed to load themes");
}
};
if (themes.length === 0) {
load();
}
}, []);
const { onUndo, onRedo, canUndo, canRedo } = usePresentationUndoRedo();
useEffect(() => {
if (isEditingTitle) {
titleInputRef.current?.focus();
titleInputRef.current?.select();
}
}, [isEditingTitle]);
const beginTitleEdit = () => {
if (isStreaming || !presentationData) return;
setDraftTitle(presentationData.title || "");
setIsEditingTitle(true);
};
const commitTitleEdit = () => {
if (!presentationData) {
setIsEditingTitle(false);
return;
}
const trimmed = draftTitle.trim();
const next =
trimmed || presentationData.title || "Presentation";
if (next !== presentationData.title) {
dispatch(updateTitle(next));
trackEvent(MixpanelEvent.Presentation_Title_Updated, {
pathname,
presentation_id,
previous_title_length: (presentationData.title || "").length,
next_title_length: next.length,
});
}
setIsEditingTitle(false);
};
const cancelTitleEdit = () => {
setDraftTitle(presentationData?.title || "");
setIsEditingTitle(false);
};
const handleTitleBlur = () => {
queueMicrotask(() => {
const intent = titleBlurIntentRef.current;
titleBlurIntentRef.current = "none";
if (intent === "cancel" || intent === "save") return;
commitTitleEdit();
});
};
const onTitleSaveMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
titleBlurIntentRef.current = "save";
};
const onTitleCancelMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
titleBlurIntentRef.current = "cancel";
};
const handleExportPptx = async () => {
if (isStreaming) return;
try {
trackEvent(MixpanelEvent.Presentation_Export_Started, {
pathname,
presentation_id,
format: "pptx",
slide_count: presentationData?.slides?.length || 0,
});
toast.info("Exporting PPTX...");
setIsExporting(true);
// Save the presentation data before exporting
await PresentationGenerationApi.updatePresentationContent(presentationData);
const safePptxFileName = buildSafeExportFileName(
presentationData?.title,
"pptx"
);
const safePptxTitle = safePptxFileName.replace(/\.pptx$/i, "");
const response = await fetch("/api/export-presentation", {
method: "POST",
body: JSON.stringify({
format: "pptx",
id: presentation_id,
title: safePptxTitle,
}),
});
if (!response.ok) {
throw new Error("Failed to export PPTX");
}
const { path: pptxPath } = await response.json();
if (!pptxPath) {
throw new Error("No path returned from export");
}
downloadLink(pptxPath, safePptxFileName);
} catch (error) {
console.error("Export failed:", error);
toast.error("Having trouble exporting!", {
description:
"We are having trouble exporting your presentation. Please try again.",
});
} finally {
setIsExporting(false);
}
};
const handleExportPdf = async () => {
if (isStreaming) return;
try {
trackEvent(MixpanelEvent.Presentation_Export_Started, {
pathname,
presentation_id,
format: "pdf",
slide_count: presentationData?.slides?.length || 0,
});
toast.info("Exporting PDF...");
setIsExporting(true);
// Save the presentation data before exporting
await PresentationGenerationApi.updatePresentationContent(presentationData);
const safePdfFileName = buildSafeExportFileName(
presentationData?.title,
"pdf"
);
const safePdfTitle = safePdfFileName.replace(/\.pdf$/i, "");
const response = await fetch("/api/export-presentation", {
method: "POST",
body: JSON.stringify({
format: "pdf",
id: presentation_id,
title: safePdfTitle,
}),
});
if (response.ok) {
const { path: pdfPath } = await response.json();
downloadLink(pdfPath, safePdfFileName);
} else {
throw new Error("Failed to export PDF");
}
} catch (err) {
console.error(err);
toast.error("Having trouble exporting!", {
description:
"We are having trouble exporting your presentation. Please try again.",
});
} finally {
setIsExporting(false);
}
};
const handleReGenerate = () => {
dispatch(clearPresentationData());
dispatch(clearHistory())
trackEvent(MixpanelEvent.Presentation_Regenerated, {
pathname,
presentation_id,
slide_count: presentationData?.slides?.length || 0,
});
router.push(`/presentation?id=${presentation_id}&stream=true`);
};
const downloadLink = (path: string, fileName: string) => {
const link = document.createElement("a");
link.href = path;
link.download = fileName;
link.rel = "noopener";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const ExportOptions = ({ mobile }: { mobile: boolean }) => (
<div className={` rounded-[18px] max-md:mt-4 ${mobile ? "" : "bg-white"} p-5`}>
<p className="text-sm font-medium text-[#19001F]">Export as</p>
<div className="my-[18px] h-[1px] bg-[#E8E8E8]" />
<div className="space-y-3">
<Button
onClick={() => {
handleExportPdf();
setOpen(false);
}}
variant="ghost"
className={` rounded-none px-0 w-full text-xs flex justify-start text-black hover:bg-transparent ${mobile ? "bg-white py-6 border-none rounded-lg" : ""}`} >
PDF
<ArrowUpRight className="w-3.5 h-3.5" />
</Button>
<Button
onClick={() => {
handleExportPptx();
setOpen(false);
}}
variant="ghost"
className={`w-full flex px-0 justify-start text-xs text-black hover:bg-transparent ${mobile ? "bg-white py-6" : ""}`}
>
PPTX
<ArrowUpRight className="w-3.5 h-3.5" />
</Button>
</div>
</div>
);
const titleBlock = (
<div
className={cn(
"min-w-0 max-w-[min(640px,calc(100vw-12rem))] flex-1 transition-[box-shadow] duration-200",
isEditingTitle && "relative z-[60]"
)}
>
{isEditingTitle ? (
<div className="flex items-stretch w-[450px] gap-0.5 rounded-[14px] border border-[#E4E2EB] bg-white pl-3.5 pr-1 py-1 shadow-[0_2px_12px_rgba(17,3,31,0.06)] ring-2 ring-[#5141e5]/15">
<input
ref={titleInputRef}
value={draftTitle}
onChange={(e) => setDraftTitle(e.target.value)}
onBlur={handleTitleBlur}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
titleBlurIntentRef.current = "save";
commitTitleEdit();
} else if (e.key === "Escape") {
e.preventDefault();
titleBlurIntentRef.current = "cancel";
cancelTitleEdit();
}
}}
placeholder="Presentation title"
className="min-w-0 flex-1 bg-transparent py-2 pr-2 font-unbounded text-base leading-tight text-[#101323] placeholder:text-[#101323]/35 outline-none border-0 focus:ring-0"
aria-label="Presentation title"
/>
<div className="flex shrink-0 items-center gap-0.5 border-l border-[#EDECEC] pl-1 ml-0.5">
<ToolTip content="Save · Enter">
<button
type="button"
onMouseDown={onTitleSaveMouseDown}
onClick={commitTitleEdit}
className="flex h-8 w-8 items-center justify-center rounded-lg text-[#5141e5] hover:bg-[#5141e5]/10 transition-colors"
aria-label="Save title"
>
<Check className="h-4 w-4" strokeWidth={2.25} />
</button>
</ToolTip>
<ToolTip content="Cancel · Esc">
<button
type="button"
onMouseDown={onTitleCancelMouseDown}
onClick={cancelTitleEdit}
className="flex h-8 w-8 items-center justify-center rounded-lg text-[#101323]/55 hover:bg-[#F6F6F9] hover:text-[#101323] transition-colors"
aria-label="Cancel editing title"
>
<X className="h-4 w-4" strokeWidth={2.25} />
</button>
</ToolTip>
</div>
</div>
) : (
<button
type="button"
onClick={beginTitleEdit}
disabled={isStreaming || !presentationData}
className={cn(
"group/title flex w-full min-w-0 items-center gap-2.5 rounded-[14px] px-3 py-2 text-left -mx-3 transition-colors",
"hover:bg-[#F6F6F9] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#5141e5] focus-visible:ring-offset-2",
"disabled:pointer-events-none disabled:opacity-100 disabled:hover:bg-transparent"
)}
>
<h2 className="min-w-0 flex-1 font-unbounded text-lg w-[450px] leading-snug text-[#101323]">
<MarkdownRenderer
content={presentationData?.title || "Presentation"}
className="mb-0 min-w-0 overflow-hidden text-ellipsis line-clamp-1 text-sm text-[#101323] prose-p:my-0 prose-headings:my-0"
/>
</h2>
{presentationData && !isStreaming && (
<Pencil
className="h-3.5 w-3.5 shrink-0 text-[#101323]/40 transition-all duration-200 group-hover/title:text-[#5141e5] opacity-80 sm:opacity-0 sm:group-hover/title:opacity-100 group-hover/title:opacity-100"
aria-hidden
/>
)}
</button>
)}
</div>
);
return (
<>
<div className="py-7 sticky top-0 bg-white z-50 mb-[17px] font-syne flex justify-between items-center gap-4">
{presentationData && !isStreaming && !isEditingTitle ? (
<ToolTip content="Rename presentation">{titleBlock}</ToolTip>
) : (
titleBlock
)}
<div className="flex items-center gap-2.5">
{isPresentationSaving && <div className="flex items-center gap-2">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
</div>}
{presentationData && presentationData.slides && !presentationData.slides[0].layout.includes("custom") && <ThemeSelector current_theme={presentationData?.theme || {}} themes={themes} />}
<div className="flex items-center gap-2 bg-[#F6F6F9] px-3.5 h-[38px] border border-[#EDECEC] rounded-[80px]">
<ToolTip content="Regenerate Presentation">
<button onClick={handleReGenerate} className="group">
<RotateCcw className="w-3.5 h-3.5 text-[#101323] group-hover:text-[#5141e5] duration-300" />
</button>
</ToolTip>
<Separator orientation="vertical" className="h-4" />
<ToolTip content="Undo">
<button disabled={!canUndo} className=" disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer group" onClick={() => {
onUndo();
}}>
<Undo2 className="w-3.5 h-3.5 text-[#101323] group-hover:text-[#5141e5] duration-300" />
</button>
</ToolTip>
<Separator orientation="vertical" className="h-4" />
<ToolTip content="Redo">
<button disabled={!canRedo} className=" disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer group" onClick={() => {
onRedo();
}}>
<Redo2 className="w-3.5 h-3.5 text-[#101323] group-hover:text-[#5141e5] duration-300" />
</button>
</ToolTip>
<Separator orientation="vertical" className="h-4 w-[2px]" />
<ToolTip content="Present">
<button
onClick={() => {
const to = `?id=${presentation_id}&mode=present&slide=${currentSlide || 0}`;
trackEvent(MixpanelEvent.Presentation_Mode_Entered, {
pathname,
presentation_id,
slide_index: currentSlide || 0,
slide_count: presentationData?.slides?.length || 0,
});
trackEvent(MixpanelEvent.Navigation, { from: pathname, to });
router.push(to);
}}
disabled={isStreaming || !presentationData?.slides || presentationData?.slides.length === 0} className="cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed group">
<Play className="w-3.5 h-3.5 text-[#101323] group-hover:text-[#5141e5] duration-300" />
</button>
</ToolTip>
</div>
<Popover open={open} onOpenChange={setOpen} >
<PopoverTrigger asChild>
<button className="flex items-center gap-[7px] px-[18px] py-[11px] rounded-[53px] text-sm font-semibold text-[#101323]"
style={{
background: "linear-gradient(270deg, #D5CAFC 2.4%, #E3D2EB 27.88%, #F4DCD3 69.23%, #FDE4C2 100%)",
}}
disabled={isExporting || isStreaming === true}
>
{isExporting ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : "Export"} <ArrowRightFromLine className="w-3.5 h-3.5" />
</button>
</PopoverTrigger>
<PopoverContent align="end" className="w-[200px] rounded-[18px] space-y-2 p-0 ">
<ExportOptions mobile={false} />
</PopoverContent>
</Popover>
</div>
</div>
</>
);
};
export default PresentationHeader;