refactor: Improve Presentation page, Present Mode & theme
This commit is contained in:
parent
af5ce9e33b
commit
5018c9b9f1
20 changed files with 682 additions and 734 deletions
|
|
@ -1,10 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { LayoutDashboard, Star, Brain, Settings, Palette } from "lucide-react";
|
||||
import { LayoutDashboard, Star, Brain, Settings, Palette, HelpCircle } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "@/store/store";
|
||||
import { IMAGE_PROVIDERS, LLM_PROVIDERS } from "@/utils/providerConstants";
|
||||
|
||||
|
||||
|
||||
|
|
@ -27,21 +31,24 @@ const DashboardSidebar = () => {
|
|||
const activeTab = pathname.split("?")[0].split("/").pop();
|
||||
const router = useRouter();
|
||||
|
||||
const { llm_config } = useSelector((state: RootState) => state.userConfig)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="sticky top-0 h-screen w-[115px] flex flex-col justify-between bg-[#F6F6F9] backdrop-blur border-r border-slate-200/60 px-4 py-8"
|
||||
className="sticky top-0 h-screen w-[115px] flex flex-col justify-between bg-[#F6F6F9] backdrop-blur border-r border-[#E1E1E5] px-4 py-8"
|
||||
aria-label="Dashboard sidebar"
|
||||
>
|
||||
<div>
|
||||
|
||||
<div onClick={() => router.push("/dashboard")} className="flex items-center pb-6 border-b border-slate-200/60 gap-2 ">
|
||||
<Link href={`/dashboard`} className="flex items-center pb-6 border-b border-[#E1E1E5] gap-2 ">
|
||||
<div className="bg-[#7C51F8] rounded-full cursor-pointer p-1 flex justify-center items-center mx-auto">
|
||||
<img src="/logo-with-bg.png" alt="Presenton logo" className="h-[40px] object-contain w-full" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
<nav className="pt-6 font-syne" aria-label="Dashboard sections">
|
||||
<div className=" space-y-6">
|
||||
|
||||
|
|
@ -70,7 +77,7 @@ const DashboardSidebar = () => {
|
|||
title="Templates"
|
||||
>
|
||||
<div className="flex flex-col cursor-pointer tex-center items-center gap-2 transition-colors">
|
||||
<Star className={`h-4 w-4 ${pathname === "/templates" ? "text-[#5146E5]" : "text-slate-600"}`} />
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke={`${pathname === "/templates" ? "#5146E5" : "#475569"}`} strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4"><path d="M4 14h6" /><path d="M4 2h10" /><rect x="4" y="18" width="16" height="4" rx="1" /><rect x="4" y="6" width="16" height="4" rx="1" /></svg>
|
||||
<span className="text-[11px] text-slate-800">Templates</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
|
@ -93,8 +100,18 @@ const DashboardSidebar = () => {
|
|||
</nav>
|
||||
</div>
|
||||
|
||||
<div className=" pt-5 border-t border-slate-200/60 font-syne "
|
||||
<div className=" pt-5 border-t border-[#E1E1E5] font-syne "
|
||||
>
|
||||
<div className="mb-4">
|
||||
|
||||
<Link href="https://docs.presenton.ai/help" target="_blank" className="flex flex-col tex-center items-center gap-2 transition-colors"><HelpCircle className="w-4 h-4" /><span className="text-[11px] text-slate-800">Help</span></Link>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
|
||||
<Link href="https://discord.com/invite/9ZsKKxudNE" target="_blank" className="flex flex-col tex-center items-center gap-2 transition-colors"><img src="/discord.png" alt="Discord" className="w-5 h-5 rounded-full object-cover border border-[#EDEEEF]" /><span className="text-[11px] text-slate-800">Community</span></Link>
|
||||
</div>
|
||||
|
||||
|
||||
{BelongingNavItems.map(({ key, label: itemLabel, icon: Icon }) => {
|
||||
const isActive = activeTab === key;
|
||||
return (
|
||||
|
|
@ -109,7 +126,11 @@ const DashboardSidebar = () => {
|
|||
aria-label={itemLabel}
|
||||
title={itemLabel}
|
||||
>
|
||||
<Icon className={["h-4 w-4", isActive ? "text-[#5146E5]" : "text-slate-600"].join(" ")} />
|
||||
{/* <div className="flex items-center ">
|
||||
<img src={imageProviderIcon} alt="image provider" className="w-5 h-5 rounded-full object-cover border border-[#EDEEEF]" />
|
||||
<img src={textProviderIcon} alt="text provider" className="w-5 h-5 rounded-full object-cover border border-[#EDEEEF]" />
|
||||
</div> */}
|
||||
<Settings className={`h-4 w-4 ${isActive ? "text-[#5146E5]" : "text-slate-600"}`} />
|
||||
<span className="text-[11px] text-slate-800">{itemLabel}</span>
|
||||
</Link>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { DashboardApi } from "@/app/(presentation-generator)/services/api/dashbo
|
|||
import { PresentationGrid } from "@/app/(presentation-generator)/(dashboard)/dashboard/components/PresentationGrid";
|
||||
import Link from "next/link";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
|
||||
|
||||
|
||||
|
||||
|
|
@ -15,6 +16,7 @@ const DashboardPage: React.FC = () => {
|
|||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
trackEvent(MixpanelEvent.Dashboard_Page_Viewed);
|
||||
const loadData = async () => {
|
||||
await fetchPresentations();
|
||||
};
|
||||
|
|
@ -58,7 +60,8 @@ const DashboardPage: React.FC = () => {
|
|||
|
||||
|
||||
<Link
|
||||
href="/generate"
|
||||
href="/upload"
|
||||
onClick={() => trackEvent(MixpanelEvent.Dashboard_New_Presentation_Clicked)}
|
||||
className="inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-black text-sm font-semibold font-syne shadow-sm hover:shadow-md"
|
||||
aria-label="Create new presentation"
|
||||
style={{
|
||||
|
|
@ -71,21 +74,7 @@ const DashboardPage: React.FC = () => {
|
|||
<span className="md:hidden">New</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Link>
|
||||
{/* {
|
||||
<Link
|
||||
href="/theme?tab=new-theme"
|
||||
className="inline-flex items-center font-inter font-normal gap-2 rounded-xl px-4 py-2.5 text-black text-sm shadow-sm hover:shadow-md"
|
||||
aria-label="Create new themes"
|
||||
style={{
|
||||
borderRadius: "48px",
|
||||
background: "linear-gradient(270deg, #D5CAFC 2.4%, #E3D2EB 27.88%, #F4DCD3 69.23%, #FDE4C2 100%)",
|
||||
}}
|
||||
>
|
||||
<span className="hidden md:inline">New Themes</span>
|
||||
<span className="md:hidden">New</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Link>
|
||||
} */}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -5,14 +5,44 @@ import React from "react";
|
|||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
|
||||
const PATHS_WITH_HEADER_BACK = [
|
||||
"/upload",
|
||||
"/outline",
|
||||
"/documents-preview",
|
||||
"/template-preview",
|
||||
] as const;
|
||||
|
||||
function pathMatches(pathname: string | null, base: string) {
|
||||
return pathname === base || pathname?.startsWith(`${base}/`) === true;
|
||||
}
|
||||
|
||||
const Header = () => {
|
||||
const pathname = usePathname();
|
||||
const showHeaderBack = PATHS_WITH_HEADER_BACK.some((p) => pathMatches(pathname, p));
|
||||
|
||||
const backToUpload =
|
||||
pathMatches(pathname, "/outline") || pathMatches(pathname, "/documents-preview");
|
||||
const backToTemplates = pathMatches(pathname, "/template-preview");
|
||||
|
||||
const backHref = backToUpload ? "/upload" : backToTemplates ? "/templates" : "/dashboard";
|
||||
const backLabel = backToUpload
|
||||
? "BACK"
|
||||
: backToTemplates
|
||||
? "BACK"
|
||||
: "BACK";
|
||||
|
||||
return (
|
||||
<div className="w-full sticky top-0 z-50 py-7 ">
|
||||
<div className="w-full sticky top-0 z-50 py-7 "
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #FFF 0%, rgba(255, 255, 255, 0.00) 110.67%)",
|
||||
|
||||
}}
|
||||
>
|
||||
<Wrapper className="px-5 sm:px-10 lg:px-20">
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* {(pathname !== "/upload" && pathname !== "/dashboard") && <BackBtn />} */}
|
||||
<Link href="/dashboard" onClick={() => trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/dashboard" })}>
|
||||
<img
|
||||
src="/logo-with-bg.png"
|
||||
|
|
@ -21,6 +51,20 @@ const Header = () => {
|
|||
/>
|
||||
</Link>
|
||||
</div>
|
||||
{showHeaderBack ? (
|
||||
<div>
|
||||
<Link
|
||||
href={backHref}
|
||||
className="text-[#333333] text-xs font-syne font-semibold flex items-center gap-2"
|
||||
onClick={() =>
|
||||
trackEvent(MixpanelEvent.Navigation, { from: pathname, to: backHref })
|
||||
}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 shrink-0 text-[#333333]" aria-hidden />
|
||||
<span>{backLabel}</span>
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
</div>
|
||||
</Wrapper>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
'use client'
|
||||
import React from "react";
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { DashboardApi } from "@/app/(presentation-generator)/services/api/dashboard";
|
||||
|
|
@ -15,6 +15,7 @@ import { toast } from "sonner";
|
|||
import { useFontLoader } from "@/app/(presentation-generator)/hooks/useFontLoad";
|
||||
import SlideScale from "@/app/(presentation-generator)/components/PresentationRender";
|
||||
import MarkdownRenderer from "@/components/MarkDownRender";
|
||||
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
|
||||
|
||||
export const PresentationCard = ({
|
||||
id,
|
||||
|
|
@ -33,6 +34,47 @@ export const PresentationCard = ({
|
|||
e.preventDefault();
|
||||
router.push(`/presentation?id=${id}&type=standard`);
|
||||
};
|
||||
useEffect(() => {
|
||||
applyTheme(presentation.theme)
|
||||
}, [])
|
||||
const applyTheme = async (theme: any) => {
|
||||
const element = document.getElementById(`dashboard-presentation-card-${id}`)
|
||||
if (!element) return;
|
||||
|
||||
if (!theme || !theme.data || !theme.data.colors['graph_0']) { return; }
|
||||
const cssVariables = {
|
||||
'--primary-color': theme.data.colors['primary'],
|
||||
'--background-color': theme.data.colors['background'],
|
||||
'--card-color': theme.data.colors['card'],
|
||||
'--stroke': theme.data.colors['stroke'],
|
||||
'--primary-text': theme.data.colors['primary_text'],
|
||||
'--background-text': theme.data.colors['background_text'],
|
||||
'--graph-0': theme.data.colors['graph_0'],
|
||||
'--graph-1': theme.data.colors['graph_1'],
|
||||
'--graph-2': theme.data.colors['graph_2'],
|
||||
'--graph-3': theme.data.colors['graph_3'],
|
||||
'--graph-4': theme.data.colors['graph_4'],
|
||||
'--graph-5': theme.data.colors['graph_5'],
|
||||
'--graph-6': theme.data.colors['graph_6'],
|
||||
'--graph-7': theme.data.colors['graph_7'],
|
||||
'--graph-8': theme.data.colors['graph_8'],
|
||||
'--graph-9': theme.data.colors['graph_9'],
|
||||
}
|
||||
Object.entries(cssVariables).forEach(([key, value]) => {
|
||||
element.style.setProperty(key, value)
|
||||
})
|
||||
//
|
||||
if (theme.data.fonts.textFont.url && theme.data.fonts.textFont.name) {
|
||||
useFontLoader({ [theme.data.fonts.textFont.name]: theme.data.fonts.textFont.url })
|
||||
}
|
||||
|
||||
// Apply fonts to preview container
|
||||
element.style.setProperty('font-family', `"${theme.data.fonts.textFont.name}"`)
|
||||
element.style.setProperty('--heading-font-family', `"${theme.data.fonts.textFont.name}"`)
|
||||
element.style.setProperty('--body-font-family', `"${theme.data.fonts.textFont.name}"`)
|
||||
|
||||
|
||||
}
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
|
@ -59,7 +101,9 @@ export const PresentationCard = ({
|
|||
onClick={handlePreview}
|
||||
className="bg-[#F8FBFB] font-syne shadow-none sm:shadow-none presentation-card rounded-[12px] p-0 group hover:shadow-md transition-all duration-500 slide-theme cursor-pointer overflow-hidden flex flex-col"
|
||||
>
|
||||
<div suppressHydrationWarning={true} className="flex flex-col flex-1 relative z-40">
|
||||
<div
|
||||
id={`dashboard-presentation-card-${id}`}
|
||||
suppressHydrationWarning={true} className="flex flex-col flex-1 relative z-40">
|
||||
{/* <p className=" text-xs font-syne absolute top-2 flex gap-1 capitalize items-center left-2 rounded-[100px] px-2.5 py-1 bg-[#3A3A3AF5] text-white font-semibold z-40 ">
|
||||
|
||||
{presentation.type}
|
||||
|
|
@ -68,14 +112,14 @@ export const PresentationCard = ({
|
|||
<img src="/card_bg.svg" alt="" className="absolute top-0 left-0 w-full h-full object-cover" />
|
||||
<div className="scale-[0.75] mt-4 border border-gray-300 rounded-lg overflow-hidden">
|
||||
|
||||
<SlideScale slide={firstSlide} />
|
||||
<SlideScale slide={firstSlide} isClickable={false} />
|
||||
</div>
|
||||
|
||||
<div className="w-full py-3 px-5 mt-auto z-40 relative bg-white border-t border-[#EDEEEF]">
|
||||
<div className="flex items-center justify-between gap-7 w-full">
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<div className="text-sm text-[#191919] font-semibold overflow-hidden line-clamp-1">
|
||||
<MarkdownRenderer content={title} className="text-sm mb-0 text-[#191919] font-semibold overflow-hidden line-clamp-1" />
|
||||
<MarkdownRenderer content={title} className="text-sm mb-0 font-syne text-[#191919] font-semibold overflow-hidden line-clamp-1" />
|
||||
</div>
|
||||
<p className="text-[#808080] text-sm font-syne">
|
||||
{new Date(presentation?.created_at).toLocaleDateString()}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { PlusIcon } from "@radix-ui/react-icons";
|
|||
import { useRouter } from "next/navigation";
|
||||
import { PresentationResponse } from "@/app/(presentation-generator)/services/api/dashboard";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
|
||||
|
||||
interface PresentationGridProps {
|
||||
presentations: PresentationResponse[];
|
||||
|
|
@ -22,6 +23,7 @@ export const PresentationGrid = ({
|
|||
}: PresentationGridProps) => {
|
||||
const router = useRouter();
|
||||
const handleCreateNewPresentation = () => {
|
||||
trackEvent(MixpanelEvent.Dashboard_Create_New_Card_Clicked, { type });
|
||||
if (type === "slide") {
|
||||
router.push("/upload");
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -6,40 +6,55 @@ import { V1ContentRender } from '../../(presentation-generator)/components/V1Con
|
|||
const BASE_WIDTH = 1280;
|
||||
const BASE_HEIGHT = 720;
|
||||
|
||||
const SlideScale = ({ slide }: { slide: any }) => {
|
||||
const SlideScale = ({
|
||||
slide,
|
||||
theme,
|
||||
isEditMode = true,
|
||||
/** Fill viewport; scale may exceed 1 so slides appear larger in present mode */
|
||||
presentMode = false,
|
||||
isClickable = true,
|
||||
}: { slide: any; theme?: any; isEditMode?: boolean; presentMode?: boolean; isClickable?: boolean }) => {
|
||||
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [containerWidth, setContainerWidth] = useState<number>(0);
|
||||
const [box, setBox] = useState({ w: 0, h: 0 });
|
||||
|
||||
const scale = useMemo(() => {
|
||||
// Slight padding to avoid overflow due to borders/scrollbars
|
||||
const safeWidth = Math.max(0, containerWidth + 20);
|
||||
if (presentMode) {
|
||||
const { w, h } = box;
|
||||
if (w < 1 || h < 1) return 1;
|
||||
const sx = (w / BASE_WIDTH) * 0.995;
|
||||
const sy = (h / BASE_HEIGHT) * 0.995;
|
||||
return Math.min(sx, sy);
|
||||
}
|
||||
const safeWidth = Math.max(0, box.w + 20);
|
||||
if (!safeWidth) return 1;
|
||||
return Math.min((safeWidth / BASE_WIDTH) * 0.98, 1);
|
||||
}, [containerWidth]);
|
||||
}, [presentMode, box.w, box.h]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const el = containerRef.current;
|
||||
const ro = new ResizeObserver(() => {
|
||||
// Use clientWidth so we match the actual available column width
|
||||
setContainerWidth(el.clientWidth);
|
||||
setBox({ w: el.clientWidth, h: el.clientHeight });
|
||||
});
|
||||
|
||||
ro.observe(el);
|
||||
// Initial measure
|
||||
setContainerWidth(el.clientWidth);
|
||||
setBox({ w: el.clientWidth, h: el.clientHeight });
|
||||
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
return (<div
|
||||
ref={containerRef}
|
||||
className="relative w-full shadow-md"
|
||||
className={`relative w-full ${presentMode ? "flex h-full min-h-0 items-center justify-center shadow-none" : "shadow-md"}`}
|
||||
>
|
||||
<div
|
||||
className="relative mx-auto max-w-[1280px] "
|
||||
style={{ height: `${BASE_HEIGHT * scale}px`, overflow: "hidden" }}
|
||||
className={presentMode ? "relative mx-auto shrink-0" : "relative mx-auto max-w-[1280px]"}
|
||||
style={{
|
||||
width: presentMode ? `${BASE_WIDTH * scale}px` : undefined,
|
||||
height: `${BASE_HEIGHT * scale}px`,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute top-0 left-0"
|
||||
|
|
@ -62,12 +77,12 @@ const SlideScale = ({ slide }: { slide: any }) => {
|
|||
} as React.CSSProperties}
|
||||
>
|
||||
|
||||
<div
|
||||
{!isClickable && <div
|
||||
className="absolute inset-0 bg-transparent z-30 w-full h-full select-none"
|
||||
aria-hidden="true"
|
||||
|
||||
/>
|
||||
<V1ContentRender slide={slide} isEditMode={true} />
|
||||
/>}
|
||||
<V1ContentRender slide={slide} isEditMode={isEditMode} theme={theme} />
|
||||
</div>
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -23,14 +23,12 @@ export const V1ContentRender = ({ slide, isEditMode, theme }: { slide: any, isEd
|
|||
const isCustomTemplate = uuidValidate(customTemplateId) || slide.layout_group.startsWith("custom-");
|
||||
|
||||
// Always call the hook (React hooks rule), but with empty id when not a custom template
|
||||
const { template: customTemplate, loading: customLoading, fonts } = useCustomTemplateDetails({
|
||||
const { template: customTemplate, loading: customLoading } = useCustomTemplateDetails({
|
||||
id: isCustomTemplate ? customTemplateId : "",
|
||||
name: isCustomTemplate ? slide.layout_group : "",
|
||||
description: ""
|
||||
});
|
||||
if (fonts && typeof fonts === 'object') {
|
||||
// useFontLoader(fonts as unknown as Record<string, string>);
|
||||
}
|
||||
|
||||
|
||||
// Memoize layout resolution to prevent unnecessary recalculations
|
||||
const Layout = useMemo(() => {
|
||||
|
|
|
|||
|
|
@ -1,312 +0,0 @@
|
|||
import {
|
||||
Accordion,
|
||||
AccordionContent,
|
||||
AccordionItem,
|
||||
AccordionTrigger,
|
||||
} from "@/components/ui/accordion";
|
||||
import { HelpCircle, X, Search } from "lucide-react";
|
||||
import React, { useState, useEffect, useRef } from "react";
|
||||
|
||||
const helpQuestions = [
|
||||
{
|
||||
id: 1,
|
||||
category: "Images",
|
||||
question: "How do I change an image?",
|
||||
answer:
|
||||
"Click on any image to reveal the image toolbar. You'll see options to Edit, Adjust position, and change how the image fits within its container. The Edit option allows you to replace or modify the current image.",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
category: "Images",
|
||||
question: "Can I generate new images with AI?",
|
||||
answer:
|
||||
"Yes! Click on any image and select the Edit option from the toolbar. In the side panel that appears, you'll find the AI Generate tab. Enter your prompt describing the image you want, and our AI will generate an image based on your description.",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
category: "Images",
|
||||
question: "How do I upload my own images?",
|
||||
answer:
|
||||
"Click on any image, then select Edit from the toolbar. In the side panel, click on the Upload tab at the top. You can browse your files to select one. Once uploaded, you can apply it to your design.",
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
category: "AI Prompts",
|
||||
question: "Can I change slide layout through prompt?",
|
||||
answer:
|
||||
"Yes you can! Click on the WandSparkles icon on the top left of each slide and it will give you a prompt input box. Describe your layout requirements and the AI will change the slide layout accordingly.",
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
category: "AI Prompts",
|
||||
question: "Can I change slide image through prompt?",
|
||||
answer:
|
||||
"Yes you can! Click on the WandSparkles icon on the top left of each slide and it will give you a prompt input box. Describe the image you want and the AI will update the slide image based on your requirements.",
|
||||
},
|
||||
|
||||
{
|
||||
id: 14,
|
||||
category: "AI Prompts",
|
||||
question: "Can I change content through prompt?",
|
||||
answer:
|
||||
"Yes you can! Click on the WandSparkles icon on the top left of each slide and it will give you a prompt input box. Describe what content you want and the AI will update the slide's text and content based on your description.",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
category: "Text",
|
||||
question: "How can I format and highlight text?",
|
||||
answer:
|
||||
"Select any text to see the formatting toolbar appear. You'll have options for Bold, Italic, Underline, Strikethrough,and more.",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
category: "Icons",
|
||||
question: "How do I change icons?",
|
||||
answer:
|
||||
"Click on any existing icon to modify it. In the icon selector panel, you can browse icos or use the search function to find specific icons. We offer thousands of icons in various styles.",
|
||||
},
|
||||
{
|
||||
id: 16,
|
||||
category: "Layout",
|
||||
question: "Can I change the position of slide?",
|
||||
answer:
|
||||
"Of course, On side panel you can drag the slide and place wherever you want.",
|
||||
},
|
||||
{
|
||||
id: 15,
|
||||
category: "Layout",
|
||||
question: "Can I add new slide between the slide?",
|
||||
answer:
|
||||
"Yes you can just click on the plus icon below each slide.It will display the all the layouts and choose required one.",
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
category: "Layout",
|
||||
question: "Can I add more sections to my slides?",
|
||||
answer:
|
||||
"Absolutely! Hover near the bottom of any text box or content block, and you'll see a + icon appear. Click this button to add a new section below the current one. You can also use the Insert menu to add specific section types.",
|
||||
},
|
||||
|
||||
{
|
||||
id: 8,
|
||||
category: "Export",
|
||||
question: "How do I download or export my presentation?",
|
||||
answer:
|
||||
"Click the Export button in the top right menu. You can choose to download as PDF, PowerPoint.",
|
||||
},
|
||||
];
|
||||
|
||||
const Help = () => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [filteredQuestions, setFilteredQuestions] = useState(helpQuestions);
|
||||
const [categories, setCategories] = useState<string[]>([]);
|
||||
const [selectedCategory, setSelectedCategory] = useState("All");
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Extract unique categories and create "All" category list
|
||||
useEffect(() => {
|
||||
const uniqueCategories = Array.from(
|
||||
new Set(helpQuestions.map((q) => q.category))
|
||||
);
|
||||
setCategories(["All", ...uniqueCategories]);
|
||||
}, []);
|
||||
|
||||
// Filter questions based on search query and selected category
|
||||
useEffect(() => {
|
||||
let results = helpQuestions;
|
||||
|
||||
// Filter by category if not "All"
|
||||
if (selectedCategory !== "All") {
|
||||
results = results.filter((q) => q.category === selectedCategory);
|
||||
}
|
||||
|
||||
// Filter by search query
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
results = results.filter(
|
||||
(q) =>
|
||||
q.question.toLowerCase().includes(query) ||
|
||||
q.answer.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
setFilteredQuestions(results);
|
||||
}, [searchQuery, selectedCategory]);
|
||||
|
||||
// Close modal when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: any) => {
|
||||
if (
|
||||
modalRef.current &&
|
||||
!modalRef.current.contains(event.target) &&
|
||||
!event.target.closest(".help-button")
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
const handleOpenClose = () => {
|
||||
setIsOpen(!isOpen);
|
||||
};
|
||||
|
||||
// Animation helpers
|
||||
const modalClass = isOpen
|
||||
? "opacity-100 scale-100"
|
||||
: "opacity-0 scale-95 pointer-events-none";
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Help Button */}
|
||||
<button
|
||||
onClick={handleOpenClose}
|
||||
className="help-button hidden fixed bottom-6 right-6 h-12 w-12 z-50 bg-emerald-600 hover:bg-emerald-700 rounded-full md:flex justify-center items-center cursor-pointer shadow-lg transition-all duration-300 hover:shadow-xl"
|
||||
aria-label="Help Center"
|
||||
>
|
||||
{isOpen ? (
|
||||
<X className="text-white h-5 w-5" />
|
||||
) : (
|
||||
<HelpCircle className="text-white h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Help Modal */}
|
||||
<div
|
||||
className={`fixed bottom-20 right-6 z-50 max-w-md w-full transition-all duration-300 transform ${modalClass}`}
|
||||
ref={modalRef}
|
||||
>
|
||||
<div className="bg-white rounded-lg shadow-2xl border border-gray-200 overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="bg-emerald-600 text-white px-6 py-4 flex justify-between items-center">
|
||||
<h2 className="text-lg font-medium">Help Center</h2>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="hover:bg-emerald-700 p-1 rounded"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="px-6 pt-4 pb-2">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search help topics..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:border-transparent"
|
||||
/>
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-gray-400" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Pills */}
|
||||
<div className="px-6 pb-3 flex gap-2 overflow-x-auto hide-scrollbar">
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => setSelectedCategory(category)}
|
||||
className={`px-3 py-1 rounded-full text-sm whitespace-nowrap ${selectedCategory === category
|
||||
? "bg-emerald-600 text-white"
|
||||
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||
}`}
|
||||
>
|
||||
{category}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* FAQ Accordion */}
|
||||
<div className="max-h-96 overflow-y-auto px-6 pb-6">
|
||||
{filteredQuestions.length > 0 ? (
|
||||
<Accordion type="single" collapsible className="w-full">
|
||||
{filteredQuestions.map((faq, index) => (
|
||||
<AccordionItem
|
||||
key={index}
|
||||
value={`item-${index}`}
|
||||
className="border-b border-gray-200 last:border-b-0"
|
||||
>
|
||||
<AccordionTrigger className="hover:no-underline py-3 px-1 text-left flex">
|
||||
<div className="flex-1 pr-2">
|
||||
<span className="text-gray-900 font-medium text-sm md:text-base">
|
||||
{faq.question}
|
||||
</span>
|
||||
<span className="block text-xs text-emerald-600 mt-0.5">
|
||||
{faq.category}
|
||||
</span>
|
||||
</div>
|
||||
</AccordionTrigger>
|
||||
<AccordionContent className="px-1 pb-3">
|
||||
<div className="text-sm text-gray-600 leading-relaxed rounded bg-gray-50 p-3">
|
||||
{faq.answer}
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
))}
|
||||
</Accordion>
|
||||
) : (
|
||||
<div className="py-8 text-center text-gray-500">
|
||||
<p>No results found for "{searchQuery}"</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchQuery("");
|
||||
setSelectedCategory("All");
|
||||
}}
|
||||
className="mt-2 text-emerald-600 hover:underline text-sm"
|
||||
>
|
||||
Clear search
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="bg-gray-50 px-6 py-3 border-t border-gray-200 text-xs text-gray-500 text-center">
|
||||
Still need help?{" "}
|
||||
<a href="/contact" className="text-emerald-600 hover:underline">
|
||||
Contact Support
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Custom AccordionTrigger implementation (since shadcn's might not be available) */}
|
||||
{!AccordionTrigger && (
|
||||
<style jsx>{`
|
||||
.accordion-trigger {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 0;
|
||||
text-align: left;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.accordion-trigger:hover {
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
}
|
||||
.accordion-content {
|
||||
overflow: hidden;
|
||||
height: 0;
|
||||
transition: height 0.2s ease;
|
||||
}
|
||||
.accordion-content[data-state="open"] {
|
||||
height: auto;
|
||||
}
|
||||
`}</style>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Help;
|
||||
|
|
@ -23,9 +23,15 @@ const LoadingState = () => {
|
|||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 mx-auto w-[500px] flex flex-col items-center justify-center p-8">
|
||||
<div className="w-full bg-white rounded-xl p-[2px] ">
|
||||
<div className="bg-white rounded-xl p-6 w-full">
|
||||
<div className="flex items-center justify-center space-x-4 ">
|
||||
|
||||
<h2 className="text-2xl font-semibold text-gray-800">Creating Your Presentation</h2>
|
||||
<div className="flex flex-col items-center justify-center gap-4">
|
||||
<div
|
||||
className="presentation-loader-dots shrink-0"
|
||||
role="status"
|
||||
aria-label="Loading"
|
||||
/>
|
||||
<h2 className="text-2xl font-semibold text-gray-800">
|
||||
Creating Your Presentation
|
||||
</h2>
|
||||
</div>
|
||||
<div className="w-full max-w-md bg-white/80 backdrop-blur-sm rounded-xl shadow-sm p-6 mb-4">
|
||||
<div className="min-h-[120px] flex items-center justify-center">
|
||||
|
|
@ -42,6 +48,29 @@ const LoadingState = () => {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style jsx>{`
|
||||
.presentation-loader-dots {
|
||||
width: 50px;
|
||||
aspect-ratio: 1;
|
||||
--_c: no-repeat radial-gradient(
|
||||
farthest-side,
|
||||
#7a5af8 92%,
|
||||
#0000
|
||||
);
|
||||
background:
|
||||
var(--_c) top,
|
||||
var(--_c) left,
|
||||
var(--_c) right,
|
||||
var(--_c) bottom;
|
||||
background-size: 12px 12px;
|
||||
animation: presentation-loader-l7 1s infinite;
|
||||
}
|
||||
@keyframes presentation-loader-l7 {
|
||||
to {
|
||||
transform: rotate(0.5turn);
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -41,7 +41,6 @@ import ThemeApi from "../../services/api/theme";
|
|||
import { Theme } from "../../services/api/types";
|
||||
import MarkdownRenderer from "@/components/MarkDownRender";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getApiUrl } from "@/utils/api";
|
||||
|
||||
const PresentationHeader = ({
|
||||
presentation_id,
|
||||
|
|
@ -140,13 +139,30 @@ const PresentationHeader = ({
|
|||
};
|
||||
|
||||
const get_presentation_pptx_model = async (id: string): Promise<PptxPresentationModel> => {
|
||||
const response = await fetch(
|
||||
getApiUrl(`/api/presentation_to_pptx_model?id=${encodeURIComponent(id)}`)
|
||||
);
|
||||
const response = await fetch(`/api/presentation_to_pptx_model?id=${id}`);
|
||||
const pptx_model = await response.json();
|
||||
return pptx_model;
|
||||
};
|
||||
|
||||
const exportViaIpc = async (format: "pptx" | "pdf"): Promise<boolean> => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
if (!(window as any).electron?.exportPresentation) return false;
|
||||
trackEvent(
|
||||
format === "pptx"
|
||||
? MixpanelEvent.Header_ExportAsPPTX_API_Call
|
||||
: MixpanelEvent.Header_ExportAsPDF_API_Call
|
||||
);
|
||||
const result = await (window as any).electron.exportPresentation(
|
||||
presentation_id,
|
||||
presentationData?.title || 'presentation',
|
||||
format
|
||||
);
|
||||
if (!result?.success) {
|
||||
throw new Error(result?.message || 'Export failed');
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleExportPptx = async () => {
|
||||
if (isStreaming) return;
|
||||
|
||||
|
|
@ -157,6 +173,11 @@ const PresentationHeader = ({
|
|||
trackEvent(MixpanelEvent.Header_UpdatePresentationContent_API_Call);
|
||||
await PresentationGenerationApi.updatePresentationContent(presentationData);
|
||||
|
||||
if (await exportViaIpc("pptx")) {
|
||||
toast.success("PPTX exported successfully!");
|
||||
return;
|
||||
}
|
||||
|
||||
trackEvent(MixpanelEvent.Header_GetPptxModel_API_Call);
|
||||
const pptx_model = await get_presentation_pptx_model(presentation_id);
|
||||
if (!pptx_model) {
|
||||
|
|
@ -192,7 +213,11 @@ const PresentationHeader = ({
|
|||
await PresentationGenerationApi.updatePresentationContent(presentationData);
|
||||
|
||||
trackEvent(MixpanelEvent.Header_ExportAsPDF_API_Call);
|
||||
const response = await fetch(getApiUrl("/api/export-as-pdf"), {
|
||||
if (await exportViaIpc("pdf")) {
|
||||
toast.success("PDF exported successfully!");
|
||||
return;
|
||||
}
|
||||
const response = await fetch('/api/export-as-pdf', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
id: presentation_id,
|
||||
|
|
@ -370,14 +395,7 @@ const PresentationHeader = ({
|
|||
{isPresentationSaving && <div className="flex items-center gap-2">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
</div>}
|
||||
{presentationData?.slides?.[0]?.layout &&
|
||||
!presentationData.slides[0].layout.includes("custom") && (
|
||||
<ThemeSelector
|
||||
presentation_id={presentation_id}
|
||||
current_theme={presentationData?.theme || {}}
|
||||
themes={themes}
|
||||
/>
|
||||
)}
|
||||
{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]">
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
|
|
@ -8,297 +9,367 @@ import {
|
|||
Maximize2,
|
||||
StickyNote,
|
||||
EyeOff,
|
||||
Keyboard,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Slide } from "../../types/slide";
|
||||
import { V1ContentRender } from "../../components/V1ContentRender";
|
||||
|
||||
|
||||
import SlideScale from "../../components/PresentationRender";
|
||||
import type { Theme } from "../../services/api/types";
|
||||
import { applyPresentationThemeToElement } from "../utils/applyPresentationThemeDom";
|
||||
|
||||
interface PresentationModeProps {
|
||||
slides: Slide[];
|
||||
currentSlide: number;
|
||||
|
||||
theme?: Theme | null;
|
||||
isFullscreen: boolean;
|
||||
onFullscreenToggle: () => void;
|
||||
onExit: () => void;
|
||||
onSlideChange: (slideNumber: number) => void;
|
||||
}
|
||||
|
||||
const PresentationMode: React.FC<PresentationModeProps> = ({
|
||||
const CHROME_HIDE_MS = 800;
|
||||
|
||||
const PresentationMode: React.FC<PresentationModeProps> = ({
|
||||
slides,
|
||||
currentSlide,
|
||||
|
||||
theme,
|
||||
isFullscreen,
|
||||
onFullscreenToggle,
|
||||
onExit,
|
||||
onSlideChange,
|
||||
|
||||
|
||||
}) => {
|
||||
if (slides === undefined || slides === null || slides.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const hideChromeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [showSpeakerNotes, setShowSpeakerNotes] = useState(true);
|
||||
const [chromeVisible, setChromeVisible] = useState(true);
|
||||
|
||||
const currentSpeakerNote = useMemo(
|
||||
() => slides[currentSlide]?.speaker_note?.trim() || "",
|
||||
[slides, currentSlide]
|
||||
);
|
||||
|
||||
const activeSlide = slides[currentSlide];
|
||||
|
||||
const recomputeScale = useCallback(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const padding = isFullscreen ? 0 : 64; // match p-8 when not fullscreen
|
||||
const fullscreenMargin = isFullscreen ? 16 : 0; // small safety margin to prevent clipping
|
||||
const availableWidth = Math.max(window.innerWidth - padding - fullscreenMargin, 0);
|
||||
const availableHeight = Math.max(window.innerHeight - padding - fullscreenMargin, 0);
|
||||
const baseW = 1280;
|
||||
const baseH = 720;
|
||||
const s = Math.min(availableWidth / baseW, availableHeight / baseH);
|
||||
|
||||
const bumpChromeVisibility = useCallback(() => {
|
||||
setChromeVisible(true);
|
||||
if (hideChromeTimerRef.current) clearTimeout(hideChromeTimerRef.current);
|
||||
hideChromeTimerRef.current = setTimeout(() => {
|
||||
if (isFullscreen) setChromeVisible(false);
|
||||
}, CHROME_HIDE_MS);
|
||||
}, [isFullscreen]);
|
||||
|
||||
useEffect(() => {
|
||||
recomputeScale();
|
||||
window.addEventListener("resize", recomputeScale);
|
||||
return () => window.removeEventListener("resize", recomputeScale);
|
||||
}, [recomputeScale]);
|
||||
rootRef.current?.focus({ preventScroll: true });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isFullscreen) {
|
||||
setChromeVisible(true);
|
||||
if (hideChromeTimerRef.current) {
|
||||
clearTimeout(hideChromeTimerRef.current);
|
||||
hideChromeTimerRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
bumpChromeVisibility();
|
||||
return () => {
|
||||
if (hideChromeTimerRef.current) clearTimeout(hideChromeTimerRef.current);
|
||||
};
|
||||
}, [isFullscreen, bumpChromeVisibility]);
|
||||
|
||||
// Modify the handleKeyPress to prevent default behavior
|
||||
const handleKeyPress = useCallback(
|
||||
useLayoutEffect(() => {
|
||||
if (!theme || !rootRef.current) return;
|
||||
applyPresentationThemeToElement(rootRef.current, theme);
|
||||
}, [theme]);
|
||||
|
||||
const handlePointerActivity = useCallback(() => {
|
||||
bumpChromeVisibility();
|
||||
}, [bumpChromeVisibility]);
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
if (currentSlide < slides.length - 1) onSlideChange(currentSlide + 1);
|
||||
}, [currentSlide, slides.length, onSlideChange]);
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
if (currentSlide > 0) onSlideChange(currentSlide - 1);
|
||||
}, [currentSlide, onSlideChange]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
event.preventDefault(); // Prevent default scroll behavior
|
||||
const navKeys = [
|
||||
"ArrowRight",
|
||||
"ArrowLeft",
|
||||
"ArrowUp",
|
||||
"ArrowDown",
|
||||
" ",
|
||||
"Home",
|
||||
"End",
|
||||
"PageDown",
|
||||
"PageUp",
|
||||
];
|
||||
if (navKeys.includes(event.key)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if (event.repeat) {
|
||||
if (event.key === " " || event.key === "ArrowRight" || event.key === "ArrowLeft") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case "ArrowRight":
|
||||
case "ArrowDown":
|
||||
case " ": // Space key
|
||||
if (currentSlide < slides.length - 1) {
|
||||
onSlideChange(currentSlide + 1);
|
||||
}
|
||||
case " ":
|
||||
case "PageDown":
|
||||
goNext();
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
case "ArrowUp":
|
||||
if (currentSlide > 0) {
|
||||
onSlideChange(currentSlide - 1);
|
||||
case "PageUp":
|
||||
goPrev();
|
||||
break;
|
||||
case "Home":
|
||||
if (currentSlide !== 0) onSlideChange(0);
|
||||
break;
|
||||
case "End":
|
||||
if (slides.length > 0 && currentSlide !== slides.length - 1) {
|
||||
onSlideChange(slides.length - 1);
|
||||
}
|
||||
break;
|
||||
case "Escape":
|
||||
// If fullscreen is active, only exit fullscreen on first ESC. Second ESC exits present mode.
|
||||
if (document.fullscreenElement) {
|
||||
try { document.exitFullscreen(); } catch (_) { }
|
||||
try {
|
||||
document.exitFullscreen();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return;
|
||||
}
|
||||
onExit();
|
||||
break;
|
||||
case "f":
|
||||
case "F":
|
||||
onFullscreenToggle();
|
||||
if (!event.ctrlKey && !event.metaKey && !event.altKey) {
|
||||
onFullscreenToggle();
|
||||
}
|
||||
break;
|
||||
case "n":
|
||||
case "N":
|
||||
setShowSpeakerNotes((prev) => !prev);
|
||||
if (!event.ctrlKey && !event.metaKey && !event.altKey) {
|
||||
setShowSpeakerNotes((prev) => !prev);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[currentSlide, slides.length, onSlideChange, onExit, onFullscreenToggle, isFullscreen]
|
||||
[currentSlide, slides.length, onSlideChange, onExit, onFullscreenToggle, goNext, goPrev]
|
||||
);
|
||||
|
||||
// Add both keydown and keyup listeners
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Prevent default behavior for arrow keys and space
|
||||
if (
|
||||
["ArrowRight", "ArrowLeft", "ArrowUp", "ArrowDown", " "].includes(e.key)
|
||||
) {
|
||||
e.preventDefault();
|
||||
}
|
||||
handleKeyPress(e);
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [handleKeyPress]);
|
||||
|
||||
// Add click handlers for the slide area
|
||||
const handleSlideClick = (e: React.MouseEvent) => {
|
||||
// Don't trigger navigation if clicking on controls
|
||||
if ((e.target as HTMLElement).closest(".presentation-controls")) {
|
||||
return;
|
||||
}
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [handleKeyDown]);
|
||||
|
||||
const handleSlideAreaClick = (e: React.MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest(".presentation-controls")) return;
|
||||
const clickX = e.clientX;
|
||||
const windowWidth = window.innerWidth;
|
||||
|
||||
if (clickX < windowWidth / 3) {
|
||||
if (currentSlide > 0) {
|
||||
onSlideChange(currentSlide - 1);
|
||||
}
|
||||
} else if (clickX > (windowWidth * 2) / 3) {
|
||||
if (currentSlide < slides.length - 1) {
|
||||
onSlideChange(currentSlide + 1);
|
||||
}
|
||||
}
|
||||
const w = window.innerWidth;
|
||||
if (clickX < w / 5) goPrev();
|
||||
else if (clickX > (w * 4) / 5) goNext();
|
||||
};
|
||||
|
||||
// Handle Escape key separately
|
||||
useEffect(() => {
|
||||
const handleEscKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && isFullscreen) {
|
||||
onFullscreenToggle(); // Just toggle fullscreen, don't exit presentation
|
||||
}
|
||||
};
|
||||
const progress = slides.length > 0 ? ((currentSlide + 1) / slides.length) * 100 : 0;
|
||||
|
||||
document.addEventListener("keydown", handleEscKey);
|
||||
return () => document.removeEventListener("keydown", handleEscKey);
|
||||
}, [isFullscreen, onFullscreenToggle]);
|
||||
if (slides === undefined || slides === null || slides.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex flex-col"
|
||||
style={{ backgroundColor: "var(--page-background-color,#c8c7c9)" }}
|
||||
id="presentation-mode-wrapper"
|
||||
ref={rootRef}
|
||||
role="application"
|
||||
aria-label="Presentation"
|
||||
className="fixed inset-0 z-[100] flex flex-col outline-none select-none"
|
||||
style={{ backgroundColor: "var(--page-background-color, #c8c7c9)" }}
|
||||
tabIndex={0}
|
||||
onClick={handleSlideClick}
|
||||
onMouseMove={handlePointerActivity}
|
||||
onClick={handleSlideAreaClick}
|
||||
>
|
||||
{/* Controls - Only show when not in fullscreen */}
|
||||
{!isFullscreen && (
|
||||
<>
|
||||
<div className="presentation-controls absolute top-4 right-4 flex items-center gap-2 z-50">
|
||||
<Button
|
||||
variant="ghost"
|
||||
style={{ color: "var(--text-body-color,#000000)" }}
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onFullscreenToggle();
|
||||
}}
|
||||
className="text-white hover:bg-white/20"
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<Minimize2 className="h-5 w-5" />
|
||||
) : (
|
||||
<Maximize2 className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
style={{ color: "var(--text-body-color,#000000)" }}
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onExit();
|
||||
}}
|
||||
className="text-white hover:bg-white/20"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
<span className="sr-only">
|
||||
Slide {currentSlide + 1} of {slides.length}
|
||||
</span>
|
||||
|
||||
<div className="presentation-controls absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-4 z-50">
|
||||
<Button
|
||||
variant="ghost"
|
||||
style={{ color: "var(--text-body-color,#000000)" }}
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSlideChange(currentSlide - 1);
|
||||
}}
|
||||
disabled={currentSlide === 0}
|
||||
className="text-white hover:bg-white/20"
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" style={{ color: "var(--text-body-color,#000000)" }} />
|
||||
</Button>
|
||||
<span className="text-white"
|
||||
style={{ color: "var(--text-body-color,#000000)" }}
|
||||
>
|
||||
{currentSlide + 1} / {slides.length}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
style={{ color: "var(--text-body-color,#000000)" }}
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSlideChange(currentSlide + 1);
|
||||
}}
|
||||
disabled={currentSlide === slides.length - 1}
|
||||
className="text-white hover:bg-white/20"
|
||||
>
|
||||
<ChevronRight className="h-5 w-5" style={{ color: "var(--text-body-color,#000000)" }} />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Centered 16:9 stage for consistent alignment in normal + fullscreen modes */}
|
||||
<div className={`flex-1 min-h-0 flex items-center justify-center ${isFullscreen ? "px-6 py-8 md:px-10 md:py-12" : "p-8"}`}>
|
||||
<div
|
||||
className="relative rounded-sm font-inter"
|
||||
style={{
|
||||
aspectRatio: "16 / 9",
|
||||
width: isFullscreen
|
||||
? "min(90vw, calc(88vh * 16 / 9))"
|
||||
: "min(calc(100vw - 4rem), calc((100vh - 4rem) * 16 / 9))",
|
||||
maxHeight: isFullscreen ? "88vh" : "calc(100vh - 4rem)",
|
||||
}}
|
||||
>
|
||||
{slides.length > 0 && slides.map((slide, index) => (
|
||||
<div
|
||||
key={slide.id}
|
||||
className={index === currentSlide ? "h-full w-full" : "hidden h-full w-full"}
|
||||
>
|
||||
<V1ContentRender slide={slide} isEditMode={true} />
|
||||
</div>
|
||||
))}
|
||||
{/* Top bar — fullscreen: auto-hide */}
|
||||
<div
|
||||
className={`presentation-controls absolute left-0 right-0 top-0 z-50 flex justify-end gap-2 px-3 py-3 transition-opacity duration-300 md:px-4 ${isFullscreen && !chromeVisible ? "pointer-events-none opacity-0" : "opacity-100"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1 rounded-full bg-white/95 px-1 py-1 backdrop-blur-sm">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Fullscreen (F)"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onFullscreenToggle();
|
||||
}}
|
||||
className="h-9 w-9 text-gray-800 hover:bg-gray-100"
|
||||
>
|
||||
{isFullscreen ? <Minimize2 className="h-5 w-5" /> : <Maximize2 className="h-5 w-5" />}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Exit presentation (Esc)"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onExit();
|
||||
}}
|
||||
className="h-9 w-9 text-gray-800 hover:bg-gray-100"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentSpeakerNote && (
|
||||
<div className="presentation-controls absolute bottom-4 right-4 z-50">
|
||||
{/* Slide stage — large viewport; SlideScale uses width+height so slides scale up */}
|
||||
<div
|
||||
className={`flex min-h-0 flex-1 items-stretch justify-stretch ${isFullscreen ? "px-2 pb-9 pt-12 sm:px-3" : "px-3 pb-24 pt-14 sm:px-4 md:pb-28 md:pt-16"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`min-h-0 w-full flex-1 overflow-hidden rounded-sm `}
|
||||
>
|
||||
{activeSlide ? (
|
||||
<SlideScale
|
||||
key={activeSlide.id ?? `slide-${currentSlide}`}
|
||||
slide={activeSlide}
|
||||
theme={theme ?? undefined}
|
||||
isEditMode={false}
|
||||
presentMode
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div
|
||||
className={`absolute bottom-0 left-0 right-0 z-40 h-1 bg-gray-200 ${isFullscreen && !chromeVisible ? "opacity-70" : "opacity-100"
|
||||
}`}
|
||||
aria-hidden
|
||||
>
|
||||
<div
|
||||
className="h-full bg-[#5141e5] transition-[width] duration-300 ease-out"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Bottom controls */}
|
||||
<div
|
||||
className={`presentation-controls absolute bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-3 rounded-full border border-gray-200/90 bg-white/95 px-2 py-2 shadow-md backdrop-blur-sm transition-all duration-300 md:gap-4 md:px-3 ${isFullscreen && !chromeVisible
|
||||
? "pointer-events-none translate-y-4 opacity-0"
|
||||
: "translate-y-0 opacity-100"
|
||||
}`}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Previous slide"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
goPrev();
|
||||
}}
|
||||
disabled={currentSlide === 0}
|
||||
className="h-10 w-10 text-gray-800 hover:bg-gray-100 disabled:opacity-35"
|
||||
>
|
||||
<ChevronLeft className="h-6 w-6" />
|
||||
</Button>
|
||||
<div
|
||||
className="min-w-22 text-center text-sm font-medium tabular-nums text-gray-800"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
>
|
||||
{currentSlide + 1} / {slides.length}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
title="Next slide"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
goNext();
|
||||
}}
|
||||
disabled={currentSlide === slides.length - 1}
|
||||
className="h-10 w-10 text-gray-800 hover:bg-gray-100 disabled:opacity-35"
|
||||
>
|
||||
<ChevronRight className="h-6 w-6" />
|
||||
</Button>
|
||||
<div className="mx-1 hidden h-6 w-px bg-gray-200 sm:block" />
|
||||
<div
|
||||
className="hidden max-w-[200px] items-center gap-1.5 text-[11px] leading-tight text-gray-500 sm:flex"
|
||||
title="Keyboard shortcuts"
|
||||
>
|
||||
<Keyboard className="h-3.5 w-3.5 shrink-0" />
|
||||
<span>
|
||||
← → space · Home/End · F fullscreen · N notes · Esc exit
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentSpeakerNote ? (
|
||||
<div
|
||||
className={`presentation-controls absolute bottom-16 right-3 z-50 max-w-[min(380px,46vw)] md:bottom-20 md:right-6 ${isFullscreen && !chromeVisible ? "opacity-90" : ""
|
||||
}`}
|
||||
>
|
||||
{showSpeakerNotes ? (
|
||||
<div className="w-[360px] max-w-[50vw] rounded-xl border border-black/10 bg-white/95 shadow-xl backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between border-b border-black/10 px-3 py-2">
|
||||
<div className="rounded-xl border border-gray-200/90 bg-white/95 shadow-lg backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between border-b border-gray-100 px-3 py-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-gray-800">
|
||||
<StickyNote className="h-4 w-4" />
|
||||
<StickyNote className="h-4 w-4 text-amber-600" />
|
||||
Speaker notes
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowSpeakerNotes(false);
|
||||
}}
|
||||
className="h-8 px-2 text-gray-600 hover:bg-black/5 hover:text-gray-800"
|
||||
className="h-8 px-2 text-gray-600 hover:bg-gray-100 hover:text-gray-900"
|
||||
>
|
||||
<EyeOff className="mr-1 h-4 w-4" />
|
||||
Hide
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-[28vh] overflow-auto whitespace-pre-wrap px-3 py-2 text-sm text-gray-700">
|
||||
<div className="max-h-[min(28vh,220px)] overflow-auto whitespace-pre-wrap px-3 py-2.5 text-sm leading-relaxed text-gray-700">
|
||||
{currentSpeakerNote}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowSpeakerNotes(true);
|
||||
}}
|
||||
className="h-9 rounded-full border border-black/10 bg-white/95 px-3 text-gray-800 shadow-md hover:bg-white"
|
||||
className="h-9 rounded-full border border-gray-200 bg-white/95 px-3 text-gray-800 shadow-md backdrop-blur-sm hover:bg-gray-50"
|
||||
>
|
||||
<StickyNote className="mr-2 h-4 w-4" />
|
||||
<StickyNote className="mr-2 h-4 w-4 text-amber-600" />
|
||||
Show notes
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
"use client";
|
||||
import React, { useState } from "react";
|
||||
import React, { useLayoutEffect, useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "@/store/store";
|
||||
import "../../utils/prism-languages";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import PresentationMode from "./PresentationMode";
|
||||
import SidePanel from "./SidePanel";
|
||||
import SlideContent from "./SlideContent";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import {
|
||||
|
|
@ -18,6 +19,7 @@ import {
|
|||
} from "../hooks";
|
||||
import { PresentationPageProps } from "../types";
|
||||
import LoadingState from "./LoadingState";
|
||||
import { applyPresentationThemeToElement } from "../utils/applyPresentationThemeDom";
|
||||
|
||||
import { usePresentationUndoRedo } from "../hooks/PresentationUndoRedo";
|
||||
import PresentationHeader from "./PresentationHeader";
|
||||
|
|
@ -31,6 +33,8 @@ const PresentationPage: React.FC<PresentationPageProps> = ({
|
|||
const [selectedSlide, setSelectedSlide] = useState(0);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
|
||||
|
||||
const { presentationData, isStreaming } = useSelector(
|
||||
|
|
@ -53,6 +57,7 @@ const PresentationPage: React.FC<PresentationPageProps> = ({
|
|||
const {
|
||||
isPresentMode,
|
||||
stream,
|
||||
currentSlide: presentSlideFromUrl,
|
||||
handleSlideClick,
|
||||
toggleFullscreen,
|
||||
handlePresentExit,
|
||||
|
|
@ -75,24 +80,27 @@ const PresentationPage: React.FC<PresentationPageProps> = ({
|
|||
|
||||
usePresentationUndoRedo();
|
||||
|
||||
/** Editor tree unmounts in present mode; remount loses inline theme CSS — re-apply from Redux. */
|
||||
useLayoutEffect(() => {
|
||||
if (isPresentMode) return;
|
||||
const theme = presentationData?.theme;
|
||||
if (!theme) return;
|
||||
const el = document.getElementById("presentation-slides-wrapper");
|
||||
applyPresentationThemeToElement(el, theme);
|
||||
}, [isPresentMode, presentationData?.theme]);
|
||||
|
||||
const onSlideChange = (newSlide: number) => {
|
||||
handleSlideChange(newSlide, presentationData);
|
||||
};
|
||||
|
||||
// useEffect(() => {
|
||||
// if(!loading && !isStreaming && presentationData?.slides && presentationData?.slides.length > 0){
|
||||
// const presentation_id = presentationData?.slides[0].layout.split(":")[0].split("custom-")[1];
|
||||
// const fonts = getCustomTemplateFonts(presentation_id);
|
||||
|
||||
// useFontLoader(fonts || []);
|
||||
// }
|
||||
// }, [presentationData,loading,isStreaming]);
|
||||
// Presentation Mode View
|
||||
if (isPresentMode) {
|
||||
return (
|
||||
<PresentationMode
|
||||
slides={presentationData?.slides!}
|
||||
currentSlide={selectedSlide}
|
||||
currentSlide={presentSlideFromUrl}
|
||||
theme={presentationData?.theme ?? undefined}
|
||||
isFullscreen={isFullscreen}
|
||||
onFullscreenToggle={toggleFullscreen}
|
||||
onExit={handlePresentExit}
|
||||
|
|
@ -113,7 +121,11 @@ const PresentationPage: React.FC<PresentationPageProps> = ({
|
|||
<p className="text-center mb-4">
|
||||
We couldn't load your presentation. Please try again.
|
||||
</p>
|
||||
<Button onClick={() => { trackEvent(MixpanelEvent.PresentationPage_Refresh_Page_Button_Clicked, { pathname }); window.location.reload(); }}>Refresh Page</Button>
|
||||
<div className="flex gap-2 justify-center items-center">
|
||||
|
||||
<Button onClick={() => { trackEvent(MixpanelEvent.PresentationPage_Refresh_Page_Button_Clicked, { pathname }); window.location.reload(); }}>Refresh Page</Button>
|
||||
<Button onClick={() => { trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/upload" }); router.push("/upload"); }}>Go to Upload</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -125,6 +137,7 @@ const PresentationPage: React.FC<PresentationPageProps> = ({
|
|||
style={{
|
||||
background: "#ffffff",
|
||||
}}
|
||||
id="presentation-slides-wrapper"
|
||||
className="flex gap-6 relative "
|
||||
>
|
||||
<div className="w-[200px]">
|
||||
|
|
@ -136,15 +149,15 @@ const PresentationPage: React.FC<PresentationPageProps> = ({
|
|||
|
||||
/>
|
||||
</div>
|
||||
<div className=" w-full h-[calc(100vh-20px)] hide-scrollbar pr-[25px] overflow-y-auto">
|
||||
<div className=" w-full h-[calc(100vh-20px)] pr-[25px] overflow-y-auto">
|
||||
<PresentationHeader presentation_id={presentation_id} isPresentationSaving={isSaving} currentSlide={selectedSlide} />
|
||||
<div
|
||||
id="presentation-slides-wrapper"
|
||||
|
||||
style={{
|
||||
background: "rgba(255, 255, 255, 0.10)",
|
||||
boxShadow: "0 0 20.01px 0 rgba(122, 90, 248, 0.16) inset",
|
||||
}}
|
||||
className="p-6 rounded-[20px] flex flex-col items-center overflow-hidden justify-center border border-[#EDECEC] "
|
||||
className="p-6 rounded-[20px] font-inter flex flex-col items-center overflow-hidden justify-center border border-[#EDECEC] "
|
||||
>
|
||||
<div className="w-full max-w-[1280px] h-full">
|
||||
|
||||
|
|
|
|||
|
|
@ -132,7 +132,7 @@ const SidePanel = ({
|
|||
<Separator orientation="horizontal" className="my-6 " />
|
||||
<div
|
||||
className={`
|
||||
fixed xl:relative h-full z-50 xl:z-auto
|
||||
relative bg-[#F6F6F9] h-full z-50 xl:z-auto
|
||||
transition-all duration-300 ease-in-out
|
||||
`}
|
||||
>
|
||||
|
|
@ -141,14 +141,14 @@ const SidePanel = ({
|
|||
className="w-full h-[calc(100vh-120px)] hide-scrollbar overflow-hidden slide-theme "
|
||||
>
|
||||
|
||||
<p className="text-xl font-normal pb-3.5 text-[#000000]">Slides</p>
|
||||
<p className="text-xl font-normal font-syne pb-3.5 text-[#000000]">Slides ({presentationData?.slides?.length})</p>
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className=" overflow-y-auto hide-scrollbar h-[calc(100%-140px)] space-y-3.5">
|
||||
<div className=" overflow-y-auto w-full hide-scrollbar h-[calc(100%-140px)] space-y-3.5">
|
||||
{isStreaming ? (
|
||||
presentationData &&
|
||||
presentationData?.slides.map((slide: any, index: number) => (
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
|
|||
import { addToHistory } from "@/store/slices/undoRedoSlice";
|
||||
import { V1ContentRender } from "../../components/V1ContentRender";
|
||||
import NewSlide from "./NewSlide";
|
||||
import SlideScale from "../../components/PresentationRender";
|
||||
|
||||
interface SlideContentProps {
|
||||
slide: any;
|
||||
|
|
@ -145,7 +146,8 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => {
|
|||
data-group={slide.layout_group}
|
||||
className={` w-full group font-syne `}
|
||||
>
|
||||
<V1ContentRender slide={slide} isEditMode={true} theme={null} />
|
||||
{/* <V1ContentRender slide={slide} isEditMode={true} theme={null} /> */}
|
||||
<SlideScale slide={slide} theme={presentationData?.theme || null} />
|
||||
{!showNewSlideSelection && (
|
||||
<div className="group-hover:opacity-100 hidden md:block opacity-0 transition-opacity my-4 duration-300">
|
||||
<ToolTip content="Add new slide below">
|
||||
|
|
@ -219,6 +221,11 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => {
|
|||
className="min-h-[110px] max-h-[180px] w-full resize-none rounded-xl border border-gray-200 p-3 text-sm focus-visible:ring-1 focus-visible:ring-[#5141e5]"
|
||||
disabled={isUpdating}
|
||||
onChange={(e) => setEditPrompt(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Enter" || e.shiftKey || isUpdating) return;
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}}
|
||||
rows={5}
|
||||
wrap="soft"
|
||||
/>
|
||||
|
|
@ -234,7 +241,7 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => {
|
|||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Popover open={isSpeakerPopoverOpen} onOpenChange={setIsSpeakerPopoverOpen}>
|
||||
{slide?.speaker_note && <Popover open={isSpeakerPopoverOpen} onOpenChange={setIsSpeakerPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -268,11 +275,11 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => {
|
|||
</div>
|
||||
<div className="space-y-3 p-4">
|
||||
<div className="max-h-[220px] min-h-[100px] overflow-auto whitespace-pre-wrap rounded-xl border border-gray-200 bg-gray-50 p-3 text-sm text-gray-800">
|
||||
{slide?.speaker_note?.trim() || "No speaker notes for this slide."}
|
||||
{slide?.speaker_note?.trim()}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Popover>}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -13,8 +13,6 @@ interface SortableSlideProps {
|
|||
const SCALE = 0.125;
|
||||
|
||||
export function SortableSlide({ slide, index, selectedSlide, onSlideClick }: SortableSlideProps) {
|
||||
const searchParams = useSearchParams();
|
||||
const type = searchParams.get("type") as 'standard' | 'smart';
|
||||
const lastClickTime = useRef(0);
|
||||
const {
|
||||
attributes,
|
||||
|
|
@ -75,14 +73,7 @@ export function SortableSlide({ slide, index, selectedSlide, onSlideClick }: Sor
|
|||
<V1ContentRender slide={slide} isEditMode={true} />
|
||||
</div>
|
||||
</div>
|
||||
{/* <div className=" slide-box relative z-50 overflow-hidden aspect-video">
|
||||
<div className="absolute bg-transparent z-50 top-0 left-0 w-full h-full" />
|
||||
<div className="transform scale-[0.2] flex pointer-events-none justify-center items-center origin-top-left w-[500%] h-[500%]"
|
||||
|
||||
>
|
||||
<ContentRender slide={slide} isEditMode={true} />
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,78 +3,35 @@ import React, { useState } from 'react'
|
|||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Palette } from 'lucide-react';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { updateTheme } from '@/store/slices/presentationGeneration';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useFontLoader } from '../../hooks/useFontLoad';
|
||||
import { RootState } from '@/store/store';
|
||||
const ThemeSelector = ({ presentation_id, current_theme, themes: allThemes }: { presentation_id: string, current_theme: any, themes: any[] }) => {
|
||||
import {
|
||||
applyPresentationThemeToElement,
|
||||
clearPresentationThemeFromElement,
|
||||
} from "../utils/applyPresentationThemeDom";
|
||||
|
||||
const ThemeSelector = ({ current_theme, themes: allThemes }: { current_theme: any, themes: any[] }) => {
|
||||
const [currentTheme, setCurrentTheme] = useState<any>(current_theme)
|
||||
const dispatch = useDispatch()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const router = useRouter()
|
||||
const applyTheme = async (theme: any) => {
|
||||
const element = document.getElementById('presentation-slides-wrapper')
|
||||
const element = document.getElementById("presentation-slides-wrapper");
|
||||
if (!element) return;
|
||||
if (allThemes.length === 0) return;
|
||||
setCurrentTheme(theme)
|
||||
clearTheme()
|
||||
if (!theme.data.colors['graph_0']) { return; }
|
||||
const cssVariables = {
|
||||
'--primary-color': theme.data.colors['primary'],
|
||||
'--background-color': theme.data.colors['background'],
|
||||
'--card-color': theme.data.colors['card'],
|
||||
'--stroke': theme.data.colors['stroke'],
|
||||
'--primary-text': theme.data.colors['primary_text'],
|
||||
'--background-text': theme.data.colors['background_text'],
|
||||
'--graph-0': theme.data.colors['graph_0'],
|
||||
'--graph-1': theme.data.colors['graph_1'],
|
||||
'--graph-2': theme.data.colors['graph_2'],
|
||||
'--graph-3': theme.data.colors['graph_3'],
|
||||
'--graph-4': theme.data.colors['graph_4'],
|
||||
'--graph-5': theme.data.colors['graph_5'],
|
||||
'--graph-6': theme.data.colors['graph_6'],
|
||||
'--graph-7': theme.data.colors['graph_7'],
|
||||
'--graph-8': theme.data.colors['graph_8'],
|
||||
'--graph-9': theme.data.colors['graph_9'],
|
||||
}
|
||||
Object.entries(cssVariables).forEach(([key, value]) => {
|
||||
element.style.setProperty(key, value)
|
||||
})
|
||||
useFontLoader({ [theme.data.fonts.textFont.name]: theme.data.fonts.textFont.url })
|
||||
|
||||
// Apply fonts to preview container
|
||||
element.style.setProperty('font-family', `"${theme.data.fonts.textFont.name}"`)
|
||||
element.style.setProperty('--heading-font-family', `"${theme.data.fonts.textFont.name}"`)
|
||||
element.style.setProperty('--body-font-family', `"${theme.data.fonts.textFont.name}"`)
|
||||
|
||||
dispatch(updateTheme(theme))
|
||||
}
|
||||
const clearTheme = () => {
|
||||
const element = document.getElementById('presentation-slides-wrapper')
|
||||
if (!element) return;
|
||||
element.style.removeProperty('--primary-color');
|
||||
element.style.removeProperty('--background-color');
|
||||
element.style.removeProperty('--card-color');
|
||||
element.style.removeProperty('--stroke');
|
||||
element.style.removeProperty('--primary-text');
|
||||
element.style.removeProperty('--background-text');
|
||||
element.style.removeProperty('--graph-0');
|
||||
element.style.removeProperty('--graph-1');
|
||||
element.style.removeProperty('--graph-2');
|
||||
element.style.removeProperty('--graph-3');
|
||||
element.style.removeProperty('--graph-4');
|
||||
element.style.removeProperty('--graph-5');
|
||||
element.style.removeProperty('--graph-6');
|
||||
element.style.removeProperty('--graph-7');
|
||||
element.style.removeProperty('--graph-8');
|
||||
element.style.removeProperty('--graph-9');
|
||||
}
|
||||
setCurrentTheme(theme);
|
||||
clearPresentationThemeFromElement(element);
|
||||
if (!theme.data?.colors?.["graph_0"]) return;
|
||||
applyPresentationThemeToElement(element, theme);
|
||||
dispatch(updateTheme(theme));
|
||||
};
|
||||
const resetTheme = async () => {
|
||||
clearTheme();
|
||||
|
||||
dispatch(updateTheme(null))
|
||||
}
|
||||
dispatch(updateTheme(null));
|
||||
clearPresentationThemeFromElement(
|
||||
document.getElementById("presentation-slides-wrapper")
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -4,8 +4,10 @@ import { toast } from "sonner";
|
|||
import { setPresentationData } from "@/store/slices/presentationGeneration";
|
||||
import { DashboardApi } from '../../services/api/dashboard';
|
||||
import { clearHistory } from "@/store/slices/undoRedoSlice";
|
||||
import { applyPresentationThemeToElement } from "../utils/applyPresentationThemeDom";
|
||||
import { resolveBackendAssetUrl } from "@/utils/api";
|
||||
import { useFontLoader } from "../../hooks/useFontLoad";
|
||||
import { Theme } from "../../services/api/types";
|
||||
|
||||
|
||||
|
||||
export const usePresentationData = (
|
||||
|
|
@ -15,55 +17,23 @@ export const usePresentationData = (
|
|||
) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const applyTheme = async (theme: Theme) => {
|
||||
const element = document.getElementById('presentation-slides-wrapper')
|
||||
if (!element) return;
|
||||
if (!theme || !theme.data) { return; }
|
||||
if (!theme.data.colors['graph_0']) { return; }
|
||||
const cssVariables = {
|
||||
'--primary-color': theme.data.colors['primary'],
|
||||
'--background-color': theme.data.colors['background'],
|
||||
'--card-color': theme.data.colors['card'],
|
||||
'--stroke': theme.data.colors['stroke'],
|
||||
'--primary-text': theme.data.colors['primary_text'],
|
||||
'--background-text': theme.data.colors['background_text'],
|
||||
'--graph-0': theme.data.colors['graph_0'],
|
||||
'--graph-1': theme.data.colors['graph_1'],
|
||||
'--graph-2': theme.data.colors['graph_2'],
|
||||
'--graph-3': theme.data.colors['graph_3'],
|
||||
'--graph-4': theme.data.colors['graph_4'],
|
||||
'--graph-5': theme.data.colors['graph_5'],
|
||||
'--graph-6': theme.data.colors['graph_6'],
|
||||
'--graph-7': theme.data.colors['graph_7'],
|
||||
'--graph-8': theme.data.colors['graph_8'],
|
||||
'--graph-9': theme.data.colors['graph_9'],
|
||||
}
|
||||
Object.entries(cssVariables).forEach(([key, value]) => {
|
||||
element.style.setProperty(key, value)
|
||||
})
|
||||
useFontLoader({ [theme.data.fonts.textFont.name]: theme.data.fonts.textFont.url })
|
||||
|
||||
// Apply fonts to preview container
|
||||
element.style.setProperty('font-family', `"${theme.data.fonts.textFont.name}"`)
|
||||
element.style.setProperty('--heading-font-family', `"${theme.data.fonts.textFont.name}"`)
|
||||
element.style.setProperty('--body-font-family', `"${theme.data.fonts.textFont.name}"`)
|
||||
// Update the Presentation content with theme
|
||||
}
|
||||
|
||||
const fetchUserSlides = useCallback(async () => {
|
||||
try {
|
||||
const data = await DashboardApi.getPresentation(presentationId);
|
||||
|
||||
|
||||
if (data) {
|
||||
dispatch(setPresentationData(data));
|
||||
dispatch(clearHistory());
|
||||
setLoading(false);
|
||||
}
|
||||
if (data?.theme) {
|
||||
applyTheme(data.theme);
|
||||
}
|
||||
if (data.fonts) {
|
||||
useFontLoader(data.fonts);
|
||||
}
|
||||
if (data?.theme) {
|
||||
const el = document.getElementById("presentation-slides-wrapper");
|
||||
applyPresentationThemeToElement(el, data.theme);
|
||||
}
|
||||
} catch (error) {
|
||||
setError(true);
|
||||
toast.error("Failed to load presentation");
|
||||
|
|
|
|||
|
|
@ -8,7 +8,30 @@ import {
|
|||
import { jsonrepair } from "jsonrepair";
|
||||
import { toast } from "sonner";
|
||||
import { MixpanelEvent, trackEvent } from "@/utils/mixpanel";
|
||||
import { getApiUrl } from "@/utils/api";
|
||||
import { getFastAPIUrl, resolveBackendAssetUrl } from "@/utils/api";
|
||||
|
||||
const MAX_STREAM_RETRIES = 3;
|
||||
const STREAM_RETRY_DELAY_MS = 1_000;
|
||||
|
||||
const normalizePresentationAssets = <T,>(input: T): T => {
|
||||
if (Array.isArray(input)) {
|
||||
return input.map((item) => normalizePresentationAssets(item)) as T;
|
||||
}
|
||||
|
||||
if (input && typeof input === "object") {
|
||||
const normalized: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(input as Record<string, unknown>)) {
|
||||
if (typeof value === "string") {
|
||||
normalized[key] = resolveBackendAssetUrl(value);
|
||||
} else {
|
||||
normalized[key] = normalizePresentationAssets(value);
|
||||
}
|
||||
}
|
||||
return normalized as T;
|
||||
}
|
||||
|
||||
return input;
|
||||
};
|
||||
|
||||
export const usePresentationStreaming = (
|
||||
presentationId: string,
|
||||
|
|
@ -21,21 +44,81 @@ export const usePresentationStreaming = (
|
|||
const previousSlidesLength = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
let eventSource: EventSource;
|
||||
if (!stream) {
|
||||
fetchUserSlides();
|
||||
return;
|
||||
}
|
||||
|
||||
let eventSource: EventSource | null = null;
|
||||
let accumulatedChunks = "";
|
||||
let retryCount = 0;
|
||||
let isClosed = false;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const initializeStream = async () => {
|
||||
dispatch(setStreaming(true));
|
||||
dispatch(clearPresentationData());
|
||||
const closeEventSource = () => {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
};
|
||||
|
||||
trackEvent(MixpanelEvent.Presentation_Stream_API_Call);
|
||||
const clearRetryTimer = () => {
|
||||
if (retryTimer) {
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const finalizeFailure = (description: string) => {
|
||||
closeEventSource();
|
||||
clearRetryTimer();
|
||||
setLoading(false);
|
||||
dispatch(setStreaming(false));
|
||||
setError(true);
|
||||
toast.error("Presentation streaming failed", { description });
|
||||
};
|
||||
|
||||
const scheduleRetry = (reason: string): boolean => {
|
||||
if (retryCount >= MAX_STREAM_RETRIES || isClosed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
retryCount += 1;
|
||||
const retryDelay = STREAM_RETRY_DELAY_MS * retryCount;
|
||||
console.warn(
|
||||
`Presentation stream retry ${retryCount}/${MAX_STREAM_RETRIES}: ${reason}`
|
||||
);
|
||||
|
||||
closeEventSource();
|
||||
clearRetryTimer();
|
||||
accumulatedChunks = "";
|
||||
previousSlidesLength.current = 0;
|
||||
|
||||
retryTimer = setTimeout(() => {
|
||||
if (!isClosed) {
|
||||
openStream();
|
||||
}
|
||||
}, retryDelay);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const openStream = () => {
|
||||
closeEventSource();
|
||||
eventSource = new EventSource(
|
||||
getApiUrl(`/api/v1/ppt/presentation/stream/${presentationId}`)
|
||||
`${getFastAPIUrl()}/api/v1/ppt/presentation/stream/${presentationId}`
|
||||
);
|
||||
|
||||
eventSource.addEventListener("response", (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
let data: any;
|
||||
try {
|
||||
data = JSON.parse(event.data);
|
||||
} catch {
|
||||
if (!scheduleRetry("invalid SSE payload")) {
|
||||
finalizeFailure("Failed to parse stream response.");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (data.type) {
|
||||
case "chunk":
|
||||
|
|
@ -43,19 +126,20 @@ export const usePresentationStreaming = (
|
|||
try {
|
||||
const repairedJson = jsonrepair(accumulatedChunks);
|
||||
const partialData = JSON.parse(repairedJson);
|
||||
const normalizedPartialData = normalizePresentationAssets(partialData);
|
||||
|
||||
if (partialData.slides) {
|
||||
if (normalizedPartialData.slides) {
|
||||
if (
|
||||
partialData.slides.length !== previousSlidesLength.current &&
|
||||
partialData.slides.length > 0
|
||||
normalizedPartialData.slides.length !== previousSlidesLength.current &&
|
||||
normalizedPartialData.slides.length > 0
|
||||
) {
|
||||
dispatch(
|
||||
setPresentationData({
|
||||
...partialData,
|
||||
slides: partialData.slides,
|
||||
...normalizedPartialData,
|
||||
slides: normalizedPartialData.slides,
|
||||
})
|
||||
);
|
||||
previousSlidesLength.current = partialData.slides.length;
|
||||
previousSlidesLength.current = normalizedPartialData.slides.length;
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
|
@ -66,27 +150,34 @@ export const usePresentationStreaming = (
|
|||
|
||||
case "complete":
|
||||
try {
|
||||
dispatch(setPresentationData(data.presentation));
|
||||
dispatch(setPresentationData(normalizePresentationAssets(data.presentation)));
|
||||
dispatch(setStreaming(false));
|
||||
setLoading(false);
|
||||
eventSource.close();
|
||||
isClosed = true;
|
||||
closeEventSource();
|
||||
clearRetryTimer();
|
||||
retryCount = 0;
|
||||
|
||||
// Remove stream parameter from URL
|
||||
const newUrl = new URL(window.location.href);
|
||||
newUrl.searchParams.delete("stream");
|
||||
window.history.replaceState({}, "", newUrl.toString());
|
||||
} catch (error) {
|
||||
eventSource.close();
|
||||
console.error("Error parsing accumulated chunks:", error);
|
||||
if (!scheduleRetry("failed to parse complete payload")) {
|
||||
finalizeFailure("Failed to parse final presentation payload.");
|
||||
}
|
||||
}
|
||||
accumulatedChunks = "";
|
||||
break;
|
||||
|
||||
case "closing":
|
||||
dispatch(setPresentationData(data.presentation));
|
||||
dispatch(setPresentationData(normalizePresentationAssets(data.presentation)));
|
||||
setLoading(false);
|
||||
dispatch(setStreaming(false));
|
||||
eventSource.close();
|
||||
isClosed = true;
|
||||
closeEventSource();
|
||||
clearRetryTimer();
|
||||
retryCount = 0;
|
||||
|
||||
// Remove stream parameter from URL
|
||||
const newUrl = new URL(window.location.href);
|
||||
|
|
@ -94,38 +185,37 @@ export const usePresentationStreaming = (
|
|||
window.history.replaceState({}, "", newUrl.toString());
|
||||
break;
|
||||
case "error":
|
||||
eventSource.close();
|
||||
toast.error("Error in outline streaming", {
|
||||
description:
|
||||
if (
|
||||
!scheduleRetry(
|
||||
data.detail || "server returned stream error response"
|
||||
)
|
||||
) {
|
||||
finalizeFailure(
|
||||
data.detail ||
|
||||
"Failed to connect to the server. Please try again.",
|
||||
});
|
||||
setLoading(false);
|
||||
dispatch(setStreaming(false));
|
||||
setError(true);
|
||||
"Failed to connect to the server. Please try again."
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error("EventSource failed:", error);
|
||||
setLoading(false);
|
||||
dispatch(setStreaming(false));
|
||||
setError(true);
|
||||
eventSource.close();
|
||||
if (!scheduleRetry("connection lost")) {
|
||||
finalizeFailure("Failed to connect to the server. Please try again.");
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
if (stream) {
|
||||
initializeStream();
|
||||
} else {
|
||||
fetchUserSlides();
|
||||
}
|
||||
dispatch(setStreaming(true));
|
||||
dispatch(clearPresentationData());
|
||||
trackEvent(MixpanelEvent.Presentation_Stream_API_Call);
|
||||
openStream();
|
||||
|
||||
return () => {
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
}
|
||||
isClosed = true;
|
||||
closeEventSource();
|
||||
clearRetryTimer();
|
||||
};
|
||||
}, [presentationId, stream, dispatch, setLoading, setError, fetchUserSlides]);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import React from "react";
|
|||
import PresentationPage from "./components/PresentationPage";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import "../utils/prism-languages";
|
||||
const page = () => {
|
||||
|
||||
const router = useRouter();
|
||||
|
|
|
|||
|
|
@ -600,8 +600,8 @@ export function getTemplateByLayoutId(layoutId: string): TemplateWithData | unde
|
|||
export function getLayoutByLayoutId(layout: string): TemplateWithData | undefined {
|
||||
const templateName = layout.split(':')[0]
|
||||
|
||||
|
||||
const template = templates.find((t) => t.id === templateName)
|
||||
|
||||
if (template) {
|
||||
return template.layouts.find((t) => t.layoutId === layout);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue