feat: add new image assets and update presentation generation state

- Added new image assets: image_mode.png, logo-with-bg.png, image-provider.png, and openai.png.
- Enhanced presentation generation state to include theme property.
- Introduced updateTheme action in presentation generation slice.
- Updated user configuration with default LLM and image provider settings.
- Modified Tailwind CSS configuration to include new font families.
- Improved API utility functions for better handling of URLs in Electron environment.
- Adjusted PPTX model utility for border radius handling.
- Expanded provider constants to include URLs and icons for LLM providers.
- Updated provider utility functions for consistent API URL usage.
- Added new quality options for image generation providers.
- Updated package-lock.json to include new dependencies for react-colorful and scheduler.
This commit is contained in:
sudipnext 2026-03-20 11:41:50 +05:45
parent ecc004788a
commit fc1bad2d7c
129 changed files with 14965 additions and 2169 deletions

View file

@ -0,0 +1,64 @@
"use client";
import { ChevronRight } from 'lucide-react';
import Link from 'next/link';
import React, { } from 'react'
import { defaultNavItems } from './DashboardSidebar';
import { usePathname } from 'next/navigation';
const DashboardNav = () => {
const pathname = usePathname();
const activeTab = pathname.split("?")[0].split("/").pop();
const activeItem = defaultNavItems.find((i: any) => i.key === activeTab);
return (
<div className="sticky top-0 right-0 z-50 py-[28px] backdrop-blur ">
<div className="flex xl:flex-row flex-col gap-6 xl:gap-0 items-center justify-between">
<h3 className=" text-[28px] tracking-[-0.84px] font-unbounded font-normal text-[#101828] flex items-center gap-2">
{activeItem?.label ?? (activeTab && activeTab?.charAt(0).toUpperCase() + activeTab?.slice(1))}
</h3>
<div className="flex gap-2.5 max-sm:w-full max-md:justify-center max-sm:flex-wrap">
{activeTab !== "playground" && activeTab !== "theme" && <Link
href="/generate"
className="inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-black text-sm font-medium shadow-sm hover:shadow-md"
aria-label="Create new presentation"
style={{
borderRadius: "48px",
background: "linear-gradient(270deg, #D5CAFC 2.4%, #E3D2EB 27.88%, #F4DCD3 69.23%, #FDE4C2 100%)",
}}
>
<span className="hidden md:inline">New presentation</span>
<span className="md:hidden">New</span>
<ChevronRight className="w-4 h-4" />
</Link>}
{activeTab === "theme" &&
<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>
)
}
export default DashboardNav

View file

@ -0,0 +1,128 @@
"use client";
import React from "react";
import { LayoutDashboard, Star, Brain, Settings, Palette } from "lucide-react";
import { usePathname } from "next/navigation";
import Link from "next/link";
import { useRouter } from "next/navigation";
export const defaultNavItems = [
{ key: "dashboard" as const, label: "Dashboard", icon: LayoutDashboard },
{ key: "templates" as const, label: "Standard", icon: Star },
{ key: "designs" as const, label: "Smart", icon: Brain },
];
export const BelongingNavItems = [
{ key: "settings" as const, label: "Settings", icon: Settings },
]
const DashboardSidebar = () => {
const pathname = usePathname();
const activeTab = pathname.split("?")[0].split("/").pop();
const router = useRouter();
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"
aria-label="Dashboard sidebar"
>
<div>
<div onClick={() => router.push("/dashboard")} className="flex items-center pb-6 border-b border-slate-200/60 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>
<nav className="pt-6 font-syne" aria-label="Dashboard sections">
<div className=" space-y-6">
{/* Dashboard */}
<Link
prefetch={false}
href={`/dashboard`}
className={[
"flex flex-col tex-center items-center gap-2 transition-colors",
pathname === "/dashboard" ? "" : "ring-transparent",
].join(" ")}
aria-label="Dashboard"
title="Dashboard"
>
<LayoutDashboard className={["h-4 w-4", pathname === "/dashboard" ? "text-[#5146E5]" : "text-slate-600"].join(" ")} />
<span className="text-[11px] text-slate-800">Dashboard</span>
</Link>
<Link
prefetch={false}
href={`/templates`}
className={[
"flex flex-col tex-center items-center gap-2 transition-colors",
pathname === "/templates" ? "" : "ring-transparent",
].join(" ")}
aria-label="Templates"
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"}`} />
<span className="text-[11px] text-slate-800">Templates</span>
</div>
</Link>
<Link
prefetch={false}
href={`/theme`}
className={[
"flex flex-col tex-center items-center gap-2 transition-colors",
pathname === "/theme" ? "" : "ring-transparent",
].join(" ")}
aria-label="Theme"
title="Theme"
>
<div className="flex flex-col cursor-pointer tex-center items-center gap-2 transition-colors">
<Palette className={`h-4 w-4 ${pathname === "/theme" ? "text-[#5146E5]" : "text-slate-600"}`} />
<span className="text-[11px] text-slate-800">Themes</span>
</div>
</Link>
</div>
</nav>
</div>
<div className=" pt-5 border-t border-slate-200/60 font-syne "
>
{BelongingNavItems.map(({ key, label: itemLabel, icon: Icon }) => {
const isActive = activeTab === key;
return (
<Link
prefetch={false}
key={key}
href={`/${key}`}
className={[
"flex flex-col tex-center items-center gap-2 transition-colors ",
isActive ? "" : "ring-transparent",
].join(" ")}
aria-label={itemLabel}
title={itemLabel}
>
<Icon className={["h-4 w-4", isActive ? "text-[#5146E5]" : "text-slate-600"].join(" ")} />
<span className="text-[11px] text-slate-800">{itemLabel}</span>
</Link>
);
})}
</div>
</aside>
);
};
export default DashboardSidebar;

View file

@ -0,0 +1,111 @@
"use client";
import React, { useState, useEffect } from "react";
import { DashboardApi } from "@/app/(presentation-generator)/services/api/dashboard";
import { PresentationGrid } from "@/app/(presentation-generator)/(dashboard)/dashboard/components/PresentationGrid";
import Link from "next/link";
import { ChevronRight } from "lucide-react";
const DashboardPage: React.FC = () => {
const [presentations, setPresentations] = useState<any>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const loadData = async () => {
await fetchPresentations();
};
loadData();
}, []);
const fetchPresentations = async () => {
try {
setIsLoading(true);
setError(null);
const data = await DashboardApi.getPresentations();
data.sort(
(a: any, b: any) =>
new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
);
setPresentations(data);
} catch (err) {
setError(null);
setPresentations([]);
} finally {
setIsLoading(false);
}
};
const removePresentation = (presentationId: string) => {
setPresentations((prev: any) =>
prev ? prev.filter((p: any) => p.id !== presentationId) : []
);
};
return (
<div className="min-h-screen w-full px-6 pb-10 relative">
<div className="sticky top-0 right-0 z-50 py-[28px] backdrop-blur mb-4 ">
<div className="flex xl:flex-row flex-col gap-6 xl:gap-0 items-center justify-between">
<h3 className=" text-[28px] tracking-[-0.84px] font-unbounded font-normal text-[#101828] flex items-center gap-2">
Slide Presentations
</h3>
<div className="flex gap-2.5 max-sm:w-full max-md:justify-center max-sm:flex-wrap">
<Link
href="/generate"
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={{
borderRadius: "48px",
background: "linear-gradient(270deg, #D5CAFC 2.4%, #E3D2EB 27.88%, #F4DCD3 69.23%, #FDE4C2 100%)",
}}
>
<span className="hidden md:inline">New presentation</span>
<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>
<PresentationGrid
presentations={presentations}
type="slide"
isLoading={isLoading}
error={error}
onPresentationDeleted={removePresentation}
/>
<div
className='fixed z-0 bottom-[-16.5rem] left-0 w-full h-full'
style={{
height: "341px",
borderRadius: '1440px',
background: 'radial-gradient(5.92% 104.69% at 50% 100%, rgba(122, 90, 248, 0.00) 0%, rgba(255, 255, 255, 0.00) 100%), radial-gradient(50% 50% at 50% 50%, rgba(122, 90, 248, 0.80) 0%, rgba(122, 90, 248, 0.00) 100%)',
}}
/>
</div>
);
};
export default DashboardPage;

View file

@ -0,0 +1,22 @@
import React from 'react';
export const EmptyState = () => {
return (
<div className="flex flex-col items-center justify-center min-h-[70vh] bg-white/50 rounded-lg">
<div className="mb-4">
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M42 14.4V33.6C42 40.8 38 44.8 30.8 44.8H17.2C10 44.8 6 40.8 6 33.6V14.4C6 7.2 10 3.2 17.2 3.2H30.8C38 3.2 42 7.2 42 14.4Z" stroke="#667085" strokeWidth="3" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
<path d="M6.96002 16.4188H41.04" stroke="#667085" strokeWidth="3" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
<path d="M19.04 3.21875V15.1388" stroke="#667085" strokeWidth="3" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
<path d="M28.96 3.21875V14.2388" stroke="#667085" strokeWidth="3" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<h3 className="text-[#101828] text-lg font-roboto font-medium mb-1">
You don't have any presentations yet.
</h3>
<p className="text-[#667085] text-base font-roboto">
Start creating the first one.
</p>
</div>
);
};

View file

@ -0,0 +1,31 @@
"use client";
import Wrapper from "@/components/Wrapper";
import React from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
const Header = () => {
const pathname = usePathname();
return (
<div className="w-full sticky top-0 z-50 py-7 ">
<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"
alt="Presentation logo"
className="h-[40px] w-[40px]"
/>
</Link>
</div>
</div>
</Wrapper>
</div>
);
};
export default Header;

View file

@ -0,0 +1,105 @@
'use client'
import React from "react";
import { Card } from "@/components/ui/card";
import { DashboardApi } from "@/app/(presentation-generator)/services/api/dashboard";
import { EllipsisVertical, Star, Trash } from "lucide-react";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { useFontLoader } from "@/app/(presentation-generator)/hooks/useFontLoader";
import SlideScale from "@/app/(presentation-generator)/components/PresentationRender";
import MarkdownRenderer from "@/components/MarkDownRender";
export const PresentationCard = ({
id,
title,
presentation,
onDeleted
}: {
id: string;
title: string;
presentation: any;
onDeleted?: (presentationId: string) => void;
}) => {
const router = useRouter();
useFontLoader(presentation.fonts || []);
const handlePreview = (e: React.MouseEvent) => {
e.preventDefault();
router.push(`/presentation?id=${id}&type=standard`);
};
const handleDelete = async (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
const response = await DashboardApi.deletePresentation(id);
if (response) {
toast.success("Presentation deleted", {
description: "The presentation has been deleted successfully",
});
if (onDeleted) {
onDeleted(id);
}
} else {
toast.error("Error deleting presentation");
}
};
const firstSlide = presentation?.slides?.[0];
return (
<Card
suppressHydrationWarning={true}
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">
{/* <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}
</p> */}
<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} />
</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" />
</div>
<p className="text-[#808080] text-sm font-syne">
{new Date(presentation?.created_at).toLocaleDateString()}
</p>
</div>
<Popover>
<PopoverTrigger className="w-6 h-6 hover:bg-gray-100 rounded-full flex items-center justify-center text-gray-500 hover:text-gray-700" onClick={(e) => e.stopPropagation()}>
<EllipsisVertical className="w-6 h-6 text-gray-500" />
</PopoverTrigger>
<PopoverContent align="end" className="bg-white w-[200px]">
<button
className="flex items-center justify-between w-full px-2 py-1 hover:bg-gray-100"
onClick={handleDelete}
>
<p>Delete</p>
<Trash className="w- h-4 text-red-500" />
</button>
</PopoverContent>
</Popover>
</div>
</div>
</div>
</Card>
);
};

View file

@ -0,0 +1,124 @@
import React from "react";
import { PresentationCard } from "./PresentationCard";
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";
interface PresentationGridProps {
presentations: PresentationResponse[];
type: "slide" | "video";
isLoading?: boolean;
error?: string | null;
onPresentationDeleted?: (presentationId: string) => void;
}
export const PresentationGrid = ({
presentations,
type,
isLoading = false,
error = null,
onPresentationDeleted,
}: PresentationGridProps) => {
const router = useRouter();
const handleCreateNewPresentation = () => {
if (type === "slide") {
router.push("/upload");
} else {
router.push("/editor");
}
};
const ShimmerCard = () => (
<div className="flex flex-col gap-4 min-h-[200px] bg-white/70 rounded-lg p-4 animate-pulse">
<div className="w-full h-24 bg-gray-200 rounded-lg"></div>
<div className="space-y-3">
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
</div>
</div>
);
const CreateNewCard = () => (
<div
onClick={handleCreateNewPresentation}
className="flex flex-col cursor-pointer group ring-1 ring-inset ring-slate-200 hover:ring-[#8A7DFF]/40 bg-white/80 rounded-xl overflow-hidden transition-all duration-300 font-syne"
>
<img src="/create_presentation.png" alt="New Presentation" className="w-full aspect-[16/11] object-cover" />
<div className="flex items-center gap-3 p-3 mt-auto border border-[#EDEEEF]">
<svg xmlns="http://www.w3.org/2000/svg" width="45" height="46" viewBox="0 0 45 46" fill="none" className="flex-shrink-0">
<rect width="45" height="46" rx="8" fill="#FB6514" />
<g clipPath="url(#clip0_1789_6104)">
<path d="M16.0332 17.1807L28.9665 17.1807" stroke="white" strokeWidth="1.11" strokeLinecap="round" strokeLinejoin="round" />
<path d="M28.3197 17.1807L28.3197 24.294C28.3197 24.637 28.1834 24.966 27.9409 25.2085C27.6983 25.4511 27.3694 25.5873 27.0264 25.5873L17.973 25.5873C17.63 25.5873 17.301 25.4511 17.0585 25.2085C16.8159 24.966 16.6797 24.637 16.6797 24.294L16.6797 17.1807" stroke="white" strokeWidth="1.11" strokeLinecap="round" strokeLinejoin="round" />
<path d="M19.2676 28.8202L22.5009 25.5869L25.7342 28.8202" stroke="white" strokeWidth="1.11" strokeLinecap="round" strokeLinejoin="round" />
</g>
<defs>
<clipPath id="clip0_1789_6104">
<rect width="15.52" height="15.52" fill="white" transform="translate(14.7402 15.2402)" />
</clipPath>
</defs>
</svg>
<div>
<h4 className="text-sm text-[#191919] font-semibold tracking-[0.14px]">Create New Presentation</h4>
<p className="text-sm text-[#808080] font-medium tracking-[0.14px] flex items-center gap-2">Get Started <ArrowRight className="w-4 h-4" /></p>
</div>
</div>
</div>
);
if (isLoading) {
return (
<div className="grid grid-cols-1 px-6 mt-10 md:grid-cols-2 lg:grid-cols-4 gap-5 sm:gap-6 w-full">
<div className="flex flex-col gap-4 min-h-[200px] cursor-pointer group ring-1 ring-inset ring-slate-200 bg-white/80 rounded-xl items-center justify-center animate-pulse">
<div className="rounded-full bg-slate-200 p-4">
<div className="w-8 h-8" />
</div>
<div className="text-center space-y-2">
<div className="h-4 bg-slate-200 rounded w-32 mx-auto"></div>
<div className="h-3 bg-slate-200 rounded w-48 mx-auto"></div>
</div>
</div>
{[...Array(15)].map((_, i) => (
<ShimmerCard key={i} />
))}
</div>
);
}
if (error) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<CreateNewCard />
<div className="col-span-3 flex items-center justify-center">
<div className="text-center text-gray-500">
<p className="mb-2">{error}</p>
<button
onClick={() => window.location.reload()}
className="text-primary hover:text-primary/80 underline"
>
Try again
</button>
</div>
</div>
</div>
);
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<CreateNewCard />
{presentations &&
presentations.length > 0 &&
presentations.map((presentation) => (
<PresentationCard
key={presentation.id}
id={presentation.id}
title={presentation.title}
presentation={presentation}
onDeleted={onPresentationDeleted}
/>
))}
</div>
);
};

View file

@ -0,0 +1,44 @@
import React from 'react';
import { Card, CardContent } from "@/components/ui/card";
import { Presentation } from '../types';
export const PresentationListItem: React.FC<Presentation> = ({
title,
date,
thumbnail,
type
}) => {
return (
<Card>
<CardContent className="flex items-center gap-4 p-4">
<div className="relative w-[120px] aspect-video rounded-md overflow-hidden">
<img
src={thumbnail}
alt={title}
className="object-cover"
/>
</div>
<div className="flex-1">
<h3 className="font-medium">{title}</h3>
<div className="text-sm text-muted-foreground">
{/* {formatDistanceToNow(new Date(date), { addSuffix: true })} */}
</div>
</div>
<div className="flex items-center gap-2">
{type === 'video' ? (
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 12L9 8V16L15 12Z" fill="currentColor" />
</svg>
) : (
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4 4H20V16H4V4Z" stroke="currentColor" strokeWidth="2" />
</svg>
)}
</div>
</CardContent>
</Card>
);
};

View file

@ -0,0 +1,28 @@
import React from 'react'
const loading = () => {
return (
<div className="grid grid-cols-1 px-6 mt-10 md:grid-cols-2 lg:grid-cols-4 gap-5 sm:gap-6 w-full">
<div className="flex flex-col gap-4 min-h-[200px] cursor-pointer group ring-1 ring-inset ring-slate-200 bg-white/80 rounded-xl items-center justify-center animate-pulse">
<div className="rounded-full bg-slate-200 p-4">
<div className="w-8 h-8" />
</div>
<div className="text-center space-y-2">
<div className="h-4 bg-slate-200 rounded w-32 mx-auto"></div>
<div className="h-3 bg-slate-200 rounded w-48 mx-auto"></div>
</div>
</div>
{[...Array(15)].map((_, i) => (
<div key={i} className="flex flex-col gap-4 min-h-[200px] bg-white/70 rounded-lg p-4 animate-pulse">
<div className="w-full h-24 bg-gray-200 rounded-lg"></div>
<div className="space-y-3">
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
</div>
</div>
))}
</div>
)
}
export default loading

View file

@ -0,0 +1,16 @@
export interface Presentation {
id: string;
title: string;
date: string;
thumbnail: string;
type: 'video' | 'slide';
}
export interface PresentationFilter {
type?: 'video' | 'slide';
search?: string;
dateRange?: {
start: Date;
end: Date;
};
}

View file

@ -0,0 +1,16 @@
import React from 'react'
import DashboardSidebar from './Components/DashboardSidebar'
const layout = ({ children }: { children: React.ReactNode }) => {
return (
<div className='flex pr-4 bg-white'>
<DashboardSidebar />
<div className='w-full'>
{children}
</div>
</div>
)
}
export default layout

View file

@ -0,0 +1,355 @@
import ToolTip from '@/components/ToolTip'
import { Button } from '@/components/ui/button'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Select, SelectItem, SelectContent, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { cn } from '@/lib/utils'
import { LLMConfig } from '@/types/llm_config'
import { DALLE_3_QUALITY_OPTIONS, GPT_IMAGE_1_5_QUALITY_OPTIONS, IMAGE_PROVIDERS } from '@/utils/providerConstants'
import { Check, ChevronUp, Eye, EyeOff } from 'lucide-react'
import React, { useState } from 'react'
const ImageProvider = ({ llmConfig, setLlmConfig }: { llmConfig: LLMConfig, setLlmConfig: (config: any) => void }) => {
const [openImageProviderSelect, setOpenImageProviderSelect] = useState(false);
const [showApiKey, setShowApiKey] = useState(false);
const isImageGenerationDisabled = llmConfig.DISABLE_IMAGE_GENERATION ?? false;
const handleChangeImageGenerationDisabled = (value: boolean) => {
setLlmConfig((prev: any) => ({
...prev,
DISABLE_IMAGE_GENERATION: value
}));
}
const input_field_changed = (value: string, field: string) => {
setLlmConfig((prev: any) => ({
...prev,
[field]: value
}));
setOpenImageProviderSelect(false);
}
const getFieldValue = (field?: string) => {
if (!field) return "";
return (llmConfig as Record<string, string | undefined>)[field] || "";
};
const updateFieldValue = (field: string | undefined, value: string) => {
if (!field) return;
setLlmConfig((prev: any) => ({
...prev,
[field]: value,
}));
};
const getTextProviderApiField = () => {
if (llmConfig.LLM === "openai") return "OPENAI_API_KEY";
if (llmConfig.LLM === "google") return "GOOGLE_API_KEY";
if (llmConfig.LLM === "anthropic") return "ANTHROPIC_API_KEY";
return "";
};
const renderQualitySelector = (llmConfig: LLMConfig, input_field_changed: (value: string, field: string) => void) => {
if (llmConfig.IMAGE_PROVIDER === "dall-e-3") {
return (
<div className="w-[205px] mr-0 ml-auto">
<label className="block text-sm font-medium text-gray-700 mb-2">
DALL·E 3 Image Quality
</label>
<div className="">
<Select value={llmConfig.DALL_E_3_QUALITY} onValueChange={(value) => input_field_changed(value, "DALL_E_3_QUALITY")}>
<SelectTrigger className="w-full h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between">
<SelectValue placeholder="Select a quality" />
</SelectTrigger>
<SelectContent>
{DALLE_3_QUALITY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
}
if (llmConfig.IMAGE_PROVIDER === "gpt-image-1.5") {
return (
<div className="w-[205px]">
<label className="block text-sm font-medium text-gray-700 mb-2">
GPT Image 1.5 Quality
</label>
<div className="">
<Select
value={llmConfig.GPT_IMAGE_1_5_QUALITY}
onValueChange={(value) => input_field_changed(value, "GPT_IMAGE_1_5_QUALITY")}
>
<SelectTrigger
className="w-full h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between">
<SelectValue placeholder="Select a quality" />
</SelectTrigger>
<SelectContent>
{GPT_IMAGE_1_5_QUALITY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
}
return null;
};
return (
<div className="space-y-6 bg-[#F9F8F8] p-7 rounded-[12px] ">
{/* API Key Input */}
<div className="mb-4 bg-white p-10 pt-5 rounded-[12px]">
<ToolTip content="Enable/Disable Image Generation" className='flex justify-end items-center'>
<div className='flex justify-end items-center'>
<Switch
checked={!isImageGenerationDisabled}
className='data-[state=checked]:bg-[#4791FF] data-[state=unchecked]:bg-gray-400'
onCheckedChange={(checked) => handleChangeImageGenerationDisabled(!checked)}
/>
</div>
</ToolTip>
<div className='flex items-center justify-between'>
<div className=" max-w-[290px] pb-[50px]">
<div className='w-[60px] h-[60px] px-[13.5px] py-[14.2px] rounded-[4px] flex items-center justify-center'
style={{ backgroundColor: '#F4F3FF' }}
>
<img src="/image-markup.svg" className='w-full h-full object-cover' alt='image-markup' />
</div>
<h3 className="text-xl font-normal text-[#191919] py-2.5">Image Generation Settings</h3>
<p className=" text-sm text-gray-500">
Choosing where images come from
</p>
</div>
<div className=' '>
<div className='flex items-center justify-end gap-4'>
{!isImageGenerationDisabled && (
<>
{/* Image Provider Selection */}
<div className="">
<label className="block text-sm font-medium text-gray-700 mb-2">
Select Image Provider
</label>
<div className="w-full">
<Popover
open={openImageProviderSelect}
onOpenChange={setOpenImageProviderSelect}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openImageProviderSelect}
className="w-[205px] h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between"
>
<div className="flex gap-3 items-center">
<span className="text-sm font-medium text-gray-900">
{llmConfig.IMAGE_PROVIDER
? IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER]
?.label || llmConfig.IMAGE_PROVIDER
: "Select image provider"}
</span>
</div>
<ChevronUp className="w-4 h-4 text-gray-500" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
align="start"
style={{ width: "var(--radix-popover-trigger-width)" }}
>
<Command>
<CommandInput placeholder="Search provider..." />
<CommandList>
<CommandEmpty>No provider found.</CommandEmpty>
<CommandGroup>
{Object.values(IMAGE_PROVIDERS).map(
(provider, index) => (
<CommandItem
key={index}
value={provider.value}
onSelect={(value) => {
input_field_changed(value, "IMAGE_PROVIDER");
setOpenImageProviderSelect(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
llmConfig.IMAGE_PROVIDER === provider.value
? "opacity-100"
: "opacity-0"
)}
/>
<div className="flex gap-3 items-center">
<div className="flex flex-col space-y-1 flex-1">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-gray-900 capitalize">
{provider.label}
</span>
</div>
<span className="text-xs text-gray-600 leading-relaxed">
{provider.description}
</span>
</div>
</div>
</CommandItem>
)
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
{/* Dynamic API Key Input for Image Provider */}
{llmConfig.IMAGE_PROVIDER &&
IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER] &&
(() => {
const provider = IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER];
// Show ComfyUI configuration
if (provider.value === "comfyui") {
return (
<div className=" space-y-4">
<div className='w-[205px]'>
<label className="block text-sm font-medium text-gray-700 mb-2">
ComfyUI Server URL
</label>
<div className="relative">
<input
type="text"
placeholder="http://192.168.1.7:8188"
className="w-full px-4 py-2.5 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
value={llmConfig.COMFYUI_URL || ""}
onChange={(e) => {
input_field_changed(
e.target.value,
"COMFYUI_URL"
);
}}
/>
</div>
</div>
</div>
);
}
// Show API key input for other providers
return (
<div className=" w-[205px]">
<label className="block text-sm font-medium text-gray-700 mb-2">
{provider.apiKeyFieldLabel}
</label>
<div className="relative">
<input
type={showApiKey ? 'text' : 'password'}
placeholder={`Enter your ${provider.apiKeyFieldLabel}`}
className="w-full px-4 py-2.5 h-12 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
value={getFieldValue(provider.apiKeyField)}
onChange={(e) =>
updateFieldValue(
provider.apiKeyField,
e.target.value
)
}
/>
<button
type="button"
onClick={() => setShowApiKey((prev) => !prev)}
className='absolute right-2 top-1/2 -translate-y-1/2 bg-white px-2 py-1 cursor-pointer'
>
{showApiKey ? <Eye className='w-4 h-4 text-gray-500' /> : <EyeOff className='w-4 h-4 text-gray-500' />}
</button>
</div>
</div>
);
})()}
</>
)}
</div>
{!isImageGenerationDisabled && <div className='flex justify-end items-center mt-[18px]'>
{renderQualitySelector(llmConfig, input_field_changed)}
{llmConfig.IMAGE_PROVIDER === "comfyui" && <div className='w-full'>
<label className="block text-sm font-medium text-gray-700 mb-2">
Workflow JSON
</label>
<div className="relative">
<textarea
placeholder='Paste your ComfyUI workflow JSON here (export via "Export (API)" in ComfyUI)'
className="w-full px-4 py-2.5 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors font-mono text-xs"
rows={3}
value={llmConfig.COMFYUI_WORKFLOW || ""}
onChange={(e) => {
input_field_changed(
e.target.value,
"COMFYUI_WORKFLOW"
);
}}
/>
</div>
</div>}
</div>}
</div>
</div>
</div>
{/* Web Grounding Toggle - show at the end, below models dropdown */}
{/* <div className="bg-white flex justify-between items-center p-10 rounded-[12px]">
<div className=' max-w-[290px]'>
<h4 className="text-xl font-normal text-[#191919]">Advanced</h4>
<p className="mt-2.5 text-sm text-gray-500">
Configure advanced AI features.
</p>
</div>
<div className="flex items-center gap-4">
<div className="w-[275px]">
</div>
<div className="w-[295px]"></div>
</div>
</div> */}
</div>
)
}
export default ImageProvider

View file

@ -0,0 +1,345 @@
"use client";
import React, { useState, useEffect } from "react";
import { Loader2, Download, CheckCircle, ChevronRight } from "lucide-react";
import { toast } from "sonner";
import { RootState } from "@/store/store";
import { useSelector } from "react-redux";
import { handleSaveLLMConfig } from "@/utils/storeHelpers";
import {
checkIfSelectedOllamaModelIsPulled,
pullOllamaModel,
} from "@/utils/providerUtils";
import { useRouter, usePathname } from "next/navigation";
import { LLMConfig } from "@/types/llm_config";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
import SettingSideBar from "./SettingSideBar";
import TextProvider from "./TextProvider";
import ImageProvider from "./ImageProvider";
import { IMAGE_PROVIDERS, LLM_PROVIDERS } from "@/utils/providerConstants";
// Button state interface
interface ButtonState {
isLoading: boolean;
isDisabled: boolean;
text: string;
showProgress: boolean;
progressPercentage?: number;
status?: string;
}
const SettingsPage = () => {
const router = useRouter();
const pathname = usePathname();
const [mode, setMode] = useState<'nanobanana' | 'presenton'>('presenton')
const [selectedProvider, setSelectedProvider] = useState<'text-provider' | 'image-provider'>('text-provider')
const userConfigState = useSelector((state: RootState) => state.userConfig);
const [llmConfig, setLlmConfig] = useState<LLMConfig>(
userConfigState.llm_config
);
const canChangeKeys = userConfigState.can_change_keys;
const [buttonState, setButtonState] = useState<ButtonState>({
isLoading: false,
isDisabled: false,
text: "Save Configuration",
showProgress: false,
});
const [downloadingModel, setDownloadingModel] = useState<{
name: string;
size: number | null;
downloaded: number | null;
status: string;
done: boolean;
} | null>(null);
const [showDownloadModal, setShowDownloadModal] = useState<boolean>(false);
const downloadProgress = React.useMemo(() => {
if (
downloadingModel &&
downloadingModel.downloaded !== null &&
downloadingModel.size !== null
) {
return Math.round(
(downloadingModel.downloaded / downloadingModel.size) * 100
);
}
return 0;
}, [downloadingModel?.downloaded, downloadingModel?.size]);
const handleSaveConfig = async () => {
trackEvent(MixpanelEvent.Settings_SaveConfiguration_Button_Clicked, { pathname });
try {
setButtonState(prev => ({
...prev,
isLoading: true,
isDisabled: true,
text: "Saving Configuration...",
}));
trackEvent(MixpanelEvent.Settings_SaveConfiguration_API_Call);
await handleSaveLLMConfig(llmConfig);
if (llmConfig.LLM === "ollama" && llmConfig.OLLAMA_MODEL) {
trackEvent(MixpanelEvent.Settings_CheckOllamaModelPulled_API_Call);
const isPulled = await checkIfSelectedOllamaModelIsPulled(
llmConfig.OLLAMA_MODEL
);
if (!isPulled) {
setShowDownloadModal(true);
trackEvent(MixpanelEvent.Settings_DownloadOllamaModel_API_Call);
await handleModelDownload();
}
}
toast.info("Configuration saved successfully");
setButtonState(prev => ({
...prev,
isLoading: false,
isDisabled: false,
text: "Save Configuration",
}));
trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/upload" });
router.push("/upload");
} catch (error) {
toast.info(error instanceof Error ? error.message : "Failed to save configuration");
setButtonState(prev => ({
...prev,
isLoading: false,
isDisabled: false,
text: "Save Configuration",
}));
}
};
const handleModelDownload = async () => {
try {
await pullOllamaModel(llmConfig.OLLAMA_MODEL!, setDownloadingModel);
}
finally {
setDownloadingModel(null);
setShowDownloadModal(false);
}
};
useEffect(() => {
if (
downloadingModel &&
downloadingModel.downloaded !== null &&
downloadingModel.size !== null
) {
const percentage = Math.round(
(downloadingModel.downloaded / downloadingModel.size) * 100
);
setButtonState({
isLoading: true,
isDisabled: true,
text: `Downloading Model (${percentage}%)`,
showProgress: true,
progressPercentage: percentage,
status: downloadingModel.status,
});
}
if (downloadingModel && downloadingModel.done) {
setTimeout(() => {
setShowDownloadModal(false);
setDownloadingModel(null);
toast.info("Model downloaded successfully!");
}, 2000);
}
}, [downloadingModel]);
useEffect(() => {
if (!canChangeKeys) {
router.push("/dashboard");
}
}, [canChangeKeys, router]);
if (!canChangeKeys) {
return null;
}
const textProviderKey = llmConfig.LLM || "openai";
const textProviderLabel =
LLM_PROVIDERS[textProviderKey]?.label || textProviderKey;
const selectedTextModel =
textProviderKey === "openai"
? llmConfig.OPENAI_MODEL
: textProviderKey === "google"
? llmConfig.GOOGLE_MODEL
: textProviderKey === "anthropic"
? llmConfig.ANTHROPIC_MODEL
: textProviderKey === "ollama"
? llmConfig.OLLAMA_MODEL
: textProviderKey === "custom"
? llmConfig.CUSTOM_MODEL
: "";
const textSummary = selectedTextModel
? `${textProviderLabel} (${selectedTextModel})`
: textProviderLabel;
const imageSummary = llmConfig.DISABLE_IMAGE_GENERATION
? "Image generation disabled"
: llmConfig.IMAGE_PROVIDER
? IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER]?.label || llmConfig.IMAGE_PROVIDER
: "No image provider";
return (
<div className="h-screen font-syne flex flex-col overflow-hidden relative">
<div
className='fixed z-0 bottom-[-14.5rem] left-0 w-full h-full'
style={{
height: "341px",
borderRadius: '1440px',
background: 'radial-gradient(5.92% 104.69% at 50% 100%, rgba(122, 90, 248, 0.00) 0%, rgba(255, 255, 255, 0.00) 100%), radial-gradient(50% 50% at 50% 50%, rgba(122, 90, 248, 0.80) 0%, rgba(122, 90, 248, 0.00) 100%)',
}}
/>
<main className="w-full mx-auto gap-6 overflow-hidden flex ">
<SettingSideBar mode={mode} setMode={setMode} selectedProvider={selectedProvider} setSelectedProvider={setSelectedProvider} />
<div className="w-full">
<div className="sticky top-0 right-0 z-50 py-[28px] backdrop-blur mb-4 ">
<div className="flex gap-3 items-center ">
<h3 className=" text-[28px] tracking-[-0.84px] font-unbounded font-normal text-black flex items-center gap-2">
Settings
</h3>
<p className="text-[10px] px-2.5 py-0.5 rounded-[50px] text-[#7A5AF8] border border-[#EDEEEF] font-medium ">
{textSummary} · {imageSummary}
</p>
</div>
</div>
{mode === 'nanobanana' && <div className=" w-full bg-[#F9F8F8] p-7 rounded-[20px]">
<h4>Nano Banana</h4>
</div>}
{mode === 'presenton' && selectedProvider === 'text-provider' && <TextProvider
onInputChange={(value, field) => {
setLlmConfig(prev => ({
...prev,
[field]: value
}));
}}
llmConfig={llmConfig}
/>}
{mode === 'presenton' && selectedProvider === 'image-provider' && <ImageProvider llmConfig={llmConfig} setLlmConfig={setLlmConfig} />}
</div>
</main>
{/* Fixed Bottom Button */}
<div className=" mx-auto fixed bottom-20 right-5 ">
<button
onClick={handleSaveConfig}
disabled={buttonState.isDisabled}
style={{
background: "linear-gradient(270deg, #D5CAFC 2.4%, #E3D2EB 27.88%, #F4DCD3 69.23%, #FDE4C2 100%)",
color: "#101323",
}}
className={`w-full font-syne font-semibold flex items-center justify-center gap-2 py-3 px-5 rounded-[58px] transition-all duration-500 ${buttonState.isDisabled
? "bg-gray-400 cursor-not-allowed"
: "bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 focus:ring-4 focus:ring-blue-200"
} text-white`}
>
{buttonState.isLoading ? (
<div className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
{buttonState.text}
</div>
) : (
buttonState.text
)}
<ChevronRight className="w-4 h-4" />
</button>
</div>
{/* Download Progress Modal */}
{showDownloadModal && downloadingModel && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-white/95 backdrop-blur-md rounded-xl shadow-2xl max-w-md w-full p-6 relative">
{/* Modal Content */}
<div className="text-center">
{/* Icon */}
<div className="mb-4">
{downloadingModel.done ? (
<CheckCircle className="w-12 h-12 text-green-600 mx-auto" />
) : (
<Download className="w-12 h-12 text-blue-600 mx-auto animate-pulse" />
)}
</div>
{/* Title */}
<h3 className="text-lg font-semibold text-gray-900 mb-2">
{downloadingModel.done
? "Download Complete!"
: "Downloading Model"}
</h3>
{/* Model Name */}
<p className="text-sm text-gray-600 mb-6">
{llmConfig.OLLAMA_MODEL}
</p>
{/* Progress Bar */}
{downloadProgress > 0 && (
<div className="mb-4">
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
<div
className="bg-blue-600 h-3 rounded-full transition-all duration-300 ease-out"
style={{ width: `${downloadProgress}%` }}
/>
</div>
<p className="text-sm text-gray-600 mt-2">
{downloadProgress}% Complete
</p>
</div>
)}
{/* Status */}
{downloadingModel.status && (
<div className="flex items-center justify-center gap-2 mb-4">
<CheckCircle className="w-4 h-4 text-green-600" />
<span className="text-sm font-medium text-green-700 capitalize">
{downloadingModel.status}
</span>
</div>
)}
{/* Status Message */}
{downloadingModel.status &&
downloadingModel.status !== "pulled" && (
<div className="text-xs text-gray-500">
{downloadingModel.status === "downloading" &&
"Downloading model files..."}
{downloadingModel.status === "verifying" &&
"Verifying model integrity..."}
{downloadingModel.status === "pulling" &&
"Pulling model from registry..."}
</div>
)}
{/* Download Info */}
{downloadingModel.downloaded && downloadingModel.size && (
<div className="mt-4 p-3 bg-gray-50 rounded-lg">
<div className="flex justify-between text-xs text-gray-600">
<span>
Downloaded:{" "}
{(downloadingModel.downloaded / 1024 / 1024).toFixed(1)}{" "}
MB
</span>
<span>
Total: {(downloadingModel.size / 1024 / 1024).toFixed(1)}{" "}
MB
</span>
</div>
</div>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default SettingsPage;

View file

@ -0,0 +1,68 @@
import React from 'react'
const SettingSideBar = ({ mode, setMode, selectedProvider, setSelectedProvider }: { mode: 'nanobanana' | 'presenton', setMode: (mode: 'nanobanana' | 'presenton') => void, selectedProvider: 'text-provider' | 'image-provider', setSelectedProvider: (provider: 'text-provider' | 'image-provider') => void }) => {
return (
<div className='w-full max-w-[230px] h-screen px-4 pt-[22px] bg-[#F9FAFB]'>
<p className='text-xs text-black font-medium border-b mt-[3.15rem] border-[#E1E1E5] pb-3.5'>FILTER BY:</p>
<div className='mt-6'>
<p className='text-[#3A3A3A] text-xs font-medium pb-2.5'>Select Mode</p>
<div className='p-1 rounded-[40px] bg-[#ffffff] w-fit border border-[#EDEEEF] flex items-center justify-center mb-[34px] '>
<button className='px-3 py-2 text-xs font-medium text-[#3A3A3A] rounded-[70px]'
onClick={() => setMode('presenton')}
style={{
background: mode === 'presenton' ? '#F4F3FF' : 'transparent',
color: mode === 'presenton' ? '#5146E5' : '#3A3A3A'
}}
>Presenton</button>
<svg xmlns="http://www.w3.org/2000/svg" className='mx-1' width="2" height="17" viewBox="0 0 2 17" fill="none">
<path d="M1 0V16.5" stroke="#EDECEC" strokeWidth="2" />
</svg>
<div className='relative'>
<button className='px-3 py-2 text-xs font-medium rounded-[70px] cursor-not-allowed opacity-60'
disabled
style={{
background: 'transparent',
color: '#9CA3AF'
}}
>
Nanobanana
</button>
<span className='absolute -top-2 -right-5 text-[7px] uppercase tracking-wide bg-[#F4F3FF] text-[#5146E5] border border-[#D9D6FE] rounded-full px-1.5 py-0.5 whitespace-nowrap'>
Coming soon
</span>
</div>
</div>
<p className='text-[#3A3A3A] text-xs font-medium pb-2.5'>Select Provider</p>
{mode === 'presenton' && <div className='space-y-2.5'>
<button className={` w-full rounded-[6px] p-3 py-4 flex items-center gap-1.5 border ${selectedProvider === 'text-provider' ? 'bg-[#F4F3FF] border-[#D9D6FE]' : 'bg-white border-[#E1E1E5]'}`} onClick={() => setSelectedProvider('text-provider')}>
<div className='relative w-6 h-6 rounded-full overflow-hidden border border-[#EDEEEF]'>
<img src='/providers/openai.png' className=' object-cover w-full h-full overflow-hidden' alt='google' />
</div>
<p className='text-[#191919] text-xs font-medium' >Text Provider</p>
</button>
<button className={` w-full rounded-[6px] p-3 py-4 flex items-center gap-1.5 border ${selectedProvider === 'image-provider' ? 'bg-[#F4F3FF] border-[#D9D6FE]' : 'bg-white border-[#E1E1E5]'}`} onClick={() => setSelectedProvider('image-provider')}>
<div className='relative w-6 h-6 rounded-full overflow-hidden border border-[#EDEEEF]'>
<img src='/providers/image-provider.png' className=' object-cover w-full h-full overflow-hidden' alt='google' />
</div>
<p className='text-[#191919] text-xs font-medium' >Image Provider</p>
</button>
</div>}
{
mode === 'nanobanana' && <div>
<button className={` w-full rounded-[6px] p-3 py-4 flex items-center gap-1.5 border bg-[#F4F3FF] border-[#D9D6FE]`}>
<div className='relative w-6 h-6 rounded-full overflow-hidden border border-[#EDEEEF]'>
<img src='/providers/openai.png' className=' object-cover w-full h-full overflow-hidden' alt='google' />
</div>
<p className='text-[#191919] text-xs font-medium' >Nanobanana</p>
</button>
</div>
}
</div>
</div>
)
}
export default SettingSideBar

View file

@ -0,0 +1,576 @@
import ToolTip from '@/components/ToolTip';
import { Button } from '@/components/ui/button';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Switch } from '@/components/ui/switch';
import { cn } from '@/lib/utils';
import { LLMConfig } from '@/types/llm_config';
import { getApiUrl } from '@/utils/api';
import { LLM_PROVIDERS } from '@/utils/providerConstants';
import { Check, Loader2, Eye, EyeOff, ChevronUp, User, RefreshCw, LogOut } from 'lucide-react';
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner';
interface OpenAIConfigProps {
onInputChange: (value: string | boolean, field: string) => void;
llmConfig: LLMConfig;
}
const TextProvider = ({
onInputChange,
llmConfig
}: OpenAIConfigProps
) => {
const [openProviderSelect, setOpenProviderSelect] = useState(false);
const [openModelSelect, setOpenModelSelect] = useState(false);
const [availableModels, setAvailableModels] = useState<string[]>([]);
const [modelsLoading, setModelsLoading] = useState(false);
const [modelsChecked, setModelsChecked] = useState(false);
const [showApiKey, setShowApiKey] = useState(false);
const isFirstRender = useRef(true);
const selectedProvider = (llmConfig.LLM || 'openai') as keyof typeof LLM_PROVIDERS;
const selectedProviderMeta = LLM_PROVIDERS[selectedProvider];
const currentModelField = useMemo(() => {
switch (selectedProvider) {
case 'openai':
return 'OPENAI_MODEL';
case 'google':
return 'GOOGLE_MODEL';
case 'anthropic':
return 'ANTHROPIC_MODEL';
case 'ollama':
return 'OLLAMA_MODEL';
case 'custom':
return 'CUSTOM_MODEL';
default:
return '';
}
}, [selectedProvider]);
const currentApiKeyField = useMemo(() => {
switch (selectedProvider) {
case 'openai':
return 'OPENAI_API_KEY';
case 'google':
return 'GOOGLE_API_KEY';
case 'anthropic':
return 'ANTHROPIC_API_KEY';
case 'custom':
return 'CUSTOM_LLM_API_KEY';
default:
return '';
}
}, [selectedProvider]);
const currentModel = currentModelField ? ((llmConfig as Record<string, unknown>)[currentModelField] as string || '') : '';
const currentApiKey = currentApiKeyField ? ((llmConfig as Record<string, unknown>)[currentApiKeyField] as string || '') : '';
const currentCustomUrl = llmConfig.CUSTOM_LLM_URL || '';
const currentOllamaUrl = llmConfig.OLLAMA_URL || '';
const useCustomOllamaUrl = !!llmConfig.USE_CUSTOM_URL;
const modelLabel = selectedProviderMeta?.label || selectedProvider;
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
setAvailableModels([]);
setModelsChecked(false);
if (currentModelField) {
onInputChange('', currentModelField);
}
}, [selectedProvider, currentApiKey, currentCustomUrl, currentModelField]);
const onApiKeyChange = (llm: keyof typeof LLM_PROVIDERS, value: string) => {
if (llm === 'ollama') {
onInputChange(value, 'OLLAMA_URL');
return;
}
const keyField =
llm === 'openai'
? 'OPENAI_API_KEY'
: llm === 'google'
? 'GOOGLE_API_KEY'
: llm === 'anthropic'
? 'ANTHROPIC_API_KEY'
: llm === 'custom'
? 'CUSTOM_LLM_API_KEY'
: '';
if (keyField) {
onInputChange(value, keyField);
}
};
const fetchAvailableModels = async () => {
if (selectedProvider === 'openai' && !currentApiKey) return;
if (selectedProvider === 'google' && !currentApiKey) return;
if (selectedProvider === 'anthropic' && !currentApiKey) return;
if (selectedProvider === 'custom' && !currentCustomUrl) return;
setModelsLoading(true);
try {
let response: Response;
if (selectedProvider === 'google') {
response = await fetch(getApiUrl('/api/v1/ppt/google/models/available'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
api_key: currentApiKey
}),
});
} else if (selectedProvider === 'anthropic') {
response = await fetch(getApiUrl('/api/v1/ppt/anthropic/models/available'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
api_key: currentApiKey
}),
});
} else if (selectedProvider === 'ollama') {
response = await fetch(getApiUrl('/api/v1/ppt/ollama/models/supported'));
} else {
response = await fetch(getApiUrl('/api/v1/ppt/openai/models/available'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: selectedProvider === 'custom' ? currentCustomUrl : selectedProviderMeta?.url || '',
api_key: currentApiKey
}),
});
}
if (response.ok) {
const data = await response.json();
const normalizedModels: string[] = selectedProvider === 'ollama'
? Array.isArray(data)
? data.map((model: { value?: string; label?: string }) => model.value || model.label || '').filter(Boolean)
: []
: Array.isArray(data)
? data
: [];
setAvailableModels(normalizedModels);
setModelsChecked(true);
if (normalizedModels.length > 0 && currentModelField) {
if (currentModel && normalizedModels.includes(currentModel)) {
onInputChange(currentModel, currentModelField);
return;
}
const preferredDefault =
selectedProvider === 'openai'
? 'gpt-4.1'
: selectedProvider === 'google'
? 'models/gemini-2.5-flash'
: selectedProvider === 'anthropic'
? 'claude-sonnet-4-20250514'
: normalizedModels[0];
const nextModel = normalizedModels.includes(preferredDefault) ? preferredDefault : normalizedModels[0];
onInputChange(nextModel, currentModelField);
}
} else {
console.error('Failed to fetch models');
setAvailableModels([]);
setModelsChecked(true);
toast.error(`Failed to fetch ${modelLabel} models`);
}
} catch (error) {
console.error('Error fetching models:', error);
toast.error('Error fetching models');
setAvailableModels([]);
setModelsChecked(true);
} finally {
setModelsLoading(false);
}
};
useEffect(() => {
if (selectedProvider === 'ollama' && !modelsChecked && !modelsLoading) {
fetchAvailableModels();
}
}, [selectedProvider, modelsChecked, modelsLoading]);
return (
<div className="space-y-6 bg-[#F9F8F8] p-7 rounded-[12px] ">
{/* API Key Input */}
<div className="mb-4 flex items-center justify-between rounded-[12px] bg-white pt-5 pb-10 px-10">
<div className=" max-w-[290px] pb-[50px]">
<div className='w-[60px] h-[60px] rounded-[4px] flex items-center justify-center'
style={{ backgroundColor: '#4C55541A' }}
>
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
<path d="M15.9459 5.31543V26.5767" stroke="#4C5554" strokeWidth="1.59459" strokeLinecap="round" strokeLinejoin="round" />
<path d="M5.31531 9.30192V6.64426C5.31531 6.29183 5.45531 5.95384 5.70451 5.70463C5.95372 5.45543 6.29171 5.31543 6.64414 5.31543H25.2477C25.6002 5.31543 25.9382 5.45543 26.1874 5.70463C26.4366 5.95384 26.5766 6.29183 26.5766 6.64426V9.30192" stroke="#4C5554" strokeWidth="1.59459" strokeLinecap="round" strokeLinejoin="round" />
<path d="M11.9594 26.5762H19.9324" stroke="#4C5554" strokeWidth="1.59459" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
<h3 className="text-xl font-normal text-[#191919] py-2.5">Text Generation Settings</h3>
<p className=" text-sm text-gray-500">
Choosing where text contets come from
</p>
</div>
<div>
{selectedProvider === 'codex' && false && <div className='border border-[#EDEEEF] mb-4 rounded-[8px] p-5 flex justify-between items-center'>
<div className='flex items-center gap-2.5'>
<User className='w-4 h-4 text-gray-500' />
<div>
<h4 className='text-[#19001F] text-sm font-medium'>Acc: 123-455-acghk</h4>
<p className='text-xs text-[#B3B3B3]'>Signed in to ChatGPT</p>
</div>
</div>
<div className='flex items-center gap-2.5'>
<ToolTip content='Refresh ChatGPT account'>
<button className='px-3.5 py-2.5 rounded-full bg-[#EDEEEF]'>
<RefreshCw className='w-4 h-4 text-black' />
</button>
</ToolTip>
<ToolTip content='Logout from ChatGPT'>
<button className='px-3.5 py-2.5 rounded-full bg-[#EDEEEF]'>
<LogOut className='w-4 h-4 text-black' />
</button>
</ToolTip>
</div>
</div>}
<div className={`flex gap-4 justify-end ${selectedProvider === 'codex' ? 'items-end' : 'items-start'}`}>
<div className="relative w-[205px] ">
<div className="flex flex-col justify-start ">
<label className="block text-sm font-medium text-gray-700 mb-2">
Select Text Provider
</label>
<Popover
open={openProviderSelect}
onOpenChange={setOpenProviderSelect}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openProviderSelect}
className="w-[205px] h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between"
>
<div className="flex gap-3 items-center">
<span className="text-sm font-medium text-gray-900">
{llmConfig.LLM
? LLM_PROVIDERS[llmConfig.LLM]
?.label || llmConfig.LLM
: "Select text provider"}
</span>
</div>
<ChevronUp className="w-4 h-4 text-gray-500" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
align="start"
style={{ width: "var(--radix-popover-trigger-width)" }}
>
<Command>
<CommandInput placeholder="Search provider..." />
<CommandList>
<CommandEmpty>No provider found.</CommandEmpty>
<CommandGroup>
{Object.values(LLM_PROVIDERS).map(
(provider, index) => (
<CommandItem
key={index}
value={provider.value}
onSelect={(value) => {
onInputChange(value, "LLM");
setOpenProviderSelect(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
llmConfig.LLM === provider.value
? "opacity-100"
: "opacity-0"
)}
/>
<div className="flex gap-3 items-center">
<div className="flex flex-col space-y-1 flex-1">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-gray-900 capitalize">
{provider.label}
</span>
</div>
<span className="text-xs text-gray-600 leading-relaxed">
{provider.description}
</span>
</div>
</div>
</CommandItem>
)
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
<div className="relative flex flex-col justify-end items-end w-[205px] ">
<div className="flex flex-col justify-start w-full ">
{selectedProvider === 'ollama' ? (
<>
{!useCustomOllamaUrl ? (
<button
type="button"
onClick={() => {
onInputChange(true, 'USE_CUSTOM_URL');
if (!currentOllamaUrl) {
onInputChange('http://localhost:11434', 'OLLAMA_URL');
}
}}
className="mt-8 py-2.5 bg-[#EDEEEF] px-3.5 w-fit rounded-[48px] text-xs font-semibold text-[#101323] transition-all duration-200 border border-[#EDEEEF] hover:bg-[#E8F0FF]/90 focus:ring-2 focus:ring-blue-500/20"
>
Use Ollama URL
</button>
) : (
<>
<label className="block text-sm font-medium capitalize text-gray-700 mb-2">
Ollama URL
</label>
<div className="relative">
<input
type="text"
value={currentOllamaUrl}
onChange={(e) => onApiKeyChange(selectedProvider, e.target.value)}
className="w-full px-2 py-3 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
placeholder="http://localhost:11434"
/>
</div>
<button
type="button"
onClick={() => {
onInputChange(false, 'USE_CUSTOM_URL');
onInputChange('http://localhost:11434', 'OLLAMA_URL');
}}
className="mt-2 text-xs font-medium text-[#4B5563] underline underline-offset-2"
>
Use default Ollama URL
</button>
</>
)}
</>
) : selectedProvider === 'codex' ?
<>
<button className='px-3.5 py-2.5 bg-[#EDEEEF] mt-auto rounded-[58px] w-full text-xs font-medium text-[#101323]'>Sign in with ChatGPT</button>
</>
: (
<>
<label className="block text-sm font-medium capitalize text-gray-700 mb-2">
{selectedProvider === 'custom' ? 'Custom LLM API Key' : `${llmConfig.LLM} API Key`}
</label>
<div className="relative">
<input
type={showApiKey ? 'text' : 'password'}
value={currentApiKey}
onChange={(e) => onApiKeyChange(selectedProvider, e.target.value)}
className="w-full px-2 py-3 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
placeholder={`Enter your ${llmConfig.LLM} API key`}
/>
<button
type="button"
onClick={() => setShowApiKey((prev) => !prev)}
className='absolute right-2 top-1/2 -translate-y-1/2 bg-white px-2 py-1 cursor-pointer'
>
{showApiKey ? <Eye className='w-4 h-4 text-gray-500' /> : <EyeOff className='w-4 h-4 text-gray-500' />}
</button>
</div>
</>
)}
{selectedProvider === 'custom' && (
<input
type="text"
value={currentCustomUrl}
onChange={(e) => onInputChange(e.target.value, 'CUSTOM_LLM_URL')}
className="w-full mt-2 px-2 py-3 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
placeholder="OpenAI-compatible URL"
/>
)}
</div>
{selectedProvider !== 'ollama' && selectedProvider !== 'codex' && (!modelsChecked || (modelsChecked && availableModels.length === 0)) && (
<button
onClick={fetchAvailableModels}
disabled={
modelsLoading ||
(selectedProvider === 'openai' && !currentApiKey) ||
(selectedProvider === 'google' && !currentApiKey) ||
(selectedProvider === 'anthropic' && !currentApiKey) ||
(selectedProvider === 'custom' && !currentCustomUrl)
}
className={`mt-4 py-2.5 bg-[#EDEEEF] px-3.5 w-fit rounded-[48px] text-xs font-semibold text-[#101323] transition-all duration-200 border ${modelsLoading
? " border-gray-300 cursor-not-allowed text-gray-500"
: " border-[#EDEEEF] text-[#101323] hover:bg-[#E8F0FF]/90 focus:ring-2 focus:ring-blue-500/20"
}`}
>
{modelsLoading ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
Checking for models...
</span>
) : (
"Check models"
)}
</button>
)}
</div>
{/* Model Selection - only show if models are available */}
{modelsChecked && availableModels.length > 0 ? (
<div className="w-[205px]">
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
{selectedProvider === 'ollama' ? 'Choose a supported model' : `Select ${modelLabel} Model`}
</label>
<div className="w-full">
<Popover
open={openModelSelect}
onOpenChange={setOpenModelSelect}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openModelSelect}
className="w-full h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between"
>
<span className="text-sm truncate font-medium text-gray-900">
{currentModel
? availableModels.find(model => model === currentModel) || currentModel
: "Select a model"}
</span>
<ChevronUp className="w-4 h-4 text-gray-500" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
align="start"
style={{ width: "var(--radix-popover-trigger-width)" }}
>
<Command>
<CommandInput placeholder="Search models..." />
<CommandList>
<CommandEmpty>No model found.</CommandEmpty>
<CommandGroup>
{availableModels.map((model, index) => (
<CommandItem
key={index}
value={model}
onSelect={(value) => {
if (currentModelField) {
onInputChange(value, currentModelField);
}
setOpenModelSelect(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
currentModel === model
? "opacity-100"
: "opacity-0"
)}
/>
<div className="flex gap-3 items-center">
<div className="flex flex-col space-y-1 flex-1">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-gray-900">
{model}
</span>
</div>
</div>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
</div>
) : null}
</div>
</div>
</div>
{/* Show message if no models found */}
{modelsChecked && availableModels.length === 0 && (
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm text-yellow-800">
No models found. Please make sure your provider credentials are valid and the selected provider is reachable.
</p>
</div>
)}
{/* Web Grounding Toggle - show at the end, below models dropdown */}
<div className="bg-white flex justify-between items-center p-10 rounded-[12px]">
<div className=' max-w-[290px]'>
<h4 className="text-xl font-normal text-[#191919]">Advanced</h4>
<p className="mt-2.5 text-sm text-gray-500">
Configure advanced AI features.
</p>
</div>
<div className="flex items-center gap-4">
<div className="w-[205px]">
<div className="flex items-center mb-4 gap-2.5 ">
<Switch
checked={!!llmConfig.WEB_GROUNDING}
onCheckedChange={(checked) => onInputChange(checked, "WEB_GROUNDING")}
/>
<label className="text-sm font-medium text-gray-700">
Enable Web Grounding
</label>
</div>
</div>
{/* <div className="w-[295px]"></div> */}
</div>
</div>
</div>
)
}
export default TextProvider

View file

@ -0,0 +1,77 @@
import { Card } from "@/components/ui/card";
export default function LoadingProfile() {
return (
<div className="h-screen bg-gradient-to-b font-instrument_sans from-gray-50 to-white flex flex-col overflow-hidden">
{/* Header Skeleton */}
<div className="flex-shrink-0 bg-white border-b border-gray-200 p-4">
<div className="container mx-auto max-w-3xl">
<div className="flex items-center justify-between">
<div className="h-8 w-32 bg-gray-200 animate-pulse rounded-md" />
<div className="flex items-center gap-4">
<div className="h-8 w-8 bg-gray-200 animate-pulse rounded-full" />
<div className="h-8 w-24 bg-gray-200 animate-pulse rounded-md" />
</div>
</div>
</div>
</div>
{/* Main Content Skeleton */}
<main className="flex-1 container mx-auto px-4 max-w-3xl overflow-hidden flex flex-col">
<div className="flex-1 overflow-hidden">
{/* LLM Selection Content Skeleton */}
<div className="space-y-6 p-6">
{/* Page Title */}
<div className="space-y-2">
<div className="h-8 w-48 bg-gray-200 animate-pulse rounded-md" />
<div className="h-5 w-72 bg-gray-200 animate-pulse rounded-md" />
</div>
{/* LLM Provider Cards */}
<div className="space-y-4">
{[...Array(3)].map((_, index) => (
<Card key={index} className="p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className="h-10 w-10 bg-gray-200 animate-pulse rounded-md" />
<div className="space-y-1">
<div className="h-5 w-32 bg-gray-200 animate-pulse rounded-md" />
<div className="h-4 w-48 bg-gray-200 animate-pulse rounded-md" />
</div>
</div>
<div className="h-6 w-6 bg-gray-200 animate-pulse rounded-full" />
</div>
{/* Configuration Fields */}
<div className="space-y-4">
{[...Array(2)].map((_, fieldIndex) => (
<div key={fieldIndex} className="space-y-2">
<div className="h-4 w-24 bg-gray-200 animate-pulse rounded-md" />
<div className="h-10 w-full bg-gray-200 animate-pulse rounded-md" />
</div>
))}
</div>
</Card>
))}
</div>
{/* Model Selection */}
<Card className="p-6">
<div className="space-y-4">
<div className="h-5 w-32 bg-gray-200 animate-pulse rounded-md" />
<div className="h-10 w-full bg-gray-200 animate-pulse rounded-md" />
</div>
</Card>
</div>
</div>
</main>
{/* Fixed Bottom Button Skeleton */}
<div className="flex-shrink-0 bg-white border-t border-gray-200 p-4">
<div className="container mx-auto max-w-3xl">
<div className="h-12 w-full bg-gray-200 animate-pulse rounded-lg" />
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,40 @@
import { Plus, Sparkles } from 'lucide-react'
import { useRouter } from 'next/navigation';
import React from 'react'
const CreateCustomTemplate = () => {
const router = useRouter();
return (
<div
onClick={() => {
router.push('/custom-template')
}}
className='w-full rounded-xl border border-[#EDEEEF] cursor-pointer font-syne'>
<div className='relative h-[215px] flex justify-center items-center '>
<img src="/card_bg.svg" alt="" className="absolute top-0 z-[1] left-0 w-full h-full object-cover" />
<div className='w-[36px] h-[36px] relative z-[4] rounded-full bg-[#7A5AF8] flex items-center justify-center'
style={{
background: 'linear-gradient(0deg, rgba(0, 0, 0, 0.20) 0%, rgba(0, 0, 0, 0.20) 100%), #FFF'
}}
><div className='w-[26px] h-[26px] rounded-full bg-white flex items-center justify-center'>
<Plus className='w-4 h-4 text-[#A2A0A1]' />
</div>
</div>
</div>
<div className='px-5 py-4 bg-white flex items-center gap-4 border-t border-[#EDEEEF]'>
<div className='bg-[#7A5AF8] w-[45px] h-[45px] rounded-lg p-2 flex items-center justify-center'>
<Sparkles className='w-6 h-6 text-white' />
</div>
<div>
<h4 className='text-[#191919] text-sm font-semibold '>Build Template</h4>
<p className='flex text-[#808080] text-sm font-medium items-center gap-2'>Build Your Own Template</p>
</div>
</div>
</div>
)
}
export default CreateCustomTemplate

View file

@ -0,0 +1,278 @@
"use client";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { Card } from "@/components/ui/card";
import { ArrowUpRight, ChevronRight, ExternalLink, Loader2, Plus } from "lucide-react";
import { templates } from "@/app/presentation-templates";
import { TemplateWithData, TemplateLayoutsWithSettings } from "@/app/presentation-templates/utils";
import {
useCustomTemplateSummaries,
useCustomTemplatePreview,
CustomTemplates,
} from "@/app/hooks/useCustomTemplates";
import { CompiledLayout } from "@/app/hooks/compileLayout";
import CreateCustomTemplate from "./CreateCustomTemplate";
import Link from "next/link";
// Component for rendering custom template card with lazy-loaded previews
export const CustomTemplateCard = React.memo(function CustomTemplateCard({ template }: { template: CustomTemplates }) {
const router = useRouter();
const { previewLayouts, loading, totalLayouts } = useCustomTemplatePreview(`${template.id}`);
const handleOpen = useCallback(() => {
if (template.id.startsWith('custom-')) {
router.push(`/template-preview/${template.id}`)
} else {
router.push(`/template-preview/custom-${template.id}`)
}
}
, [router, template.id]);
return (
<Card
className="cursor-pointer flex flex-col justify-between shadow-none sm:shadow-none relative hover:shadow-lg transition-all duration-200 group overflow-hidden"
onClick={handleOpen}
>
<img src="/card_bg.svg" alt="" className="absolute top-0 left-0 w-full h-full object-cover" />
<span 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">
Layouts- {totalLayouts}
</span>
<div className="p-5">
{/* Layout previews */}
<div className="grid grid-cols-2 gap-2">
{loading ? (
// Loading placeholders
[...Array(Math.min(4, template.layoutCount))].map((_, index) => (
<div
key={`${template.id}-loading-${index}`}
className="relative bg-gradient-to-br from-purple-50 to-blue-50 border border-gray-200 overflow-hidden aspect-video rounded flex items-center justify-center"
>
<Loader2 className="w-4 h-4 text-purple-300 animate-spin" />
</div>
))
) : previewLayouts.length > 0 && (
// Actual layout previews
previewLayouts.slice(0, 4).map((layout: CompiledLayout, index: number) => {
const LayoutComponent = layout.component;
return (
<div
key={`${template.id}-preview-${index}`}
className="relative bg-gray-100 border border-gray-200 overflow-hidden aspect-video rounded"
>
<div className="absolute inset-0 bg-transparent z-10" />
<div
className="transform scale-[0.12] origin-top-left"
style={{ width: "833.33%", height: "833.33%" }}
>
<LayoutComponent data={layout.sampleData} />
</div>
</div>
);
})
)}
</div>
</div>
<div className="flex items-center justify-between p-5 bg-white border-t border-[#EDEEEF] relative z-40 ">
<h3 className="text-sm font-bold w-[191px] text-gray-900">
{template.name}
</h3>
<div className="flex items-center gap-2">
<ArrowUpRight className="w-4 h-4 text-gray-400 group-hover:text-purple-600 transition-colors" />
</div>
</div>
</Card>
);
}, (prev, next) => {
// Custom templates may be refetched, producing new object references; compare on fields we render/use.
return (
prev.template.id === next.template.id &&
prev.template.id === next.template.id &&
prev.template.name === next.template.name &&
prev.template.layoutCount === next.template.layoutCount
);
});
const InbuiltTemplateCard = React.memo(function InbuiltTemplateCard({
template,
onOpen,
}: {
template: TemplateLayoutsWithSettings;
onOpen: (id: string) => void;
}) {
const previewLayouts = useMemo(() => template.layouts.slice(0, 4), [template.layouts]);
const handleOpen = useCallback(() => onOpen(template.id), [onOpen, template.id]);
return (
<Card
key={template.id}
className="cursor-pointer relative sm:shadow-none shadow-none hover:shadow-lg transition-all duration-200 group overflow-hidden"
onClick={handleOpen}
>
<span 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">
Layouts- {template.layouts.length}
</span>
<img src="/card_bg.svg" alt="" className="absolute top-0 left-0 w-full h-full object-cover" />
<div className="p-5">
<div className="grid grid-cols-2 gap-2">
{previewLayouts.map((layout: TemplateWithData, index: number) => {
const LayoutComponent = layout.component;
return (
<div
key={`${template.id}-preview-${index}`}
className="relative bg-gray-100 border border-gray-200 overflow-hidden aspect-video rounded"
>
<div className="absolute inset-0 bg-transparent z-10" />
<div
className="transform scale-[0.12] origin-top-left"
style={{ width: "833.33%", height: "833.33%" }}
>
<LayoutComponent data={layout.sampleData} />
</div>
</div>
);
})}
</div>
</div>
<div className="flex items-center justify-between p-5 bg-white border-t border-[#EDEEEF] relative z-40 ">
<div className="w-[191px]">
<h3 className="text-sm font-bold text-gray-900 capitalize">
{template.name}
</h3>
<p className="text-xs text-gray-600 mb-4 line-clamp-2">
{template.description}
</p>
</div>
<div className="flex items-center gap-2">
<ArrowUpRight className="w-4 h-4 text-gray-400 group-hover:text-blue-600 transition-colors" />
</div>
</div>
</Card>
);
});
const LayoutPreview = () => {
const [tab, setTab] = useState<'custom' | 'default'>('default');
const router = useRouter();
const { templates: customTemplates, loading: customLoading } = useCustomTemplateSummaries();
useEffect(() => {
const existingScript = document.querySelector('script[src*="tailwindcss.com"]');
if (!existingScript) {
const script = document.createElement("script");
script.src = "https://cdn.tailwindcss.com";
script.async = true;
document.head.appendChild(script);
}
}, []);
const handleOpenPreview = useCallback((id: string) => router.push(`/template-preview/${id}`), [router]);
const inbuiltTemplateCards = useMemo(
() =>
templates.map((template: TemplateLayoutsWithSettings) => (
<InbuiltTemplateCard key={template.id} template={template} onOpen={handleOpenPreview} />
)),
[handleOpenPreview],
);
const customTemplateCards = useMemo(
() => customTemplates.map((template: CustomTemplates) => <CustomTemplateCard key={template.id} template={template} />),
[customTemplates],
);
return (
<div className="min-h-screen relative font-syne">
<div
className='fixed z-0 bottom-[-16.5rem] left-0 w-full h-full'
style={{
height: "341px",
borderRadius: '1440px',
background: 'radial-gradient(5.92% 104.69% at 50% 100%, rgba(122, 90, 248, 0.00) 0%, rgba(255, 255, 255, 0.00) 100%), radial-gradient(50% 50% at 50% 50%, rgba(122, 90, 248, 0.80) 0%, rgba(122, 90, 248, 0.00) 100%)',
}}
/>
<div className="sticky top-0 right-0 z-50 py-[28px] px-6 backdrop-blur ">
<div className="flex xl:flex-row flex-col gap-6 xl:gap-0 items-center justify-between">
<h3 className=" text-[28px] tracking-[-0.84px] font-unbounded font-normal text-[#101828] flex items-center gap-2">
Templates
</h3>
<div className="flex gap-2.5 max-sm:w-full max-md:justify-center max-sm:flex-wrap">
<Link
href="/custom-template"
className="inline-flex items-center font-syne font-semibold gap-2 rounded-xl px-4 py-2.5 text-black text-sm shadow-sm hover:shadow-md"
aria-label="Create new template"
style={{
borderRadius: "48px",
background: "linear-gradient(270deg, #D5CAFC 2.4%, #E3D2EB 27.88%, #F4DCD3 69.23%, #FDE4C2 100%)",
}}
>
<span className="hidden md:inline">New Template</span>
<span className="md:hidden">New</span>
<ChevronRight className="w-4 h-4" />
</Link>
</div>
</div>
</div>
<div className="l mx-auto px-6 py-8">
<div className='p-1 rounded-[40px] bg-[#ffffff] w-fit border border-[#EDEEEF] flex items-center justify-center '>
<button className='px-5 py-2 text-xs font-medium text-[#3A3A3A] rounded-[70px]'
onClick={() => setTab('custom')}
style={{
background: tab === 'custom' ? '#F4F3FF' : 'transparent',
color: tab === 'custom' ? '#5146E5' : '#3A3A3A'
}}
>Custom</button>
<svg xmlns="http://www.w3.org/2000/svg" className='mx-1' width="2" height="17" viewBox="0 0 2 17" fill="none">
<path d="M1 0V16.5" stroke="#EDECEC" strokeWidth="2" />
</svg>
<button className='px-5 py-2 text-xs font-medium text-[#3A3A3A] rounded-[70px]'
onClick={() => setTab('default')}
style={{
background: tab === 'default' ? '#F4F3FF' : 'transparent',
color: tab === 'default' ? '#5146E5' : '#3A3A3A'
}}
>Built-in</button>
</div>
{/* Inbuilt Templates Section */}
{tab === 'default' && <section className="my-12">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{inbuiltTemplateCards}
</div>
</section>}
{tab === 'custom' && <section className="my-12">
{customLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
<span className="ml-3 text-gray-600">Loading custom templates...</span>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<CreateCustomTemplate />
{customTemplateCards}
</div>
)}
</section>}
</div>
</div>
);
};
export default LayoutPreview;

View file

@ -0,0 +1,58 @@
import { Skeleton } from '@/components/ui/skeleton'
import { Card } from '@/components/ui/card'
const Loading = () => {
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto px-6 py-8">
{/* Header */}
<div className="text-center mb-8">
<Skeleton className="h-9 w-48 mx-auto mb-2" />
<Skeleton className="h-5 w-64 mx-auto" />
</div>
{/* Inbuilt Templates Section */}
<section className="mb-12">
<Skeleton className="h-6 w-40 mb-6" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{Array.from({ length: 4 }).map((_, idx) => (
<Card key={idx} className="overflow-hidden">
<div className="p-5">
<div className="flex items-center justify-between mb-2">
<Skeleton className="h-6 w-28" />
<div className="flex items-center gap-2">
<Skeleton className="h-6 w-8 rounded-full" />
<Skeleton className="h-4 w-4" />
</div>
</div>
<Skeleton className="h-4 w-full mb-1" />
<Skeleton className="h-4 w-3/4 mb-4" />
<div className="grid grid-cols-2 gap-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="aspect-video rounded" />
))}
</div>
</div>
</Card>
))}
</div>
</section>
{/* Custom Templates Section */}
<section>
<div className="flex items-center justify-between mb-6">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-10 w-44 rounded-md" />
</div>
<div className="flex items-center justify-center py-12">
<Skeleton className="h-8 w-8 rounded-full" />
<Skeleton className="h-5 w-48 ml-3" />
</div>
</section>
</div>
</div>
)
}
export default Loading

View file

@ -0,0 +1,10 @@
import React from 'react'
import TemplatePanel from './components/TemplatePanel'
const page = () => {
return (
<TemplatePanel />
)
}
export default page

View file

@ -0,0 +1,66 @@
"use client";
import React from 'react'
import { HexColorPicker, HexColorInput } from 'react-colorful'
import { ThemeColors } from './types'
interface ColorPickerComponentProps {
colorKey: keyof ThemeColors
currentColor: string
onColorChange: (colorKey: keyof ThemeColors, value: string) => void
showColorPicker: string | null
onShowColorPicker: (colorKey: string | null) => void,
label: string
}
export const ColorPickerComponent: React.FC<ColorPickerComponentProps> = ({
colorKey,
currentColor,
onColorChange,
showColorPicker,
onShowColorPicker,
label
}) => (
<div className="">
{label && <p className="text-xs text-[#38393D] font-medium pb-1.5">
{label}
</p>}
<div className="flex gap-2 border border-[#EDEEEF] rounded-md p-1">
<div
className="w-8 h-8 rounded border border-gray-300 cursor-pointer relative"
style={{ backgroundColor: currentColor }}
onClick={(e) => {
e.stopPropagation()
onShowColorPicker(showColorPicker === colorKey ? null : colorKey)
}}
>
{showColorPicker === colorKey && (
<div
className="absolute top-full left-0 z-[9999] mt-2 bg-white border border-gray-300 rounded-lg shadow-lg p-2"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<HexColorPicker
color={currentColor}
onChange={(color) => onColorChange(colorKey, color)}
/>
<div className="mt-2">
<HexColorInput
color={currentColor}
onChange={(color) => onColorChange(colorKey, color)}
className="w-full px-2 py-1 text-sm border border-gray-300 rounded outline-none "
prefixed
/>
</div>
</div>
)}
</div>
<input
className='w-full outline-none text-sm font-medium text-[#38393D]'
value={currentColor}
onChange={(e) => onColorChange(colorKey, e.target.value)}
placeholder="#000000"
/>
</div>
</div>
)

View file

@ -0,0 +1,41 @@
"use client";
import { ArrowRight, Plus, Sparkle, Sparkles } from 'lucide-react'
import { useRouter } from 'next/navigation'
import React from 'react'
const CustomTabEmpty = () => {
const router = useRouter()
return (
<div
onClick={() => {
router.push('/theme?tab=new-theme')
}}
className='w-[305px] rounded-xl border border-[#EDEEEF] cursor-pointer'>
<div className='relative h-[250px] flex justify-center items-center '>
<img src="/card_bg.svg" alt="" className="absolute top-0 z-[1] left-0 w-full h-full object-cover" />
<div className='w-[36px] h-[36px] relative z-[4] rounded-full bg-[#7A5AF8] flex items-center justify-center'
style={{
background: 'linear-gradient(90deg, #F00 5.21%, #FF8A00 16.48%, #FFE600 27.74%, #14FF00 39.35%, #00A3FF 49.37%, #0500FF 61.18%, #AD00FF 72.26%, #FF00C7 83.53%, #F00 94.61%), #FFF'
}}
><div className='w-[26px] h-[26px] rounded-full bg-white flex items-center justify-center'>
<Plus className='w-4 h-4 text-[#A2A0A1]' />
</div>
</div>
</div>
<div className='px-5 py-4 bg-white flex items-center gap-4 border-t border-[#EDEEEF]'>
<div className='bg-[#7A5AF8] w-[45px] h-[45px] rounded-lg p-2 flex items-center justify-center'>
<Sparkles className='w-6 h-6 text-white' />
</div>
<div>
<h4 className='text-[#191919] text-sm font-semibold '>Build Theme</h4>
<p className='flex text-[#808080] text-sm font-medium items-center gap-2'>From colors <ArrowRight className='w-3 h-3' /> fonts </p>
</div>
</div>
</div>
)
}
export default CustomTabEmpty

View file

@ -0,0 +1,44 @@
"use client";
import React from 'react'
import { Check } from 'lucide-react'
interface FontCardProps {
font: any
isSelected: boolean
onSelect: (fontName: string) => void
}
export const FontCard: React.FC<FontCardProps> = ({ font, isSelected, onSelect }) => (
<div
className={`relative p-3 rounded-xl cursor-pointer transition-all duration-200 group
${isSelected
? 'bg-gradient-to-br from-[#F8F7FF] to-[#F0EFFF] border border-[#7A5AF8] shadow-sm'
: 'bg-white border border-[#EDEEEF] hover:border-[#C4B5FD] hover:bg-[#FAFAFF]'
}`}
onClick={() => onSelect(font.name)}
>
<div className="flex items-center justify-between gap-2">
<div className="flex-1 min-w-0">
<p
className={`text-sm font-medium truncate ${isSelected ? 'text-[#7A5AF8]' : 'text-[#151515]'}`}
style={{ fontFamily: `"${font.name}"` }}
>
{font.displayName}
</p>
<p
className="text-[11px] text-[#A6A4A2] mt-0.5"
style={{ fontFamily: `"${font.name}"` }}
>
ABC abc 123
</p>
</div>
<div
className={`text-xl font-semibold ${isSelected ? 'text-[#7A5AF8]' : 'text-[#333] group-hover:text-[#7A5AF8]'} transition-colors`}
style={{ fontFamily: `"${font.name}"` }}
>
Aa
</div>
</div>
</div>
)

View file

@ -0,0 +1,33 @@
import React from 'react'
interface StepIndicatorProps {
currentStep: number
}
const steps = [
{ step: 1, label: 'Brand' },
{ step: 2, label: 'Palette' },
{ step: 3, label: 'Fonts' },
{ step: 4, label: 'Logo' },
]
export const StepIndicator: React.FC<StepIndicatorProps> = ({ currentStep }) => (
<div className="flex flex-col items-center gap-7 px-4 min-w-[104px] pt-8 border-r border-[#EDEEEF]">
{steps.map(({ step, label }) => {
const isActive = currentStep === step
return (
<div key={step} className="flex flex-col items-center gap-1.5 px-3 ">
<span
className={`px-2 py-0.5 rounded-full text-[9px] font-medium ${isActive
? 'bg-[#7A5AF8] text-white'
: 'bg-white text-[#404348] border border-[#EDEEEF]'
}`}
>
Step-{step}
</span>
<span className="text-[11px] font-normal text-black">{label}</span>
</div>
)
})}
</div>
)

View file

@ -0,0 +1,185 @@
"use client";
import React, { useState } from 'react'
import { AlertTriangle, Check, Copy, Trash } from 'lucide-react'
import { Theme } from '@/app/(presentation-generator)/services/api/types'
import ToolTip from '@/components/ToolTip'
interface ThemeCardProps {
theme: Theme
onSelect: (theme: Theme) => void
onDelete: (themeId: string) => void
showDeleteButton?: boolean
}
export const ThemeCard: React.FC<ThemeCardProps> = ({ theme, onSelect, onDelete, showDeleteButton = true }) => {
if (!theme.data.colors['graph_0']) { return null }
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [copied, setCopied] = useState(false)
return (<div
className={` group rounded-xl border w-[305px] cursor-pointer transition-all relative bg-white border-[#EDEEEF] hover:shadow-md`}
onClick={() => onSelect(theme)}
>
{showDeleteButton && <button
className="absolute hidden group-hover:block duration-300 transition-all -top-3 -right-3 z-10 bg-white rounded-full p-2 border border-[#EDEEEF] hover:bg-gray-100 hover:text-gray-700"
style={{ boxShadow: '0 6.6px 13.2px 0 rgba(0, 0, 0, 0.10)' }}
onClick={(e) => {
e.stopPropagation()
setShowDeleteDialog(true)
}}
>
<Trash className="h-3 w-3" />
</button>}
{/* Delete Confirmation Dialog */}
{showDeleteDialog && (
<div
className="fixed inset-0 z-50 flex items-center justify-center animate-[fadeIn_150ms_ease-out]"
onClick={(e) => {
e.stopPropagation()
setShowDeleteDialog(false)
}}
>
<div className="absolute inset-0 bg-black/40 backdrop-blur-[2px]" />
<div
className="relative bg-white rounded-2xl w-[340px] shadow-2xl animate-[scaleIn_200ms_ease-out] "
onClick={(e) => e.stopPropagation()}
>
<div className="p-6 pb-4 flex flex-col items-center text-center">
<div className="w-12 h-12 rounded-full bg-red-50 flex items-center justify-center mb-4">
<AlertTriangle className="h-6 w-6 text-red-500" />
</div>
<h3 className="text-lg font-semibold text-[#191919] mb-2">Delete Theme?</h3>
<p className="text-sm text-gray-500 leading-relaxed">
You're about to delete <span className="font-medium text-gray-700">"{theme.name}"</span>. This action cannot be undone.
</p>
</div>
<div className="flex border-t border-gray-100">
<button
onClick={() => setShowDeleteDialog(false)}
className="flex-1 px-4 py-3.5 text-sm font-medium text-gray-600 hover:bg-gray-50 transition-colors"
>
Cancel
</button>
<button
onClick={() => {
onDelete(theme.id)
setShowDeleteDialog(false)
}}
className="flex-1 px-4 py-3.5 text-sm font-medium text-red-500 hover:bg-red-50 transition-colors border-l border-gray-100"
>
Delete
</button>
</div>
</div>
</div>
)}
<div className='relative h-[250px] flex justify-center items-center '>
<img src="/card_bg.svg" alt="" className="absolute top-0 z-[1] left-0 w-[99%] h-full object-cover" />
<div className=" absolute top-0 left-0 flex items-center justify-between gap-2 z-[2] p-2">
<ToolTip content='Font' >
<p className=" text-xs font-syne flex gap-1 capitalize items-center rounded-[100px] px-2.5 py-1 bg-[#3A3A3AF5] text-white font-semibold z-40 ">
{theme.data.fonts.textFont.name}
</p>
</ToolTip>
{theme.company_name && <ToolTip content='COMPANY'>
<p className=" text-xs font-syne flex gap-1 capitalize items-center rounded-[100px] px-2.5 py-1 bg-[#3A3A3AF5] text-white font-semibold text-ellipsis overflow-hidden whitespace-nowrap z-40 ">
{theme.company_name}
</p>
</ToolTip>}
{theme.logo_url && <ToolTip content='LOGO'>
<p className=" text-xs font-syne flex gap-1 capitalize items-center rounded-[100px] px-2.5 py-1 bg-[#3A3A3AF5] text-white font-semibold z-40 ">
<img src={theme.logo_url} alt={theme.name} className="w-full max-w-6 h-4 rounded-full object-cover" />
</p>
</ToolTip>}
</div>
<div className=" relative z-[3] px-6">
<div className="w-full h-[135px]">
<div
className=" w-full h-full rounded-xl p-3 border border-black/10 "
style={{ backgroundColor: theme.data.colors['background'] }}
>
<div
className="h-[calc(100%-2px)] w-[calc(100%-2px)] mx-auto my-auto rounded-xl p-4 border border-black/10 shadow-[0_2px_6px_rgba(0,0,0,0.10)]"
style={{ backgroundColor: theme.data.colors['card'] }}
>
<div className="h-full w-full flex flex-col justify-center">
<div
className="text-[22px] font-semibold leading-[1.05] text-left truncate"
style={{ color: theme.data.colors['background_text'], fontFamily: `"${theme.data.fonts.textFont.name}", ui-serif, Georgia, serif` }}
>
{theme.name}
</div>
<div
className="mt-1 text-base font-medium leading-[1.1] text-left truncate"
style={{ color: theme.data.colors['background_text'], fontFamily: `"${theme.data.fonts.textFont.name}", ui-serif, Georgia, serif` }}
>
Choose your preferences.
</div>
<div
className="mt-2 h-2.5 w-16 rounded-full"
style={{ backgroundColor: theme.data.colors['primary'] }}
/>
</div>
</div>
</div>
</div>
</div>
</div>
<div className='px-5 border-t rounded-b-xl border-[#EDEEEF] w-full py-2.5 h-[80px] bg-white flex items-center justify-between'>
<div>
<h4 className='text-sm font-semibold text-[#191919] pb-1'>{theme.name}</h4>
<div className='flex items-center gap-1'>
<div className='w-4 h-4 rounded-full border border-[#EDEEEF] '
style={{ backgroundColor: theme.data.colors['primary'] }}
/>
<div
className='w-4 h-4 rounded-full border border-[#EDEEEF] '
style={{ backgroundColor: theme.data.colors['background'] }}
/>
</div>
</div>
<button
onClick={(e) => {
e.stopPropagation()
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(theme.id)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}}
className={copied ? "text-green-500" : "text-gray-500 hover:text-gray-700"}
title={copied ? "Copied!" : "Copy ID"}
>
{copied ? <Check className="h-5 w-5" /> : <Copy className="h-5 w-5" />}
</button>
</div>
</div>)
}

View file

@ -0,0 +1,205 @@
export const FONT_OPTIONS: any[] = [
{ name: 'Inter', displayName: 'Inter', cssUrl: 'https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap' },
{ name: 'DM Sans', displayName: 'DM Sans', cssUrl: 'https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&display=swap' },
{ name: 'Overpass', displayName: 'Overpass', cssUrl: 'https://fonts.googleapis.com/css2?family=Overpass:wght@100..900&display=swap' },
{ name: 'Barlow', displayName: 'Barlow', cssUrl: 'https://fonts.googleapis.com/css2?family=Barlow:wght@100..900&display=swap' },
{ name: 'Nunito', displayName: 'Nunito', cssUrl: 'https://fonts.googleapis.com/css2?family=Nunito:wght@200..1000&display=swap' },
{ name: 'Lora', displayName: 'Lora', cssUrl: 'https://fonts.googleapis.com/css2?family=Lora:wght@400;500;600;700&display=swap' },
{ name: 'Instrument Sans', displayName: 'Instrument Sans', cssUrl: 'https://fonts.googleapis.com/css2?family=Instrument+Sans:ital,wght@0,400..700;1,400..700&display=swap' },
{ name: 'Roboto Slab', displayName: 'Roboto Slab', cssUrl: 'https://fonts.googleapis.com/css2?family=Roboto+Slab:wght@100..900&display=swap' },
{ name: 'Montserrat', displayName: 'Montserrat', cssUrl: 'https://fonts.googleapis.com/css2?family=Montserrat:wght@100..900&display=swap' },
{ name: 'Libre Baskerville', displayName: 'Libre Baskerville', cssUrl: 'https://fonts.googleapis.com/css2?family=Libre+Baskerville:wght@400;700&display=swap' },
{ name: 'Prompt', displayName: 'Prompt', cssUrl: 'https://fonts.googleapis.com/css2?family=Prompt:wght@100..900&display=swap' },
{ name: 'Inconsolata', displayName: 'Inconsolata', cssUrl: 'https://fonts.googleapis.com/css2?family=Inconsolata:wght@200..900&display=swap' },
{ name: 'Fraunces', displayName: 'Fraunces', cssUrl: 'https://fonts.googleapis.com/css2?family=Fraunces:wght@300..900&display=swap' },
{ name: 'Gelasio', displayName: 'Gelasio', cssUrl: 'https://fonts.googleapis.com/css2?family=Gelasio:wght@300..700&display=swap' },
{ name: 'Raleway', displayName: 'Raleway', cssUrl: 'https://fonts.googleapis.com/css2?family=Raleway:wght@100..900&display=swap' },
{ name: 'Kanit', displayName: 'Kanit', cssUrl: 'https://fonts.googleapis.com/css2?family=Kanit:wght@100..900&display=swap' },
{ name: 'Corben', displayName: 'Corben', cssUrl: 'https://fonts.googleapis.com/css2?family=Corben:wght@400;700&display=swap' },
{ name: 'Poppins', displayName: 'Poppins', cssUrl: 'https://fonts.googleapis.com/css2?family=Poppins:wght@100..900&display=swap' },
{ name: 'Open Sans', displayName: 'Open Sans', cssUrl: 'https://fonts.googleapis.com/css2?family=Open+Sans:wght@300..800&display=swap' },
{ name: 'Lato', displayName: 'Lato', cssUrl: 'https://fonts.googleapis.com/css2?family=Lato:wght@100..900&display=swap' },
{ name: 'Source Sans Pro', displayName: 'Source Sans Pro', cssUrl: 'https://fonts.googleapis.com/css2?family=Source+Sans+Pro:wght@200..900&display=swap' },
{ name: 'Playfair Display', displayName: 'Playfair Display', cssUrl: 'https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400..900&display=swap' },
{ name: 'Roboto', displayName: 'Roboto', cssUrl: 'https://fonts.googleapis.com/css2?family=Roboto:wght@100..900&display=swap' }
]
export const DEFAULT_THEMES: any[] = [
{
id: "edge-yellow",
name: "Edge Yellow",
description: "Yellow and dark theme for professionalish and edge.",
logo: null,
logo_url: null,
company_name: null,
data: {
colors: {
primary: "#f5f547",
background: "#1f1f1f",
card: "#424242",
stroke: "#585858",
primary_text: "#161616",
background_text: "#f5f547",
graph_0: "#ffff54",
graph_1: "#f1f142",
graph_2: "#dada15",
graph_3: "#c1bf00",
graph_4: "#a8a600",
graph_5: "#908c00",
graph_6: "#797400",
graph_7: "#625c00",
graph_8: "#4d4500",
graph_9: "#382f00"
},
fonts: {
textFont: {
name: "Playfair Display",
url: "https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400..900&display=swap"
}
}
}
},
{
id: "light-rose",
name: "Light Rose",
description: "Rose background with punchy font",
logo: null,
logo_url: null,
company_name: null,
data: {
colors: {
"primary": "#030204",
background: "#f69c9c",
card: "#ffaeb4",
stroke: "#bf6a6b",
primary_text: "#bebebe",
background_text: "#030202",
graph_0: "#2f2c32",
graph_1: "#444147",
graph_2: "#5a565d",
graph_3: "#706d73",
graph_4: "#88848b",
graph_5: "#a09da4",
graph_6: "#b9b6bd",
graph_7: "#d3cfd6",
graph_8: "#eae6ed",
graph_9: "#f7f3fb"
},
fonts: {
textFont: {
name: "Overpass",
url: "https://fonts.googleapis.com/css2?family=Overpass:wght@100..900&display=swap"
}
}
}
},
{
id: "mint-blue",
name: "Mint Blue",
description: "Mint Greent with blue heading.",
logo: null,
logo_url: null,
company_name: null,
data: {
colors: {
primary: "#3b3172",
background: "#ffffff",
card: "#80e7cf",
stroke: "#d1d1d1",
primary_text: "#ffffff",
background_text: "#3b3172",
graph_0: "#003d2d",
graph_1: "#005341",
graph_2: "#006a57",
graph_3: "#00826d",
graph_4: "#2b9a85",
graph_5: "#4ab39d",
graph_6: "#65cdb6",
graph_7: "#80e7cf",
graph_8: "#98ffe6",
graph_9: "#a5fff4"
},
fonts: {
textFont: {
name: "Prompt",
url: "https://fonts.googleapis.com/css2?family=Prompt:wght@100..900&display=swap"
}
}
}
},
{
id: "professional-blue",
name: "Professional Blue",
description: "Clean and professional blue theme",
logo: null,
logo_url: null,
company_name: null,
data: {
colors: {
primary: "#161616",
background: "#ffffff",
card: "#dae6ff",
stroke: "#d1d1d1",
primary_text: "#eeeaea",
background_text: "#000000",
graph_0: "#2e2e2e",
graph_1: "#424242",
graph_2: "#585858",
graph_3: "#6f6f6f",
graph_4: "#868686",
graph_5: "#9e9e9e",
graph_6: "#b7b7b7",
graph_7: "#d1d1d1",
graph_8: "#e8e8e8",
graph_9: "#f5f5f5"
},
fonts: {
textFont: {
name: "Inter",
url: "https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap"
}
}
}
},
{
id: "professional-dark",
name: "Professional Dark",
description: "Clean and professional for dark corporate usage.",
logo: null,
logo_url: null,
company_name: null,
data: {
colors: {
primary: "#eff5f1",
background: "#050505",
card: "#424242",
stroke: "#585858",
primary_text: "#050505",
background_text: "#eff5f1",
graph_0: "#ebf6ff",
graph_1: "#dee8fa",
graph_2: "#c7d2e3",
graph_3: "#aeb8c9",
graph_4: "#959fb0",
graph_5: "#7d8797",
graph_6: "#666f7f",
graph_7: "#505867",
graph_8: "#3a4351",
graph_9: "#262e3c"
},
fonts: {
textFont: {
name: "Instrument Sans",
url: "https://fonts.googleapis.com/css2?family=Instrument+Sans:ital,wght@0,400..700;1,400..700&display=swap"
}
}
}
}
]

View file

@ -0,0 +1,20 @@
export interface ThemeColors {
'primary': string
'background': string
'card': string
'stroke': string
'primary_text': string
'background_text': string
'graph_0': string
'graph_1': string
'graph_2': string
'graph_3': string
'graph_4': string
'graph_5': string
'graph_6': string
'graph_7': string
'graph_8': string
'graph_9': string
}

View file

@ -0,0 +1,10 @@
export const loadGoogleFont = (fontFamily: string) => {
// Check if font is already loaded
const existingLink = document.querySelector(`link[href*="${fontFamily.replace(' ', '+')}"]`)
if (existingLink) return
const link = document.createElement('link')
link.href = `https://fonts.googleapis.com/css2?family=${fontFamily.replace(' ', '+')}:wght@300;400;500;600;700&display=swap`
link.rel = 'stylesheet'
document.head.appendChild(link)
}

View file

@ -0,0 +1,54 @@
import { Skeleton } from '@/components/ui/skeleton'
const ThemeCardSkeleton = () => (
<div className="rounded-xl px-6 border border-[#EDEEEF] w-[305px] bg-white overflow-hidden">
{/* Preview area */}
<div className="relative h-[250px] p-6">
{/* Top badges */}
<div className="absolute top-2 left-2 flex items-center gap-2 z-10">
<Skeleton className="h-6 w-16 rounded-full" />
<Skeleton className="h-6 w-20 rounded-full" />
</div>
{/* Card preview */}
<div className="h-full flex items-center justify-center">
<div className="w-full h-[135px] rounded-xl overflow-hidden">
<Skeleton className="w-full h-full" />
</div>
</div>
</div>
{/* Bottom info */}
<div className="px-5 border-t border-[#EDEEEF] py-2.5 h-[80px] flex items-center justify-between">
<div>
<Skeleton className="h-4 w-24 mb-2" />
<div className="flex items-center gap-1">
<Skeleton className="w-4 h-4 rounded-full" />
<Skeleton className="w-4 h-4 rounded-full" />
</div>
</div>
<Skeleton className="h-5 w-5" />
</div>
</div>
)
const Loading = () => {
return (
<div className="space-y-6">
{/* Tabs skeleton */}
<div className="p-1 rounded-[40px] bg-[#F7F6F9] w-fit border border-[#F4F4F4] flex items-center justify-center">
<Skeleton className="h-8 w-20 rounded-[70px]" />
<div className="mx-1 w-[2px] h-[17px] bg-[#EDECEC]" />
<Skeleton className="h-8 w-20 rounded-[70px]" />
</div>
{/* Theme cards grid */}
<div className="flex flex-wrap gap-6">
{Array.from({ length: 4 }).map((_, idx) => (
<ThemeCardSkeleton key={idx} />
))}
</div>
</div>
)
}
export default Loading

View file

@ -0,0 +1,9 @@
import React from 'react'
import ThemePanel from './components/ThemePanel'
const page = () => {
return (
<ThemePanel />
)
}
export default page

View file

@ -18,7 +18,7 @@ const HeaderNav = () => {
<Link
href="/dashboard"
prefetch={false}
className="flex items-center gap-2 px-3 py-2 text-white hover:bg-primary/80 rounded-md transition-colors outline-none"
className="flex items-center gap-2 px-3 py-2 text-[#101323] rounded-md transition-colors outline-none"
role="menuitem"
onClick={() => trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/dashboard" })}
>
@ -31,7 +31,7 @@ const HeaderNav = () => {
<Link
href="/settings"
prefetch={false}
className="flex items-center gap-2 px-3 py-2 text-white hover:bg-primary/80 rounded-md transition-colors outline-none"
className="flex items-center gap-2 px-3 py-2 text-[#101323] rounded-md transition-colors outline-none"
role="menuitem"
onClick={() => trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/settings" })}
>

View file

@ -357,7 +357,9 @@ const ImageEditor = ({
<div className="grid grid-cols-2 gap-4 ">
{previousGeneratedImages.map((image) => (
<div
onClick={() => handleImageChange(image.file_url || image.path)}
onClick={() =>
handleImageChange(image.file_url || image.path)
}
key={image.id}
className="aspect-[4/3] w-full overflow-hidden rounded-lg border cursor-pointer hover:border-blue-500 transition-colors"
>
@ -483,7 +485,7 @@ const ImageEditor = ({
handleDeleteImage(image.id)
}}/>
<img
src={image.file_url || image.path}
src={image.file_url || image.path}
alt="Uploaded preview"
className="w-full h-full object-cover group-hover:scale-105 transition-transform"
/>

View file

@ -1,3 +1,4 @@
import { useEffect } from "react"
import { useEditor, EditorContent } from "@tiptap/react"
import StarterKit from "@tiptap/starter-kit"
import { Markdown } from "tiptap-markdown"
@ -19,12 +20,14 @@ export default function MarkdownEditor({ content, onChange }: { content: string;
immediatelyRender: false,
});
// Update editor content when the content prop changes (for streaming)
// useEffect(() => {
// if (editor && content !== editor.storage.markdown.getMarkdown()) {
// editor.commands.setContent(content);
// }
// }, [content, editor]);
// Keep editor state in sync when parent changes content (e.g. reorder)
useEffect(() => {
if (!editor) return;
const currentMarkdown = editor.storage.markdown.getMarkdown();
if (content !== currentMarkdown) {
editor.commands.setContent(content, false);
}
}, [content, editor]);
return (
<div className="relative">

View file

@ -86,7 +86,7 @@ export const V1ContentRender = ({ slide, isEditMode, theme }: { slide: any, isEd
if (isEditMode) {
return (
<SlideErrorBoundary label={`Slide ${slide.index + 1}`}>
<div ref={containerRef} className={`w-full h-full `}>
<div ref={containerRef} className={`w-full h-full border border-[#EDEEEF] `}>
<EditableLayoutWrapper
slideIndex={slide.index}

View file

@ -1,5 +1,5 @@
import React from "react";
import Header from "@/app/(presentation-generator)/dashboard/components/Header";
import Header from "@/app/(presentation-generator)/(dashboard)/dashboard/components/Header";
export const APIKeyWarning: React.FC = () => {
return (
@ -8,7 +8,7 @@ export const APIKeyWarning: React.FC = () => {
<div className="flex items-center justify-center aspect-video mx-auto px-6">
<div className="text-center space-y-2 my-6 bg-white p-10 rounded-lg shadow-lg">
<h1 className="text-xl font-bold text-gray-900">
Please add "GOOGLE_API_KEY" to enable template creation via AI.
Please add "GOOGLE_API_KEY" to enable template creation via AI.
</h1>
<h1 className="text-xl font-bold text-gray-900">Please add your OpenAI API Key to process the layout</h1>
<p className="text-lg text-gray-600 max-w-2xl mx-auto">

View file

@ -1,6 +1,6 @@
import React from "react";
import { Loader2 } from "lucide-react";
import Header from "@/app/(presentation-generator)/dashboard/components/Header";
import Header from "@/app/(presentation-generator)/(dashboard)/dashboard/components/Header";
interface LoadingSpinnerProps {
message: string;

View file

@ -83,7 +83,7 @@ export const useFontManagement = () => {
const formData = new FormData();
formData.append("font_file", file);
const response = await fetch(getApiUrl("api/v1/ppt/fonts/upload"), {
const response = await fetch(getApiUrl("/api/v1/ppt/fonts/upload"), {
method: "POST",
body: formData,
});

View file

@ -36,7 +36,7 @@ export const useLayoutSaving = (
while (retryCount < maxRetries) {
try {
const response = await fetch(getApiUrl("api/v1/ppt/html-to-react/"), {
const response = await fetch(getApiUrl("/api/v1/ppt/html-to-react/"), {
method: "POST",
headers: {
"Content-Type": "application/json",
@ -137,7 +137,7 @@ export const useLayoutSaving = (
}
// First create/update the template metadata
await fetch(getApiUrl("api/v1/ppt/template-management/templates"), {
await fetch(getApiUrl("/api/v1/ppt/template-management/templates"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: presentationId, name: layoutName, description }),

View file

@ -143,7 +143,7 @@ export const useSlideEdit = (
formData.append("html", currentHtml);
formData.append("prompt", prompt);
const response = await fetch(getApiUrl("api/v1/ppt/html-edit/"), {
const response = await fetch(getApiUrl("/api/v1/ppt/html-edit/"), {
method: "POST",
body: formData,
});

View file

@ -28,7 +28,7 @@ export const useSlideProcessing = (
);
try {
const htmlResponse = await fetch(getApiUrl("api/v1/ppt/slide-to-html/"), {
const htmlResponse = await fetch(getApiUrl("/api/v1/ppt/slide-to-html/"), {
method: "POST",
headers: {
"Content-Type": "application/json",
@ -134,7 +134,7 @@ export const useSlideProcessing = (
let slidesResponseData: any = null;
if (isPdf) {
formData.append("pdf_file", selectedFile);
const pdfResponse = await fetch(getApiUrl("api/v1/ppt/pdf-slides/process"), {
const pdfResponse = await fetch(getApiUrl("/api/v1/ppt/pdf-slides/process"), {
method: "POST",
body: formData,
});
@ -144,7 +144,7 @@ export const useSlideProcessing = (
);
} else if (isPptx) {
formData.append("pptx_file", selectedFile);
const pptxResponse = await fetch(getApiUrl("api/v1/ppt/pptx-slides/process"), {
const pptxResponse = await fetch(getApiUrl("/api/v1/ppt/pptx-slides/process"), {
method: "POST",
body: formData,
});

View file

@ -2,7 +2,7 @@
import React, { useEffect } from "react";
import FontManager from "./components/FontManager";
import Header from "../dashboard/components/Header";
import Header from "../(dashboard)/dashboard/components/Header";
import { useCustomLayout } from "./hooks/useCustomLayout";
import { useFontManagement } from "./hooks/useFontManagement";

View file

@ -21,8 +21,6 @@ export interface ProcessedSlide extends SlideData {
error?: string;
modified?: boolean;
convertingToReact?: boolean; // indicates HTML-to-React conversion in progress
react?: string; // React component code
layout_name?: string; // Layout name
}
export interface FontData {

View file

@ -0,0 +1,36 @@
export const useFontLoader = (fonts: Record<string, string>) => {
const injectFonts = () => {
if (typeof document === 'undefined' || !fonts || typeof fonts !== 'object') return;
const ensureStylesheetLink = (href: string) => {
const existing = document.querySelector(`link[rel="stylesheet"][data-font-url="${href}"]`);
if (existing) return;
const link = document.createElement('link');
link.rel = 'stylesheet';
link.setAttribute('data-font-url', href);
link.href = href;
document.head.appendChild(link);
};
const ensureFontFaceStyle = (name: string, srcUrl: string) => {
const existing = document.querySelector(`style[data-font-url="${srcUrl}"]`);
if (existing) return;
const styleEl = document.createElement('style');
styleEl.setAttribute('data-font-url', srcUrl);
styleEl.textContent = `@font-face {\n font-family: '${name}';\n src: url('${srcUrl}');\n font-style: normal;\n font-display: swap;\n}`;
document.head.appendChild(styleEl);
};
Object.entries(fonts).forEach(([name, url]) => {
if (!name || !url) return;
const isCss = /\.css(\?|$)/i.test(url) || /fonts\.googleapis\.com/.test(url);
if (isCss) {
ensureStylesheetLink(url);
} else {
ensureFontFaceStyle(name, url);
}
});
};
injectFonts();
};

View file

@ -28,26 +28,21 @@ LayoutPreview.displayName = 'LayoutPreview';
export const CustomTemplateCard = memo(({ template, onSelectTemplate, selectedTemplate }: { template: CustomTemplates, onSelectTemplate: (template: string) => void, selectedTemplate: string | null }) => {
const { previewLayouts, loading: customLoading } = useCustomTemplatePreview(template.id);
const { previewLayouts, loading: customLoading, totalLayouts } = useCustomTemplatePreview(template.id);
const isSelected = selectedTemplate === template.id;
return (
<Card
className={`${isSelected ? 'border-2 border-blue-500' : ''} cursor-pointer hover:shadow-lg transition-all duration-200 group overflow-hidden relative`}
style={{ contain: 'layout style paint' }}
onClick={() => {
onSelectTemplate(template.id);
}}
className={`${isSelected ? 'border-2 border-blue-500' : ''} font-syne cursor-pointer flex flex-col justify-between relative hover:shadow-lg transition-all duration-200 group overflow-hidden`}
onClick={() => onSelectTemplate(template.id)}
>
<img src="/card_bg.svg" alt="" className="absolute top-0 left-0 w-full h-full object-cover" />
<span 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">
Layouts- {totalLayouts}
</span>
<div className="p-5">
<div className="flex items-center justify-between mb-2">
<h3 className="text-xl font-bold text-gray-900">
{template.name}
</h3>
</div>
{/* Layout previews */}
<div className="grid grid-cols-2 gap-2">
@ -61,36 +56,37 @@ export const CustomTemplateCard = memo(({ template, onSelectTemplate, selectedTe
<Loader2 className="w-4 h-4 text-purple-300 animate-spin" />
</div>
))
) : previewLayouts && previewLayouts?.length > 0 ? (
// Actual layout previews - using memoized component
previewLayouts?.slice(0, 4).map((layout: CompiledLayout, index: number) => (
<LayoutPreview
key={`${template.id}-preview-${index}`}
layout={layout}
templateId={template.id}
index={index}
/>
))
) : (
// Empty state placeholders
[...Array(Math.min(4, template.layoutCount))].map((_, index) => (
<div
key={`${template.id}-empty-${index}`}
className="relative bg-gray-100 border border-gray-200 overflow-hidden aspect-video rounded flex items-center justify-center"
>
<span className="text-xs text-gray-400">No preview</span>
</div>
))
) : previewLayouts.length > 0 && (
// Actual layout previews
previewLayouts.slice(0, 4).map((layout: CompiledLayout, index: number) => {
const LayoutComponent = layout.component;
return (
<div
key={`${template.id}-preview-${index}`}
className="relative bg-gray-100 border border-gray-200 overflow-hidden aspect-video rounded"
>
<div className="absolute inset-0 bg-transparent z-10" />
<div
className="transform scale-[0.12] origin-top-left"
style={{ width: "833.33%", height: "833.33%" }}
>
<LayoutComponent data={layout.sampleData} />
</div>
</div>
);
})
)}
</div>
</div>
{isSelected && (
<div className="absolute top-0 right-0 bg-blue-500 text-white px-2 py-1 rounded-bl-lg">
Selected
</div>
)}
<div className="flex items-center justify-between p-5 bg-white border-t border-[#EDEEEF] relative z-40 ">
<h3 className="text-sm font-bold text-gray-900">
{template.name}
</h3>
</div>
</Card>
);
});

View file

@ -8,8 +8,8 @@ const EmptyStateView: React.FC = () => {
const router = useRouter();
return (
<Wrapper>
<div className="max-w-[800px] h-[calc(100vh-72px)] flex justify-center items-center mx-auto px-4 sm:px-6 pb-6">
<Wrapper className="bg-white">
<div className="max-w-[800px] h-[calc(100vh-72px)] font-syne flex justify-center items-center mx-auto px-4 sm:px-6 pb-6">
<div className="text-center space-y-8">
{/* Icon */}
<div className="flex justify-center">

View file

@ -4,6 +4,7 @@ import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
import { Button } from "@/components/ui/button";
import { LoadingState, Template } from "../types/index";
import { TemplateLayoutsWithSettings } from "@/app/presentation-templates/utils";
import { ChevronRight } from "lucide-react";
interface GenerateButtonProps {
loadingState: LoadingState;
@ -50,34 +51,14 @@ const GenerateButton: React.FC<GenerateButtonProps> = ({
}
onSubmit();
}}
className="bg-[#5146E5] w-full rounded-lg text-base sm:text-lg py-4 sm:py-6 font-instrument_sans font-semibold hover:bg-[#5146E5]/80 text-white disabled:opacity-50 disabled:cursor-not-allowed"
className=" w-full flex items-center gap-0.5 rounded-[58px] text-sm py-3 px-5 font-instrument_sans font-semibold text-[#101323] disabled:opacity-50 disabled:cursor-not-allowed font-syne"
style={{
background: "linear-gradient(270deg, #D5CAFC 2.4%, #E3D2EB 27.88%, #F4DCD3 69.23%, #FDE4C2 100%)",
}}
>
<svg
className="mr-2"
width="24"
height="24"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 25 25"
fill="none"
>
<g clipPath="url(#clip0_1960_939)">
<path
d="M21.217 9.57008L21.463 9.00408C21.8955 8.0028 22.6876 7.2 23.683 6.75408L24.442 6.41508C24.5341 6.37272 24.6121 6.30485 24.6668 6.21951C24.7214 6.13417 24.7505 6.03494 24.7505 5.93358C24.7505 5.83222 24.7214 5.73299 24.6668 5.64765C24.6121 5.56231 24.5341 5.49444 24.442 5.45208L23.725 5.13308C22.7046 4.67446 21.8989 3.84196 21.474 2.80708L21.221 2.19608C21.1838 2.10144 21.119 2.02018 21.035 1.96291C20.951 1.90563 20.8517 1.875 20.75 1.875C20.6483 1.875 20.549 1.90563 20.465 1.96291C20.381 2.02018 20.3162 2.10144 20.279 2.19608L20.026 2.80608C19.6015 3.84116 18.7962 4.67401 17.776 5.13308L17.058 5.45308C16.9662 5.49556 16.8885 5.56342 16.834 5.64865C16.7795 5.73389 16.7506 5.83293 16.7506 5.93408C16.7506 6.03523 16.7795 6.13428 16.834 6.21951C16.8885 6.30474 16.9662 6.3726 17.058 6.41508L17.818 6.75308C18.8132 7.19945 19.6049 8.00261 20.037 9.00408L20.283 9.57008C20.463 9.98408 21.036 9.98408 21.217 9.57008ZM6.55 16.8761H8.704L9.304 15.3761H12.196L12.796 16.8761H14.95L11.75 8.87608H9.75L6.55 16.8761ZM10.75 11.7611L11.396 13.3761H10.104L10.75 11.7611ZM15.75 16.8761V8.87608H17.75V16.8761H15.75ZM3.75 3.87608C3.48478 3.87608 3.23043 3.98144 3.04289 4.16897C2.85536 4.35651 2.75 4.61086 2.75 4.87608V20.8761C2.75 21.1413 2.85536 21.3957 3.04289 21.5832C3.23043 21.7707 3.48478 21.8761 3.75 21.8761H21.75C22.0152 21.8761 22.2696 21.7707 22.4571 21.5832C22.6446 21.3957 22.75 21.1413 22.75 20.8761V11.8761H20.75V19.8761H4.75V5.87608H14.75V3.87608H3.75Z"
fill="white"
/>
</g>
<defs>
<clipPath id="clip0_1960_939">
<rect
width="24"
height="24"
fill="white"
transform="translate(0.75 0.876953)"
/>
</clipPath>
</defs>
</svg>
{getButtonText()}
<ChevronRight className="w-4 h-4" />
</Button>
);
};

View file

@ -48,7 +48,7 @@ const OutlineContent: React.FC<OutlineContentProps> = ({
const pathname = usePathname();
return (
<div className="space-y-6 font-instrument_sans">
<div className="space-y-6 font-syne ">
{isLoading && (!outlines || outlines.length === 0) && (
<div className="flex items-center justify-center">
<span className="inline-flex items-center gap-1 rounded-full border border-blue-200 bg-blue-50 text-blue-600 px-2 py-0.5 text-xs">
@ -70,7 +70,7 @@ const OutlineContent: React.FC<OutlineContentProps> = ({
</div> */}
{/* Skeleton loading state */}
{isLoading && (
<div className="space-y-4">
<div className="space-y-4 bg-white">
{[...Array(6)].map((_, index) => (
<div key={index} className="animate-pulse">
<div className="flex items-start space-x-3 p-4 border rounded-lg bg-white">
@ -91,41 +91,30 @@ const OutlineContent: React.FC<OutlineContentProps> = ({
)}
{/* Outlines content */}
{outlines && outlines.length > 0 && (
<div>
<div className="bg-[#F9F8F8] min-h-[calc(100vh-16rem)] p-7 relative z-20 rounded-[20px] overflow-y-auto custom_scrollbar">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={onDragEnd}
>
{isStreaming ? (
outlines.map((item, index) => (
<OutlineItem
key={`slide-${index}`}
index={index + 1}
slideOutline={item}
isStreaming={isStreaming}
isActiveStreaming={activeSlideIndex === index}
isStableStreaming={highestActiveIndex >= 0 && index < highestActiveIndex}
/>
))
) :
<SortableContext
items={outlines?.map((item, index) => ({ id: `slide-${index}` })) || []}
<SortableContext
items={outlines.map((_, index) => `slide-${index}`)}
strategy={verticalListSortingStrategy}
>
{outlines?.map((item, index) => (
{outlines.map((item, index) => (
<OutlineItem
key={`slide-${index}`}
sortableId={`slide-${index}`}
index={index + 1}
slideOutline={item}
isStreaming={isStreaming}
isActiveStreaming={false}
isStableStreaming={false}
isActiveStreaming={activeSlideIndex === index}
isStableStreaming={highestActiveIndex >= 0 && index < highestActiveIndex}
/>
))}
</SortableContext>}
</SortableContext>
</DndContext>
<Button

View file

@ -1,6 +1,6 @@
import { useSortable } from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { Trash2 } from "lucide-react"
import { GripHorizontal, Trash, Trash2 } from "lucide-react"
import { RootState } from "@/store/store"
import { useDispatch, useSelector } from "react-redux"
import { deleteSlideOutline, setOutlines } from "@/store/slices/presentationGeneration"
@ -11,6 +11,7 @@ import { marked } from "marked"
interface OutlineItemProps {
sortableId: string
slideOutline: {
content: string,
},
@ -21,6 +22,7 @@ interface OutlineItemProps {
}
export function OutlineItem({
sortableId,
index,
slideOutline,
isStreaming,
@ -45,7 +47,7 @@ export function OutlineItem({
}
}, [outlines.length]);
const handleSlideChange = (newOutline:any) => {
const handleSlideChange = (newOutline: any) => {
if (isStreaming) return;
const newData = outlines?.map((each, idx) => {
if (idx === index - 1) {
@ -69,7 +71,7 @@ export function OutlineItem({
transform,
transition,
isDragging,
} = useSortable({ id: index })
} = useSortable({ id: sortableId, disabled: isStreaming })
const style = {
transform: CSS.Transform.toString(transform),
@ -117,30 +119,34 @@ export function OutlineItem({
}, [isStreaming, isActiveStreaming, isStableStreaming, slideOutline.content])
return (
<div className="mb-2">
{/* Main Title Row */}
<div
ref={setNodeRef}
style={style}
className={`mb-4 bg-white rounded-[12px] group shadow-sm p-10 relative font-syne transition-all duration-500 hover:shadow-[0_6.6px_13.2px_0_rgba(0,0,0,0.10)] ${isDragging ? "opacity-50" : ""}`}
>
<div
ref={setNodeRef}
style={style}
className={`flex items-start gap-2 md:gap-4 p-2 sm:pr-4 border border-black/10 bg-purple-100/10 rounded-[8px] ${isDragging ? "opacity-50" : ""}`}
className="flex items-start gap-3 md:gap-4 rounded-[8px]"
>
{/* Drag Handle with Number - Make it smaller on mobile */}
<div
{...attributes}
{...listeners}
className="min-w-8 sm:min-w-10 w-10 sm:w-14 h-10 sm:h-14 bg-blue-400/10 rounded-[8px] flex items-center justify-center relative cursor-grab"
className=" flex items-center justify-center relative cursor-grab"
>
<div className="grid grid-cols-2 gap-[2px]">
<div className="w-[3px] h-[3px] bg-black/80 rounded-full" />
<div className="w-[3px] h-[3px] bg-black/80 rounded-full" />
<div className="w-[3px] h-[3px] bg-black/80 rounded-full" />
<div className="w-[3px] h-[3px] bg-black/80 rounded-full" />
</div>
<span className="text-black/80 text-md sm:text-lg font-medium ml-1">{index}</span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
<path d="M12 10C12.5523 10 13 9.55228 13 9C13 8.44772 12.5523 8 12 8C11.4477 8 11 8.44772 11 9C11 9.55228 11.4477 10 12 10Z" fill="#191919" stroke="#191919" strokeWidth="0.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M19 10C19.5523 10 20 9.55228 20 9C20 8.44772 19.5523 8 19 8C18.4477 8 18 8.44772 18 9C18 9.55228 18.4477 10 19 10Z" fill="#191919" stroke="#191919" strokeWidth="0.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M5 10C5.55228 10 6 9.55228 6 9C6 8.44772 5.55228 8 5 8C4.44772 8 4 8.44772 4 9C4 9.55228 4.44772 10 5 10Z" fill="#191919" stroke="#191919" strokeWidth="0.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M12 16C12.5523 16 13 15.5523 13 15C13 14.4477 12.5523 14 12 14C11.4477 14 11 14.4477 11 15C11 15.5523 11.4477 16 12 16Z" fill="#191919" stroke="#191919" strokeWidth="0.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M19 16C19.5523 16 20 15.5523 20 15C20 14.4477 19.5523 14 19 14C18.4477 14 18 14.4477 18 15C18 15.5523 18.4477 16 19 16Z" fill="#191919" stroke="#191919" strokeWidth="0.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M5 16C5.55228 16 6 15.5523 6 15C6 14.4477 5.55228 14 5 14C4.44772 14 4 14.4477 4 15C4 15.5523 4.44772 16 5 16Z" fill="#191919" stroke="#191919" strokeWidth="0.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
{/* Main Title Input - Add onFocus handler */}
<div id={`outline-item-${index}`} className="flex flex-col basis-full gap-2">
<p className="text-black w-fit text-[10px] font-medium bg-white border border-[#EDEEEF] rounded-[80px] px-2.5">slide {index}</p>
{/* Editable Markdown Content */}
{isStreaming ? (
isActiveStreaming ? (
@ -166,15 +172,15 @@ export function OutlineItem({
</div>
{/* Action Buttons */}
<div className="flex gap-1 sm:gap-2 items-center">
<div className="hidden group-hover:flex absolute -top-3 -right-3 gap-1 sm:gap-2 items-center">
<ToolTip content="Delete Slide">
<button
onClick={handleSlideDelete}
className="p-1.5 sm:p-2 bg-gray-200/50 hover:bg-gray-200 rounded-lg transition-colors"
className="p-1.5 sm:p-2 bg-white shadow-md rounded-full transition-colors"
>
<Trash2 className="w-4 h-4 sm:w-5 sm:h-5 text-black/70" />
<Trash className="w-4 h-4 text-black/70" />
</button>
</ToolTip>
</div>

View file

@ -16,6 +16,7 @@ import { useOutlineManagement } from "../hooks/useOutlineManagement";
import { usePresentationGeneration } from "../hooks/usePresentationGeneration";
import TemplateSelection from "./TemplateSelection";
import { TemplateLayoutsWithSettings } from "@/app/presentation-templates/utils";
import { Separator } from "@/components/ui/separator";
const OutlinePage: React.FC = () => {
const { presentation_id, outlines } = useSelector(
@ -39,7 +40,8 @@ const OutlinePage: React.FC = () => {
return (
<div className="h-[calc(100vh-72px)]">
<div className=" font-syne pb-9">
<OverlayLoader
show={loadingState.isLoading}
text={loadingState.message}
@ -47,16 +49,27 @@ const OutlinePage: React.FC = () => {
duration={loadingState.duration}
/>
<Wrapper className="h-full flex flex-col w-full">
<div className="flex-grow overflow-y-hidden w-[1200px] mx-auto">
<Wrapper className="h-full flex flex-col w-full relative px-5 sm:px-10 lg:px-20 ">
<div className="flex-grow w-full hidden-scrollbar mx-auto ">
<Tabs value={activeTab} onValueChange={setActiveTab} className="h-full flex flex-col">
<TabsList className="grid w-[50%] mx-auto my-4 grid-cols-2">
<TabsTrigger value={TABS.OUTLINE}>Outline & Content</TabsTrigger>
<TabsTrigger value={TABS.LAYOUTS}>Select Template</TabsTrigger>
<TabsList className="my-4 h-auto w-fit rounded-full border border-[#EDEEEF] bg-white p-1.5">
<TabsTrigger
value={TABS.OUTLINE}
className="rounded-full px-5 py-2 text-xs font-medium text-[#2D2D2D] shadow-none data-[state=active]:bg-[#F4F3FF] data-[state=active]:text-[#7E3AF2] data-[state=active]:shadow-none"
>
Outline & Content
</TabsTrigger>
<Separator orientation="vertical" className="h-6 mx-1" />
<TabsTrigger
value={TABS.LAYOUTS}
className="relative rounded-full px-5 py-2 text-xs font-medium text-[#2D2D2D] shadow-none data-[state=active]:bg-[#F4F3FF] data-[state=active]:text-[#7E3AF2] data-[state=active]:shadow-none"
>
Select Template
</TabsTrigger>
</TabsList>
<div className="flex-grow w-full mx-auto">
<TabsContent value={TABS.OUTLINE} className="h-[calc(100vh-16rem)] overflow-y-auto custom_scrollbar"
<TabsContent value={TABS.OUTLINE} className="h-[calc(100vh-15rem)] overflow-y-auto hide-scrollbar"
>
<div>
<OutlineContent
@ -71,7 +84,7 @@ const OutlinePage: React.FC = () => {
</div>
</TabsContent>
<TabsContent value={TABS.LAYOUTS} className="h-[calc(100vh-16rem)] overflow-y-auto custom_scrollbar">
<TabsContent value={TABS.LAYOUTS} className="h-[calc(100vh-16rem)] bg-white overflow-y-auto hide-scrollbar">
<div>
<TemplateSelection
selectedTemplate={selectedTemplate}
@ -81,11 +94,9 @@ const OutlinePage: React.FC = () => {
</TabsContent>
</div>
</Tabs>
</div>
{/* Fixed Button */}
{/* Fixed Button */}
<div className="py-4 border-t border-gray-200">
<div className="max-w-[1200px] mx-auto">
<div className="absolute bottom-[26px] right-[26px] z-50">
<GenerateButton
outlineCount={outlines.length}
loadingState={loadingState}
@ -95,6 +106,9 @@ const OutlinePage: React.FC = () => {
/>
</div>
</div>
</Wrapper>
</div>
);

View file

@ -1,5 +1,5 @@
"use client";
import React, { useEffect } from "react";
import React, { useEffect, useMemo, useCallback, memo } from "react";
import { TemplateLayoutsWithSettings } from "@/app/presentation-templates/utils";
import { templates } from "@/app/presentation-templates";
@ -8,19 +8,87 @@ import { TemplateWithData } from "@/app/presentation-templates/utils";
import { CustomTemplates, useCustomTemplateSummaries } from "@/app/hooks/useCustomTemplates";
import { Loader2 } from "lucide-react";
import { CustomTemplateCard } from "./CustomTemplateCard";
import CreateCustomTemplate from "../../(dashboard)/templates/components/CreateCustomTemplate";
// Memoized layout preview for built-in templates
const BuiltInLayoutPreview = memo(({ layout, templateId, index }: {
layout: TemplateWithData;
templateId: string;
index: number;
}) => {
const LayoutComponent = layout.component;
return (
<div
className="relative bg-gray-100 font-syne border border-gray-200 overflow-hidden aspect-video rounded"
style={{ contain: 'layout style paint' }}
>
<div className="absolute inset-0 bg-transparent z-10" />
<div
className="transform scale-[0.12] origin-top-left"
style={{ width: "833.33%", height: "833.33%" }}
>
<LayoutComponent data={layout.sampleData} />
</div>
</div>
);
});
BuiltInLayoutPreview.displayName = 'BuiltInLayoutPreview';
// Memoized built-in template card
const BuiltInTemplateCard = memo(({ template, isSelected, onSelect }: {
template: TemplateLayoutsWithSettings;
isSelected: boolean;
onSelect: (template: TemplateLayoutsWithSettings) => void;
}) => {
const previewLayouts = useMemo(() => template.layouts.slice(0, 4), [template.layouts]);
const handleClick = useCallback(() => onSelect(template), [onSelect, template]);
return (
<Card
className={`${isSelected ? 'border-2 border-blue-500' : ''} cursor-pointer relative hover:shadow-lg transition-all duration-200 group overflow-hidden`}
onClick={handleClick}
>
<span 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">
Layouts- {template.layouts.length}
</span>
<img src="/card_bg.svg" alt="" className="absolute top-0 left-0 w-full h-full object-cover" />
<div className="p-5">
<div className="grid grid-cols-2 gap-2">
{previewLayouts.map((layout: TemplateWithData, index: number) => (
<BuiltInLayoutPreview
key={`${template.id}-preview-${index}`}
layout={layout}
templateId={template.id}
index={index}
/>
))}
</div>
</div>
<div className="flex items-center justify-between p-5 bg-white border-t border-[#EDEEEF] relative z-40">
<div>
<h3 className="text-sm font-bold text-gray-900 capitalize font-syne">
{template.name}
</h3>
<p className="text-xs text-gray-600 line-clamp-2 font-syne">
{template.description}
</p>
</div>
</div>
</Card>
);
});
BuiltInTemplateCard.displayName = 'BuiltInTemplateCard';
interface TemplateSelectionProps {
selectedTemplate: (TemplateLayoutsWithSettings | string) | null;
onSelectTemplate: (template: TemplateLayoutsWithSettings | string) => void;
}
const TemplateSelection: React.FC<TemplateSelectionProps> = ({
const TemplateSelection: React.FC<TemplateSelectionProps> = memo(({
selectedTemplate,
onSelectTemplate
}) => {
useEffect(() => {
const existingScript = document.querySelector(
'script[src*="tailwindcss.com"]'
);
@ -30,107 +98,99 @@ const TemplateSelection: React.FC<TemplateSelectionProps> = ({
script.async = true;
document.head.appendChild(script);
}
}, []);
const { templates: customTemplates, loading: customLoading } = useCustomTemplateSummaries();
// Stable callback for custom template selection
const handleCustomSelect = useCallback(
(template: TemplateLayoutsWithSettings | string) => onSelectTemplate(template),
[onSelectTemplate]
);
// Stable callback for built-in template selection
const handleBuiltInSelect = useCallback(
(template: TemplateLayoutsWithSettings) => onSelectTemplate(template),
[onSelectTemplate]
);
// Derive the selected custom template id only when selectedTemplate changes
const selectedCustomId = useMemo(
() => (typeof selectedTemplate === 'string' ? selectedTemplate : null),
[selectedTemplate]
);
// Derive the selected built-in template id only when selectedTemplate changes
const selectedBuiltInId = useMemo(
() => (typeof selectedTemplate !== 'string' ? selectedTemplate?.id ?? null : null),
[selectedTemplate]
);
// Memoize the custom templates section
const customTemplateCards = useMemo(() => {
if (customLoading) {
return (
<div className="flex items-center justify-center py-12 font-syne">
<Loader2 className="w-8 h-8 animate-spin text-blue-600 font-syne" />
<span className="ml-3 text-gray-600">Loading custom templates...</span>
</div>
);
}
if (customTemplates.length === 0) {
return (
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<CreateCustomTemplate />
</div>
);
}
return (
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
{customTemplates.map((template: CustomTemplates) => (
<CustomTemplateCard
key={template.id}
template={template}
onSelectTemplate={handleCustomSelect}
selectedTemplate={selectedCustomId}
/>
))}
</div>
);
}, [customLoading, customTemplates, handleCustomSelect, selectedCustomId]);
// Memoize the built-in templates list
const builtInTemplateCards = useMemo(
() =>
templates.map((template: TemplateLayoutsWithSettings) => (
<BuiltInTemplateCard
key={template.id}
template={template}
isSelected={selectedBuiltInId === template.id}
onSelect={handleBuiltInSelect}
/>
)),
[selectedBuiltInId, handleBuiltInSelect]
);
return (
<div className="space-y-8 mb-4">
{/* In Built Templates */}
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-3">In Built Templates</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{templates.map((template: TemplateLayoutsWithSettings) => {
const previewLayouts = template.layouts.slice(0, 4);
return (
<Card
key={template.id}
className={`${typeof selectedTemplate !== 'string' && selectedTemplate?.id === template.id ? 'border-2 border-blue-500' : ''} cursor-pointer hover:shadow-lg transition-all duration-200 group overflow-hidden relative`}
onClick={() => onSelectTemplate(template)}
>
<div className="p-5">
<div className="flex items-center justify-between mb-2">
<h3 className="text-xl font-bold text-gray-900 capitalize">
{template.name}
</h3>
</div>
<p className="text-sm text-gray-600 mb-4 line-clamp-2">
{template.description}
</p>
<div className="grid grid-cols-2 gap-2">
{previewLayouts.map((layout: TemplateWithData, index: number) => {
const LayoutComponent = layout.component;
return (
<div
key={`${template.id}-preview-${index}`}
className="relative bg-gray-100 border border-gray-200 overflow-hidden aspect-video rounded"
style={{ contain: 'layout style paint' }}
>
<div className="absolute inset-0 bg-transparent z-10" />
<div
className="transform scale-[0.2] flex justify-center items-center origin-top-left w-[500%] h-[500%]"
style={{ transform: 'scale(0.2) translateZ(0)', backfaceVisibility: 'hidden' }}
>
<LayoutComponent data={layout.sampleData} />
</div>
</div>
);
})}
</div>
</div>
{typeof selectedTemplate !== 'string' && selectedTemplate?.id === template.id && (
<div className="absolute top-0 right-0 bg-blue-500 text-white px-2 py-1 rounded-bl-lg">
Selected
</div>
)}
</Card>
);
})}
</div>
</div>
<div className="space-y-[30px] mb-4">
{/* Custom AI Templates */}
<div>
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold text-gray-900">Custom AI Templates</h3>
<h3 className="text-base font-semibold text-gray-900 font-syne">Custom</h3>
</div>
{customTemplateCards}
</div>
{/* In Built Templates */}
<div>
<h3 className="text-base font-semibold text-gray-900 mb-3 font-syne">In Built</h3>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{builtInTemplateCards}
</div>
{customLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
<span className="ml-3 text-gray-600">Loading custom templates...</span>
</div>
) : customTemplates.length === 0 ? (
<Card className="p-8 text-center">
<p className="text-gray-500">No custom templates yet.</p>
<p className="text-sm text-gray-400 mt-2">
Custom templates you create will appear here.
</p>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{customTemplates.map((template: CustomTemplates) => (
<CustomTemplateCard
key={template.id}
template={template}
onSelectTemplate={onSelectTemplate}
selectedTemplate={typeof selectedTemplate === 'string' ? selectedTemplate : null}
/>
))}
</div>
)}
</div>
</div>
);
};
});
TemplateSelection.displayName = 'TemplateSelection';
export default TemplateSelection;
export default TemplateSelection;

View file

@ -11,12 +11,16 @@ export const useOutlineManagement = (outlines: { content: string }[] | null) =>
if (!active || !over || !outlines) return;
if (active.id !== over.id) {
const oldIndex = outlines.findIndex((item) => item.content === active.id);
const newIndex = outlines.findIndex((item) => item.content === over.id);
const reorderedArray = arrayMove(outlines, oldIndex, newIndex);
dispatch(setOutlines(reorderedArray));
}
if (active.id === over.id) return;
const oldIndex = Number(String(active.id).replace("slide-", ""));
const newIndex = Number(String(over.id).replace("slide-", ""));
if (Number.isNaN(oldIndex) || Number.isNaN(newIndex)) return;
if (oldIndex < 0 || newIndex < 0 || oldIndex >= outlines.length || newIndex >= outlines.length) return;
const reorderedArray = arrayMove(outlines, oldIndex, newIndex);
dispatch(setOutlines(reorderedArray));
}, [outlines, dispatch]);
const handleAddSlide = useCallback(() => {

View file

@ -4,7 +4,6 @@ import { toast } from "sonner";
import { setOutlines } from "@/store/slices/presentationGeneration";
import { jsonrepair } from "jsonrepair";
import { RootState } from "@/store/store";
import { getApiUrl } from "@/utils/api";
@ -30,7 +29,7 @@ export const useOutlineStreaming = (presentationId: string | null) => {
setIsLoading(true)
try {
eventSource = new EventSource(
getApiUrl(`api/v1/ppt/outlines/stream/${presentationId}`)
`/api/v1/ppt/outlines/stream/${presentationId}`
);
eventSource.addEventListener("response", (event) => {

View file

@ -129,7 +129,7 @@ export const usePresentationGeneration = (
layout = {
name: selectedTemplate.id,
ordered: false,
slides: selectedTemplate.layouts.map((layoutItem) => ({
slides: selectedTemplate.layouts.map((layoutItem: any) => ({
id: layoutItem.layoutId,
name: layoutItem.layoutName,
description: layoutItem.layoutDescription,

View file

@ -1,5 +1,5 @@
import React from 'react'
import Header from '@/app/(presentation-generator)/dashboard/components/Header'
import Header from '@/app/(presentation-generator)/(dashboard)/dashboard/components/Header'
import { Metadata } from 'next'
import OutlinePage from './components/OutlinePage'
export const metadata: Metadata = {
@ -25,6 +25,15 @@ const page = () => {
return (
<div className='relative min-h-screen'>
<Header />
<div
className='fixed z-[-10] bottom-5 left-1/2 -translate-x-1/2 w-full h-full'
style={{
height: "341px",
width: "86%",
borderRadius: '1440px',
background: 'radial-gradient(5.92% 104.69% at 50% 100%, rgba(122, 90, 248, 0.00) 0%, rgba(255, 255, 255, 0.00) 100%), radial-gradient(50% 50% at 50% 50%, rgba(122, 90, 248, 0.80) 0%, rgba(122, 90, 248, 0.00) 100%)',
}}
/>
<OutlinePage />
</div>
)

View file

@ -10,10 +10,11 @@ import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
import { AlertCircle } from "lucide-react";
import { setPresentationData } from "@/store/slices/presentationGeneration";
import { DashboardApi } from "../services/api/dashboard";
import { setupImageUrlConverter } from "@/utils/image-url-converter";
import { V1ContentRender } from "../components/V1ContentRender";
import { useFontLoader } from "../hooks/useFontLoad";
import { Theme } from "../services/api/types";
@ -42,13 +43,6 @@ const PresentationPage = ({ presentation_id }: { presentation_id: string }) => {
}
}
}, [presentationData]);
// Setup image URL converter for Docker/browser compatibility
useEffect(() => {
const observer = setupImageUrlConverter();
return () => observer?.disconnect();
}, []);
// Function to fetch the slides
useEffect(() => {
fetchUserSlides();
@ -60,6 +54,9 @@ const PresentationPage = ({ presentation_id }: { presentation_id: string }) => {
const data = await DashboardApi.getPresentation(presentation_id);
dispatch(setPresentationData(data));
setContentLoading(false);
if (data?.theme) {
applyTheme(data.theme);
}
} catch (error) {
setError(true);
toast.error("Failed to load presentation");
@ -68,6 +65,43 @@ const PresentationPage = ({ presentation_id }: { presentation_id: string }) => {
}
};
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
}
// Regular view
return (
<div className="flex overflow-hidden flex-col">

View file

@ -0,0 +1,135 @@
'use client'
import React, { useEffect, useState, memo, useCallback } from "react";
import { useDispatch } from "react-redux";
import { addNewSlide } from "@/store/slices/presentationGeneration";
import { Loader2, X } from "lucide-react";
import { v4 as uuidv4 } from "uuid";
import { toast } from 'sonner';
import { getCustomTemplateDetails } from "@/app/hooks/useCustomTemplates";
import { getTemplatesByTemplateName } from "@/app/presentation-templates";
interface LayoutItemProps {
layout: any;
onSelect: (sampleData: any, layoutId: string) => void;
}
const LayoutItem = memo(({ layout, onSelect }: LayoutItemProps) => {
const { component: LayoutComponent, sampleData, layoutId } = layout;
return (
<div
onClick={() => onSelect(sampleData, layoutId)}
className="relative cursor-pointer overflow-hidden aspect-video"
>
<div className="absolute cursor-pointer bg-transparent z-40 top-0 left-0 w-full h-full" />
<div className="transform scale-[0.2] flex justify-center items-center origin-top-left w-[500%] h-[500%]">
<LayoutComponent data={sampleData} />
</div>
</div>
);
});
LayoutItem.displayName = 'LayoutItem';
interface NewSlideV1Props {
setShowNewSlideSelection: (show: boolean) => void;
templateID: string;
index: number;
presentationId: string;
}
const NewSlideV1 = ({
setShowNewSlideSelection,
templateID,
index,
presentationId,
}: NewSlideV1Props) => {
const dispatch = useDispatch();
const [layouts, setLayouts] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const isCustomTemplate = templateID.startsWith("custom-");
const handleNewSlide = useCallback((sampleData: any, id: string) => {
try {
const newSlide = {
id: uuidv4(),
index: index,
content: sampleData,
layout_group: templateID,
layout: isCustomTemplate ? `${templateID}:${id}` : id,
presentation: presentationId,
};
dispatch(addNewSlide({ slideData: newSlide, index }));
setShowNewSlideSelection(false);
} catch (error: any) {
console.error(error);
toast.error("Error adding new slide");
}
}, [index, templateID, presentationId, dispatch, setShowNewSlideSelection]);
useEffect(() => {
if (layouts.length > 0 || loading) return;
const fetchLayouts = async () => {
if (isCustomTemplate) {
setLoading(true);
const customTemplateId = templateID.split("custom-")[1];
const templateDetails = await getCustomTemplateDetails(customTemplateId, "Custom Template", "User-created template");
setLayouts(templateDetails?.layouts || []);
setLoading(false);
} else {
setLoading(true);
const templateDetails = getTemplatesByTemplateName(templateID);
setLayouts(templateDetails || []);
setLoading(false);
}
}
fetchLayouts();
}, []);
if (loading) {
return (
<div className="my-6 w-full bg-gray-50 p-8 max-w-[1280px]">
<div className="flex justify-between items-center mb-8">
<h2 className="text-2xl font-semibold">Select a Slide Layout</h2>
<X
onClick={() => setShowNewSlideSelection(false)}
className="text-gray-500 text-2xl cursor-pointer"
/>
</div>
<div className="flex items-center justify-center h-32">
<Loader2 className="w-8 h-8 animate-spin text-gray-500" />
</div>
</div>
);
}
return (
<div className="my-6 w-full bg-gray-50 p-8 max-w-[1280px]">
<div className="flex justify-between items-center mb-8">
<h2 className="text-2xl font-semibold">Select a Slide Layout</h2>
<X
onClick={() => setShowNewSlideSelection(false)}
className="text-gray-500 text-2xl cursor-pointer"
/>
</div>
<div className="grid grid-cols-4 gap-4">
{layouts.map((layout: any) => (
<LayoutItem
key={layout.layoutId}
layout={layout}
onSelect={handleNewSlide}
/>
))}
</div>
</div>
);
};
export default NewSlideV1;

View file

@ -0,0 +1,318 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Play,
Loader2,
Redo2,
Undo2,
RotateCcw,
ArrowRightFromLine,
ArrowUpRight,
} from "lucide-react";
import React, { useEffect, 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 { PptxPresentationModel } from "@/types/pptx_models";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
import { usePresentationUndoRedo } from "../hooks/PresentationUndoRedo";
import ToolTip from "@/components/ToolTip";
import { clearPresentationData } 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";
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 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();
const get_presentation_pptx_model = async (id: string): Promise<PptxPresentationModel> => {
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;
try {
setIsExporting(true);
// Save the presentation data before exporting
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) {
throw new Error("Failed to get presentation PPTX model");
}
trackEvent(MixpanelEvent.Header_ExportAsPPTX_API_Call);
const pptx_path = await PresentationGenerationApi.exportAsPPTX(pptx_model);
if (pptx_path) {
// window.open(pptx_path, '_self');
downloadLink(pptx_path);
} else {
throw new Error("No path returned from export");
}
} 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 {
setIsExporting(true);
// Save the presentation data before exporting
trackEvent(MixpanelEvent.Header_UpdatePresentationContent_API_Call);
await PresentationGenerationApi.updatePresentationContent(presentationData);
trackEvent(MixpanelEvent.Header_ExportAsPDF_API_Call);
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,
title: presentationData?.title,
})
});
if (response.ok) {
const { path: pdfPath } = await response.json();
// window.open(pdfPath, '_blank');
downloadLink(pdfPath);
} 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.Header_ReGenerate_Button_Clicked, { pathname });
router.push(`/presentation?id=${presentation_id}&stream=true`);
};
const downloadLink = (path: string) => {
// if we have popup access give direct download if not redirect to the path
if (window.opener) {
window.open(path, '_blank');
} else {
const link = document.createElement('a');
link.href = path;
link.download = path.split('/').pop() || 'download';
document.body.appendChild(link);
link.click();
}
};
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={() => {
trackEvent(MixpanelEvent.Header_Export_PDF_Button_Clicked, { pathname });
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={() => {
trackEvent(MixpanelEvent.Header_Export_PPTX_Button_Clicked, { pathname });
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>
);
return (
<>
<div className="py-7 sticky top-0 bg-white z-50 mb-[17px] font-syne flex justify-between items-center">
<h2 className="text-lg text-[#101323] font-unbounded "><MarkdownRenderer content={presentationData?.title || "Presentation"} className="mb-0 w-[600px] truncate text-sm text-[#101323] " /></h2>
<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>}
<ThemeSelector presentation_id={presentation_id} 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.Navigation, { from: pathname, to });
router.push(to);
}}
disabled={!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}
>
{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;

View file

@ -0,0 +1,306 @@
"use client";
import React, { useCallback, useEffect, useMemo, useState } from "react";
import {
ChevronLeft,
ChevronRight,
X,
Minimize2,
Maximize2,
StickyNote,
EyeOff,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Slide } from "../../types/slide";
import { V1ContentRender } from "../../components/V1ContentRender";
interface PresentationModeProps {
slides: Slide[];
currentSlide: number;
isFullscreen: boolean;
onFullscreenToggle: () => void;
onExit: () => void;
onSlideChange: (slideNumber: number) => void;
}
const PresentationMode: React.FC<PresentationModeProps> = ({
slides,
currentSlide,
isFullscreen,
onFullscreenToggle,
onExit,
onSlideChange,
}) => {
if (slides === undefined || slides === null || slides.length === 0) {
return null;
}
const [showSpeakerNotes, setShowSpeakerNotes] = useState(true);
const currentSpeakerNote = useMemo(
() => slides[currentSlide]?.speaker_note?.trim() || "",
[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);
}, [isFullscreen]);
useEffect(() => {
recomputeScale();
window.addEventListener("resize", recomputeScale);
return () => window.removeEventListener("resize", recomputeScale);
}, [recomputeScale]);
// Modify the handleKeyPress to prevent default behavior
const handleKeyPress = useCallback(
(event: KeyboardEvent) => {
event.preventDefault(); // Prevent default scroll behavior
switch (event.key) {
case "ArrowRight":
case "ArrowDown":
case " ": // Space key
if (currentSlide < slides.length - 1) {
onSlideChange(currentSlide + 1);
}
break;
case "ArrowLeft":
case "ArrowUp":
if (currentSlide > 0) {
onSlideChange(currentSlide - 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 (_) { }
return;
}
onExit();
break;
case "f":
case "F":
onFullscreenToggle();
break;
case "n":
case "N":
setShowSpeakerNotes((prev) => !prev);
break;
}
},
[currentSlide, slides.length, onSlideChange, onExit, onFullscreenToggle, isFullscreen]
);
// 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;
}
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);
}
}
};
// Handle Escape key separately
useEffect(() => {
const handleEscKey = (e: KeyboardEvent) => {
if (e.key === "Escape" && isFullscreen) {
onFullscreenToggle(); // Just toggle fullscreen, don't exit presentation
}
};
document.addEventListener("keydown", handleEscKey);
return () => document.removeEventListener("keydown", handleEscKey);
}, [isFullscreen, onFullscreenToggle]);
return (
<div
className="fixed inset-0 flex flex-col"
style={{ backgroundColor: "var(--page-background-color,#c8c7c9)" }}
tabIndex={0}
onClick={handleSlideClick}
>
{/* 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>
<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>
))}
</div>
</div>
{currentSpeakerNote && (
<div className="presentation-controls absolute bottom-4 right-4 z-50">
{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="flex items-center gap-2 text-sm font-medium text-gray-800">
<StickyNote className="h-4 w-4" />
Speaker notes
</div>
<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"
>
<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">
{currentSpeakerNote}
</div>
</div>
) : (
<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"
>
<StickyNote className="mr-2 h-4 w-4" />
Show notes
</Button>
)}
</div>
)}
</div>
);
};
export default PresentationMode;

View file

@ -1,17 +1,15 @@
"use client";
import React, { useEffect, useState } from "react";
import React, { useState } from "react";
import { useSelector } from "react-redux";
import { RootState } from "@/store/store";
import { Skeleton } from "@/components/ui/skeleton";
import PresentationMode from "../../components/PresentationMode";
import PresentationMode from "./PresentationMode";
import SidePanel from "./SidePanel";
import SlideContent from "./SlideContent";
import Header from "./Header";
import { Button } from "@/components/ui/button";
import { usePathname } from "next/navigation";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
import { AlertCircle, Loader2 } from "lucide-react";
import Help from "./Help";
import { AlertCircle } from "lucide-react";
import {
usePresentationStreaming,
usePresentationData,
@ -20,10 +18,10 @@ import {
} from "../hooks";
import { PresentationPageProps } from "../types";
import LoadingState from "./LoadingState";
import { setupImageUrlConverter } from "@/utils/image-url-converter";
import { useFontLoader } from "../../hooks/useFontLoader";
import { usePresentationUndoRedo } from "../hooks/PresentationUndoRedo";
import PresentationHeader from "./PresentationHeader";
const PresentationPage: React.FC<PresentationPageProps> = ({
presentation_id,
}) => {
@ -33,13 +31,6 @@ const PresentationPage: React.FC<PresentationPageProps> = ({
const [selectedSlide, setSelectedSlide] = useState(0);
const [isFullscreen, setIsFullscreen] = useState(false);
const [error, setError] = useState(false);
const [isMobilePanelOpen, setIsMobilePanelOpen] = useState(false);
// Setup image URL converter for Docker/browser compatibility
useEffect(() => {
const observer = setupImageUrlConverter();
return () => observer?.disconnect();
}, []);
const { presentationData, isStreaming } = useSelector(
@ -88,7 +79,6 @@ const PresentationPage: React.FC<PresentationPageProps> = ({
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];
@ -113,7 +103,7 @@ const PresentationPage: React.FC<PresentationPageProps> = ({
if (error) {
return (
<div className="flex flex-col items-center justify-center h-screen bg-gray-100">
<div className="flex flex-col items-center justify-center h-screen bg-gray-100 font-syne">
<div
className="bg-white border border-red-300 text-red-700 px-6 py-8 rounded-lg shadow-lg flex flex-col items-center"
role="alert"
@ -130,63 +120,65 @@ const PresentationPage: React.FC<PresentationPageProps> = ({
}
return (
<div className="h-screen flex overflow-hidden flex-col">
<div className="fixed right-6 top-[5.2rem] z-50">
{isSaving && <Loader2 className="w-6 h-6 animate-spin text-blue-500" />}
</div>
<Header presentation_id={presentation_id} currentSlide={selectedSlide} />
<Help />
<div className="h-screen overflow-hidden font-syne ">
<div
style={{
background: "#c8c7c9",
background: "#ffffff",
}}
className="flex flex-1 relative pt-6"
className="flex gap-6 relative "
>
<SidePanel
selectedSlide={selectedSlide}
onSlideClick={handleSlideClick}
loading={loading}
isMobilePanelOpen={isMobilePanelOpen}
setIsMobilePanelOpen={setIsMobilePanelOpen}
/>
<div className="w-[200px]">
<SidePanel
selectedSlide={selectedSlide}
onSlideClick={handleSlideClick}
presentationId={presentation_id}
loading={loading}
<div className="flex-1 h-[calc(100vh-100px)] overflow-y-auto">
/>
</div>
<div className=" w-full h-[calc(100vh-20px)] hide-scrollbar pr-[25px] overflow-y-auto">
<PresentationHeader presentation_id={presentation_id} isPresentationSaving={isSaving} currentSlide={selectedSlide} />
<div
id="presentation-slides-wrapper"
className="mx-auto flex flex-col items-center overflow-hidden justify-center p-2 sm:p-6 pt-0"
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] "
>
{!presentationData ||
loading ||
!presentationData?.slides ||
presentationData?.slides.length === 0 ? (
<div className="relative w-full h-[calc(100vh-120px)] mx-auto">
<div className="">
{Array.from({ length: 2 }).map((_, index) => (
<Skeleton
key={index}
className="aspect-video bg-gray-400 my-4 w-full mx-auto max-w-[1280px]"
/>
))}
<div className="w-full max-w-[1280px] h-full">
{!presentationData ||
loading ||
!presentationData?.slides ||
presentationData?.slides.length === 0 ? (
<div className="relative w-full h-[calc(100vh-120px)] mx-auto">
<div className="">
{Array.from({ length: 2 }).map((_, index) => (
<Skeleton
key={index}
className="aspect-video bg-gray-400 my-4 w-full mx-auto "
/>
))}
</div>
{stream && <LoadingState />}
</div>
{stream && <LoadingState />}
</div>
) : (
<>
{presentationData &&
presentationData.slides &&
presentationData.slides.length > 0 &&
presentationData.slides.map((slide: any, index: number) => (
<SlideContent
key={`${slide.type}-${index}-${slide.index}`}
slide={slide}
index={index}
presentationId={presentation_id}
/>
))}
</>
)}
) : (
<>
{presentationData &&
presentationData.slides &&
presentationData.slides.length > 0 &&
presentationData.slides.map((slide: any, index: number) => (
<SlideContent
key={`${slide.type}-${index}-${slide.index}`}
slide={slide}
index={index}
presentationId={presentation_id}
/>
))}
</>
)}
</div>
</div>
</div>
</div>

View file

@ -1,8 +1,6 @@
"use client";
import React, { useState, useEffect } from "react";
import { LayoutList, ListTree, PanelRightOpen, X } from "lucide-react";
import ToolTip from "@/components/ToolTip";
import { Button } from "@/components/ui/button";
import React, { useState } from "react";
import { Plus } from "lucide-react";
import { useDispatch, useSelector } from "react-redux";
import { RootState } from "@/store/store";
import {
@ -21,26 +19,29 @@ import {
} from "@dnd-kit/sortable";
import { setPresentationData } from "@/store/slices/presentationGeneration";
import { SortableSlide } from "./SortableSlide";
import { SortableListItem } from "./SortableListItem";
import SlideScale from "../../components/PresentationRender";
import { Separator } from "@/components/ui/separator";
import { useRouter } from "next/navigation";
import NewSlide from "./NewSlide";
interface SidePanelProps {
selectedSlide: number;
onSlideClick: (index: number) => void;
isMobilePanelOpen: boolean;
setIsMobilePanelOpen: (value: boolean) => void;
presentationId: string;
loading: boolean;
}
const SidePanel = ({
selectedSlide,
onSlideClick,
isMobilePanelOpen,
setIsMobilePanelOpen,
presentationId,
loading,
}: SidePanelProps) => {
const [isOpen, setIsOpen] = useState(true);
const [active, setActive] = useState<"list" | "grid">("grid");
const router = useRouter();
const [showNewSlideSelection, setShowNewSlideSelection] = useState(false);
const { presentationData, isStreaming } = useSelector(
(state: RootState) => state.presentationGeneration
@ -48,13 +49,21 @@ const SidePanel = ({
const dispatch = useDispatch();
const lastSlideIndex = presentationData?.slides?.length
? presentationData.slides.length - 1
: 0;
const lastSlideTemplateId = presentationData?.slides?.[lastSlideIndex]?.layout
? presentationData.slides[lastSlideIndex].layout.split(":")[0]
: "";
const handleAddSlideClick = () => {
if (!presentationData?.slides?.length || isStreaming) return;
setShowNewSlideSelection(true);
};
useEffect(() => {
if (window.innerWidth < 768) {
setIsOpen(isMobilePanelOpen);
}
}, [isMobilePanelOpen]);
const sensors = useSensors(
useSensor(PointerSensor, {
@ -67,12 +76,7 @@ const SidePanel = ({
})
);
const handleClose = () => {
setIsOpen(false);
if (window.innerWidth < 768) {
setIsMobilePanelOpen(false);
}
};
const handleDragEnd = (event: any) => {
const { active, over } = event;
@ -119,196 +123,97 @@ const SidePanel = ({
}
return (
<>
{/* Desktop Toggle Button - Always visible when panel is closed */}
{!isOpen && (
<div className="hidden xl:block fixed left-4 top-1/2 -translate-y-1/2 z-50">
<ToolTip content="Open Panel">
<Button
onClick={() => setIsOpen(true)}
className="bg-white hover:bg-gray-50 shadow-lg"
>
<PanelRightOpen className="text-black" size={20} />
</Button>
</ToolTip>
</div>
)}
<div className="bg-[#F6F6F9] pt-8 px-4 w-[200px]">
{/* Mobile Toggle Button */}
{!isMobilePanelOpen && (
<div className="xl:hidden fixed left-4 bottom-4 z-50">
<ToolTip content="Show Panel">
<Button
onClick={() => setIsMobilePanelOpen(true)}
className="bg-[#5146E5] text-white p-3 rounded-full shadow-lg"
>
<PanelRightOpen className="text-white" size={20} />
</Button>
</ToolTip>
</div>
)}
<img onClick={() => {
router.push("/dashboard");
}} src="/logo-with-bg.png" alt="" className="w-10 h-10 cursor-pointer object-contain" />
<Separator orientation="horizontal" className="my-6 " />
<div
className={`
fixed xl:relative h-full z-50 xl:z-auto
fixed xl:relative h-full z-50 xl:z-auto
transition-all duration-300 ease-in-out
${isOpen ? "ml-0" : "-ml-[300px]"}
${isMobilePanelOpen
? "translate-x-0"
: "-translate-x-full xl:translate-x-0"
}
`}
>
<div
className="min-w-[300px] bg-white max-w-[300px] h-[calc(100vh-120px)] rounded-[20px] hide-scrollbar overflow-hidden slide-theme shadow-xl"
className="w-full h-[calc(100vh-120px)] hide-scrollbar overflow-hidden slide-theme "
>
<div
className="sticky top-0 z-40 px-6 py-4"
>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center justify-start gap-4">
<ToolTip content="Image Preview">
<Button
className={`${active === "grid"
? "bg-[#5141e5] hover:bg-[#4638c7]"
: "bg-white hover:bg-white"
}`}
onClick={() => {
if (!isStreaming) {
setActive("grid")
}
}}
>
<LayoutList
className={`${active === "grid" ? "text-white" : "text-black"
}`}
size={20}
/>
</Button>
</ToolTip>
<ToolTip content="List Preview">
<Button
className={`${active === "list"
? "bg-[#5141e5] hover:bg-[#4638c7]"
: "bg-white hover:bg-white"
}`}
onClick={() => {
if (!isStreaming) {
setActive("list")
}
}}
>
<ListTree
className={`${active === "list" ? "text-white" : "text-black"
}`}
size={20}
/>
</Button>
</ToolTip>
</div>
<X
onClick={handleClose}
className="text-[#6c7081] cursor-pointer hover:text-gray-600"
size={20}
/>
</div>
</div>
<p className="text-xl font-normal pb-3.5 text-[#000000]">Slides</p>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
{/* List Preview */}
{active === "list" && (
<div className="p-4 overflow-y-auto hide-scrollbar h-[calc(100%-100px)]">
{isStreaming ? (
presentationData &&
presentationData?.slides.map((slide: any, index: number) => (
<div
key={`${index}-${slide.type}-${slide.id}`}
className={`p-3 cursor-pointer rounded-lg slide-box`}
>
<span className="font-medium slide-title">
Slide {index + 1}
</span>
<p className="text-sm slide-description">
{slide.content.title}
</p>
</div>
))
) : (
<SortableContext
items={
presentationData?.slides.map((slide: any) => slide.id!) || []
}
strategy={verticalListSortingStrategy}
<div className=" overflow-y-auto hide-scrollbar h-[calc(100%-140px)] space-y-3.5">
{isStreaming ? (
presentationData &&
presentationData?.slides.map((slide: any, index: number) => (
<div
key={`${slide.id}-${index}`}
onClick={() => onSlideClick(index)}
className={` cursor-pointer ring-2 rounded-[12px] transition-all duration-200 ${selectedSlide === index ? ' ring-[#5141e5]' : 'ring-gray-200'
}`}
>
<div className="space-y-2" id={`slide-${selectedSlide}`}>
{presentationData &&
presentationData?.slides.map((slide: any, index: number) => (
<SortableListItem
key={`${slide.id}-${index}`}
slide={slide}
index={index}
selectedSlide={selectedSlide}
onSlideClick={onSlideClick}
/>
))}
</div>
</SortableContext>
)}
</div>
)}
{/* Grid Preview */}
{active === "grid" && (
<div className="p-4 overflow-y-auto hide-scrollbar h-[calc(100%-100px)] space-y-4">
{isStreaming ? (
presentationData &&
presentationData?.slides.map((slide: any, index: number) => (
<div
key={`${slide.id}-${index}`}
onClick={() => onSlideClick(index)}
className={` cursor-pointer ring-2 p-1 rounded-md transition-all duration-200 ${selectedSlide === index ? ' ring-[#5141e5]' : 'ring-gray-200'
}`}
>
<div className=" bg-white pointer-events-none relative overflow-hidden aspect-video">
<div className="absolute bg-gray-100/5 z-50 top-0 left-0 w-full h-full" />
<div className="transform scale-[0.2] flex justify-center items-center origin-top-left w-[500%] h-[500%]">
<SlideScale slide={slide} />
</div>
<div className=" bg-white pointer-events-none relative overflow-hidden aspect-video">
<div className="absolute bg-gray-100/5 z-50 top-0 left-0 w-full h-full" />
<div className="transform scale-[0.2] flex justify-center items-center origin-top-left w-[500%] h-[500%]">
<SlideScale slide={slide} />
</div>
</div>
))
) : (
<SortableContext
items={
presentationData?.slides.map((slide: any) => slide.id || `${slide.index}`) || []
}
strategy={verticalListSortingStrategy}
>
{presentationData &&
presentationData?.slides.map((slide: any, index: number) => (
<SortableSlide
key={`${slide.id}-${index}`}
slide={slide}
index={index}
selectedSlide={selectedSlide}
onSlideClick={onSlideClick}
</div>
))
) : (
<SortableContext
items={
presentationData?.slides.map((slide: any) => slide.id || `${slide.index}`) || []
}
strategy={verticalListSortingStrategy}
>
{presentationData &&
presentationData?.slides.map((slide: any, index: number) => (
<SortableSlide
key={`${slide.id}-${index}`}
slide={slide}
index={index}
selectedSlide={selectedSlide}
onSlideClick={onSlideClick}
/>
))}
</SortableContext>
)}
</div>
/>
))}
</SortableContext>
)}
</div>
)}
</DndContext>
<Separator orientation="horizontal" className=" " />
<button
type="button"
onClick={handleAddSlideClick}
className="pt-6 gap-2 flex flex-col py-2 duration-300 items-center justify-center rounded-lg cursor-pointer mx-auto"
>
<Plus className="w-3.5 h-3.5" />
<span className="text-[11px] font-normal text-[#000000]">Add Slide</span>
</button>
</div>
</div>
</>
{showNewSlideSelection && lastSlideTemplateId && (
<div className="fixed inset-0 z-[60] bg-black/50 overflow-y-auto p-4">
<div className="min-h-full flex items-start justify-center py-8">
<NewSlide
index={lastSlideIndex}
templateID={lastSlideTemplateId}
setShowNewSlideSelection={setShowNewSlideSelection}
presentationId={presentationId}
/>
</div>
</div>
)}
</div>
);
};

View file

@ -1,5 +1,5 @@
import React, { useEffect, useState, useMemo } from "react";
import { Loader2, PlusIcon, Trash2, WandSparkles, StickyNote } from "lucide-react";
import React, { useEffect, useState } from "react";
import { Loader2, PlusIcon, Trash2, Pencil, Trash } from "lucide-react";
import {
Popover,
PopoverContent,
@ -18,9 +18,9 @@ import {
} from "@/store/slices/presentationGeneration";
import { usePathname } from "next/navigation";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
import NewSlide from "../../components/NewSlide";
import { addToHistory } from "@/store/slices/undoRedoSlice";
import { V1ContentRender } from "../../components/V1ContentRender";
import NewSlide from "./NewSlide";
interface SlideContentProps {
slide: any;
@ -32,6 +32,9 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => {
const dispatch = useDispatch();
const [isUpdating, setIsUpdating] = useState(false);
const [showNewSlideSelection, setShowNewSlideSelection] = useState(false);
const [isEditPopoverOpen, setIsEditPopoverOpen] = useState(false);
const [isSpeakerPopoverOpen, setIsSpeakerPopoverOpen] = useState(false);
const [editPrompt, setEditPrompt] = useState("");
const { presentationData, isStreaming } = useSelector(
(state: RootState) => state.presentationGeneration
);
@ -41,26 +44,24 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => {
const pathname = usePathname();
const handleSubmit = async () => {
const element = document.getElementById(
`slide-${slide.index}-prompt`
) as HTMLInputElement;
const value = element?.value;
if (!value?.trim()) {
if (!editPrompt.trim()) {
toast.error("Please enter a prompt before submitting");
return;
}
setIsUpdating(true);
try {
trackEvent(MixpanelEvent.Slide_Update_From_Prompt_Button_Clicked, { pathname });
trackEvent(MixpanelEvent.Slide_Edit_API_Call);
const response = await PresentationGenerationApi.editSlide(
slide.id,
value
editPrompt
);
if (response) {
dispatch(updateSlide({ index: slide.index, slide: response }));
toast.success("Slide updated successfully");
setEditPrompt("");
}
} catch (error: any) {
console.error("Error in slide editing:", error);
@ -71,8 +72,10 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => {
setIsUpdating(false);
}
};
const onDeleteSlide = async () => {
try {
trackEvent(MixpanelEvent.Slide_Delete_Slide_Button_Clicked, { pathname });
trackEvent(MixpanelEvent.Slide_Delete_API_Call);
// Add current state to past
dispatch(addToHistory({
@ -132,7 +135,7 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => {
<>
<div
id={`slide-${slide.index}`}
className=" w-full max-w-[1280px] main-slide flex items-center max-md:mb-4 justify-center relative"
className=" w-full main-slide flex items-center max-md:mb-4 justify-center relative"
>
{isStreaming && (
<Loader2 className="w-8 h-8 absolute right-2 top-2 z-30 text-blue-800 animate-spin" />
@ -140,7 +143,7 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => {
<div
data-layout={slide.layout}
data-group={slide.layout_group}
className={` w-full group `}
className={` w-full group font-syne `}
>
<V1ContentRender slide={slide} isEditMode={true} theme={null} />
{!showNewSlideSelection && (
@ -170,96 +173,116 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => {
)}
{!isStreaming && (
<ToolTip content="Delete slide">
<div
onClick={() => {
trackEvent(MixpanelEvent.Slide_Delete_Slide_Button_Clicked, { pathname });
onDeleteSlide();
}}
className="absolute top-2 z-20 sm:top-4 right-2 sm:right-4 hidden md:block transition-transform"
>
<Trash2 className="text-gray-500 text-xl cursor-pointer" />
</div>
</ToolTip>
)}
{!isStreaming && (
<div className="absolute top-2 z-20 sm:top-4 hidden md:block left-2 sm:left-4 transition-transform">
<Popover>
<PopoverTrigger>
<ToolTip content="Update slide using prompt">
<div
className={`p-2 group-hover:scale-105 rounded-lg bg-[#5141e5] hover:shadow-md transition-all duration-300 cursor-pointer shadow-md `}
>
<WandSparkles className="w-4 sm:w-5 h-4 sm:h-5 text-white" />
</div>
</ToolTip>
<div
className={`absolute right-3 top-3 z-30 hidden md:flex flex-row items-center gap-2 rounded-[28px] border border-gray-200/80 bg-white/95 px-2.5 py-2 ${isEditPopoverOpen || isSpeakerPopoverOpen
? "opacity-100 pointer-events-auto"
: "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto"
}`}
style={{
boxShadow: "0 2px 13.2px 0 rgba(0, 0, 0, 0.10)"
}}
>
<Popover open={isEditPopoverOpen} onOpenChange={setIsEditPopoverOpen}>
<PopoverTrigger asChild>
<button
type="button"
className="flex px-3.5 py-2.5 items-center justify-center rounded-full bg-[#F7F6F9] font-syne"
>
<ToolTip content="Update slide using prompt">
<Pencil className="h-4 w-4" />
</ToolTip>
</button>
</PopoverTrigger>
<PopoverContent
side="right"
align="start"
sideOffset={10}
className="w-[280px] sm:w-[400px] z-20"
side="bottom"
align="center"
sideOffset={12}
className="z-30 w-[340px] rounded-2xl border border-gray-200 bg-white p-0 shadow-2xl font-syne"
>
<div className="space-y-4">
<form
className="flex flex-col gap-3"
onSubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
>
<Textarea
id={`slide-${slide.index}-prompt`}
placeholder="Enter your prompt here..."
className="w-full min-h-[100px] max-h-[100px] p-2 text-sm border rounded-lg focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0"
disabled={isUpdating}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
}}
rows={4}
wrap="soft"
/>
<button
disabled={isUpdating}
type="submit"
className={`bg-gradient-to-r from-[#9034EA] to-[#5146E5] rounded-[32px] px-4 py-2 text-white flex items-center justify-end gap-2 ml-auto ${isUpdating ? "opacity-70 cursor-not-allowed" : ""
}`}
onClick={() => {
trackEvent(MixpanelEvent.Slide_Update_From_Prompt_Button_Clicked, { pathname });
}}
>
{isUpdating ? "Updating..." : "Update"}
<SendHorizontal className="w-4 sm:w-5 h-4 sm:h-5" />
</button>
</form>
<div className="border-b border-gray-100 px-4 py-3">
<p className="text-sm font-semibold text-gray-900">Update slide</p>
<p className="mt-1 text-xs text-gray-500">
Describe how this slide should be improved.
</p>
</div>
<form
className="flex flex-col gap-3 p-4"
onSubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
>
<Textarea
id={`slide-${slide.index}-prompt`}
value={editPrompt}
placeholder="Enter your prompt here..."
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)}
rows={5}
wrap="soft"
/>
<button
disabled={isUpdating}
type="submit"
className={`ml-auto flex items-center justify-center gap-2 rounded-full bg-gradient-to-r from-[#9034EA] to-[#5146E5] px-4 py-2 text-sm font-medium text-white transition-opacity ${isUpdating ? "cursor-not-allowed opacity-70" : "hover:opacity-90"}`}
>
{isUpdating ? "Updating..." : "Update"}
<SendHorizontal className="h-4 w-4" />
</button>
</form>
</PopoverContent>
</Popover>
</div>
)}
{/* Speaker Notes */}
{!isStreaming && slide?.speaker_note && (
<div className="absolute top-2 z-20 sm:top-4 right-8 sm:right-12 hidden md:block transition-transform">
<Popover>
<Popover open={isSpeakerPopoverOpen} onOpenChange={setIsSpeakerPopoverOpen}>
<PopoverTrigger asChild>
<div className=" cursor-pointer ">
<ToolTip content="Show speaker notes">
<StickyNote className="text-xl text-gray-500" />
<button
type="button"
style={{
background: "linear-gradient(270deg, #D5CAFC 2.4%, #E3D2EB 27.88%, #F4DCD3 69.23%, #FDE4C2 100%)",
}}
className={`flex px-4 py-2.5 items-center justify-center rounded-full border font-syne ${slide?.speaker_note
? "border-violet-200 bg-violet-50 text-violet-700"
: "border-gray-200 bg-white text-gray-600"
}`}
>
<ToolTip content="Edit speaker notes">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M5.13334 11.6665V9.27482L6.24167 9.39149C6.56434 9.37356 6.86969 9.23977 7.1016 9.01472C7.33351 8.78966 7.4764 8.48847 7.50401 8.16649V4.84149C7.50787 4.0011 7.17774 3.1936 6.58624 2.59663C5.99473 1.99965 5.1903 1.6621 4.34992 1.65824C3.50954 1.65437 2.70204 1.9845 2.10506 2.57601C1.50809 3.16751 1.17054 3.97194 1.16667 4.81232C1.16667 6.44565 1.54934 6.59382 1.75001 7.46649C1.88562 7.99351 1.89143 8.54556 1.76692 9.07532L1.16667 11.6665" stroke="black" strokeWidth="1.16667" strokeLinecap="round" strokeLinejoin="round" />
<path d="M11.55 10.3833C12.3701 9.56317 12.8309 8.45095 12.8312 7.29115C12.8316 6.13134 12.3714 5.01886 11.5518 4.19824" stroke="black" strokeWidth="1.16667" strokeLinecap="round" strokeLinejoin="round" />
<path d="M9.91667 8.74974C10.1075 8.55893 10.2586 8.33217 10.3613 8.08258C10.464 7.83299 10.5161 7.56553 10.5148 7.29566C10.5134 7.02578 10.4586 6.75885 10.3534 6.51031C10.2482 6.26177 10.0948 6.03654 9.90208 5.84766" stroke="black" strokeWidth="1.16667" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</ToolTip>
</div>
</button>
</PopoverTrigger>
<PopoverContent side="left" align="start" sideOffset={10} className="w-[320px] z-30">
<div className="space-y-2">
<p className="text-xs font-semibold text-gray-600">Speaker notes</p>
<div className="text-sm text-gray-800 whitespace-pre-wrap max-h-64 overflow-auto">
{slide.speaker_note}
<PopoverContent
side="bottom"
align="center"
sideOffset={12}
className="z-30 w-[340px] rounded-2xl border border-gray-200 bg-white p-0 shadow-2xl font-syne"
>
<div className="border-b border-gray-100 px-4 py-3">
<p className="text-sm font-semibold text-gray-900">Speaker notes</p>
</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."}
</div>
</div>
</PopoverContent>
</Popover>
<button
type="button"
onClick={onDeleteSlide}
className="flex px-4 py-2.5 items-center justify-center rounded-full border border-gray-200 bg-white text-gray-600 font-syne"
>
<ToolTip content="Delete slide">
<Trash className="h-4 w-4" />
</ToolTip>
</button>
</div>
)}
</div>

View file

@ -10,7 +10,7 @@ interface SortableSlideProps {
selectedSlide: number;
onSlideClick: (index: any) => void;
}
const SCALE = 0.2;
const SCALE = 0.125;
export function SortableSlide({ slide, index, selectedSlide, onSlideClick }: SortableSlideProps) {
const searchParams = useSearchParams();
@ -55,7 +55,7 @@ export function SortableSlide({ slide, index, selectedSlide, onSlideClick }: Sor
{...attributes}
{...listeners}
onClick={handleClick}
className={` cursor-pointer border-[3px] relative p-1 shadow-lg rounded-md transition-all duration-200 ${selectedSlide === index ? ' border-[#5141e5]' : 'border-gray-300'
className={` cursor-pointer border relative p-1 rounded-[12px] transition-all duration-200 ${selectedSlide === index ? ' border-[#BDB4FE]' : 'border-[#EDEEEF]'
}`}
>

View file

@ -0,0 +1,122 @@
"use client";
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 { 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[] }) => {
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')
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');
}
const resetTheme = async () => {
clearTheme();
dispatch(updateTheme(null))
}
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger>
<button className={`text-sm px-[18px] py-2.5 gap-1.5 flex items-center border border-[#EDEEEF] bg-[#F6F6F9] duration-300 rounded-[88px] font-medium font-syne ${isOpen ? 'text-[#007AFF]' : 'text-black'}`}>
<Palette className={`h-4 w-4 ${isOpen ? 'text-[#007AFF]' : 'text-black'}`} /> Theme
</button>
</PopoverTrigger>
<PopoverContent className="w-fit rounded-[18px] max-h-80 overflow-y-auto hide-scrollbar">
<div className='pb-2 flex gap-2 justify-end'>
<button className='text-xs text-gray-500 pb-2 text-right underline' onClick={() => router.push(`/theme?tab=new-theme`)}>+Customize Theme</button>
<button className='text-xs text-gray-500 pb-2 text-right underline' onClick={resetTheme}>Reset Theme</button>
</div>
<div className="grid grid-cols-3 gap-4">
{allThemes && allThemes.length > 0 && allThemes.map((t) => (
<div
key={t.id}
onClick={() => applyTheme(t)}
className={`text-left group relative`}
>
<div className={`rounded-xl cursor-pointer p-1 border shadow-sm bg-white transition-all group-hover:shadow-md ${currentTheme.id === t.id ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-gray-300'}`}>
<div className="rounded-lg p-2" style={{ backgroundColor: t.data.colors['background'] }}>
<div className="rounded-md shadow-sm p-3" style={{ backgroundColor: t.data.colors['card'] }}>
<div className="w-16 h-2 rounded-full mb-2" style={{ backgroundColor: t.data.colors['background_text'] }} />
<div className="w-12 h-2 rounded-full mb-1" style={{ backgroundColor: t.data.colors['background_text'] }} />
<div className="w-8 h-2 rounded-full mb-3" style={{ backgroundColor: t.data.colors['background_text'] }} />
<div className="w-8 h-3 rounded-full" style={{ backgroundColor: t.data.colors['primary'] }} />
</div>
</div>
</div>
<p className="mt-2 text-xs text-center font-medium text-gray-700 truncate w-full">
{t.name}
</p>
</div>
))}
</div>
</PopoverContent>
</Popover>
)
}
export default ThemeSelector

View file

@ -3,7 +3,9 @@ import { useDispatch } from "react-redux";
import { toast } from "sonner";
import { setPresentationData } from "@/store/slices/presentationGeneration";
import { DashboardApi } from '../../services/api/dashboard';
import { clearHistory } from "@/store/slices/undoRedoSlice";
import { clearHistory } from "@/store/slices/undoRedoSlice";
import { useFontLoader } from "../../hooks/useFontLoad";
import { Theme } from "../../services/api/types";
export const usePresentationData = (
@ -13,6 +15,41 @@ 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);
@ -21,6 +58,9 @@ export const usePresentationData = (
dispatch(clearHistory());
setLoading(false);
}
if (data?.theme) {
applyTheme(data.theme);
}
} catch (error) {
setError(true);
toast.error("Failed to load presentation");

View file

@ -8,7 +8,6 @@ import {
import { jsonrepair } from "jsonrepair";
import { toast } from "sonner";
import { MixpanelEvent, trackEvent } from "@/utils/mixpanel";
import { getApiUrl } from "@/utils/api";
export const usePresentationStreaming = (
presentationId: string,
@ -31,7 +30,7 @@ export const usePresentationStreaming = (
trackEvent(MixpanelEvent.Presentation_Stream_API_Call);
eventSource = new EventSource(
getApiUrl(`api/v1/ppt/presentation/stream/${presentationId}`)
`/api/v1/ppt/presentation/stream/${presentationId}`
);
eventSource.addEventListener("response", (event) => {

View file

@ -10,7 +10,7 @@ const page = () => {
const queryId = params.get("id");
if (!queryId) {
return (
<div className="flex flex-col items-center justify-center h-screen">
<div className="flex flex-col items-center justify-center h-screen font-syne">
<h1 className="text-2xl font-bold">No presentation id found</h1>
<p className="text-gray-500 pb-4">Please try again</p>
<Button onClick={() => router.push("/dashboard")}>Go to home</Button>

View file

@ -14,13 +14,13 @@ export interface PresentationResponse {
n_slides: number;
prompt: string;
summary: string | null;
theme: string;
titles: string[];
user_id: string;
vector_store: any;
theme: Record<string, any> | null;
titles: string[];
user_id: string;
vector_store: any;
thumbnail: string;
slides: any[];
thumbnail: string;
slides: any[];
}
export class DashboardApi {
@ -28,45 +28,45 @@ export class DashboardApi {
static async getPresentations(): Promise<PresentationResponse[]> {
try {
const response = await fetch(
getApiUrl("api/v1/ppt/presentation/all"),
getApiUrl(`/api/v1/ppt/presentation/all`),
{
method: "GET",
}
);
// Handle the special case where 404 means "no presentations found"
if (response.status === 404) {
console.log("No presentations found");
return [];
}
return await ApiResponseHandler.handleResponse(response, "Failed to fetch presentations");
} catch (error) {
console.error("Error fetching presentations:", error);
throw error;
}
}
static async getPresentation(id: string) {
try {
const response = await fetch(
getApiUrl(`api/v1/ppt/presentation/${id}`),
getApiUrl(`/api/v1/ppt/presentation/${id}`),
{
method: "GET",
}
);
return await ApiResponseHandler.handleResponse(response, "Presentation not found");
} catch (error) {
console.error("Error fetching presentation:", error);
throw error;
}
}
static async deletePresentation(presentation_id: string) {
try {
const response = await fetch(
getApiUrl(`api/v1/ppt/presentation/${presentation_id}`),
getApiUrl(`/api/v1/ppt/presentation/${presentation_id}`),
{
method: "DELETE",
headers: getHeader(),

View file

@ -19,13 +19,12 @@ export interface IconSearch {
}
export interface PreviousGeneratedImagesResponse {
extras: {
prompt: string;
theme_prompt: string | null;
},
created_at: string;
id: string;
path: string;
file_url: string;
extras: {
prompt: string;
theme_prompt: string | null;
};
created_at: string;
id: string;
path: string;
file_url?: string;
}

View file

@ -13,7 +13,7 @@ export class PresentationGenerationApi {
try {
const response = await fetch(
getApiUrl("api/v1/ppt/files/upload"),
getApiUrl(`/api/v1/ppt/files/upload`),
{
method: "POST",
headers: getHeaderForFormData(),
@ -32,7 +32,7 @@ export class PresentationGenerationApi {
static async decomposeDocuments(documentKeys: string[]) {
try {
const response = await fetch(
getApiUrl("api/v1/ppt/files/decompose"),
getApiUrl(`/api/v1/ppt/files/decompose`),
{
method: "POST",
headers: getHeader(),
@ -76,7 +76,7 @@ export class PresentationGenerationApi {
}) {
try {
const response = await fetch(
getApiUrl("api/v1/ppt/presentation/create"),
getApiUrl(`/api/v1/ppt/presentation/create`),
{
method: "POST",
headers: getHeader(),
@ -109,7 +109,7 @@ export class PresentationGenerationApi {
) {
try {
const response = await fetch(
getApiUrl("api/v1/ppt/slide/edit"),
getApiUrl(`/api/v1/ppt/slide/edit`),
{
method: "POST",
headers: getHeader(),
@ -130,9 +130,8 @@ export class PresentationGenerationApi {
static async updatePresentationContent(body: any) {
try {
console.log("Updating presentation with data:", body);
const response = await fetch(
getApiUrl("api/v1/ppt/presentation/update"),
getApiUrl(`/api/v1/ppt/presentation/update`),
{
method: "PATCH",
headers: getHeader(),
@ -151,7 +150,7 @@ export class PresentationGenerationApi {
static async presentationPrepare(presentationData: any) {
try {
const response = await fetch(
getApiUrl("api/v1/ppt/presentation/prepare"),
getApiUrl(`/api/v1/ppt/presentation/prepare`),
{
method: "POST",
headers: getHeader(),
@ -173,7 +172,7 @@ export class PresentationGenerationApi {
static async generateImage(imageGenerate: ImageGenerate) {
try {
const response = await fetch(
getApiUrl(`api/v1/ppt/images/generate?prompt=${imageGenerate.prompt}`),
getApiUrl(`/api/v1/ppt/images/generate?prompt=${imageGenerate.prompt}`),
{
method: "GET",
headers: getHeader(),
@ -191,7 +190,7 @@ export class PresentationGenerationApi {
static getPreviousGeneratedImages = async (): Promise<PreviousGeneratedImagesResponse[]> => {
try {
const response = await fetch(
getApiUrl("api/v1/ppt/images/generated"),
getApiUrl(`/api/v1/ppt/images/generated`),
{
method: "GET",
headers: getHeader(),
@ -208,7 +207,7 @@ export class PresentationGenerationApi {
static async searchIcons(iconSearch: IconSearch) {
try {
const response = await fetch(
getApiUrl(`api/v1/ppt/icons/search?query=${iconSearch.query}&limit=${iconSearch.limit}`),
getApiUrl(`/api/v1/ppt/icons/search?query=${iconSearch.query}&limit=${iconSearch.limit}`),
{
method: "GET",
headers: getHeader(),
@ -229,7 +228,7 @@ export class PresentationGenerationApi {
static async exportAsPPTX(presentationData: any) {
try {
const response = await fetch(
getApiUrl("api/v1/ppt/presentation/export/pptx"),
getApiUrl(`/api/v1/ppt/presentation/export/pptx`),
{
method: "POST",
headers: getHeader(),

View file

@ -1,10 +1,11 @@
import { ApiResponseHandler } from "./api-error-handler";
import { getApiUrl } from "@/utils/api";
class TemplateService {
static async getCustomTemplateSummaries() {
try {
const response = await fetch(`/api/v1/ppt/template-management/summary`,);
const response = await fetch(getApiUrl(`/api/v1/ppt/template-management/summary`),);
return await ApiResponseHandler.handleResponse(response, "Failed to get custom template summaries");
} catch (error) {
console.error("Failed to get custom template summaries", error);
@ -14,7 +15,7 @@ class TemplateService {
static async getCustomTemplateDetails(templateId: string) {
try {
const response = await fetch(`/api/v1/ppt/template-management/get-templates/${templateId}`,);
const response = await fetch(getApiUrl(`/api/v1/ppt/template-management/get-templates/${templateId}`),);
return await ApiResponseHandler.handleResponse(response, "Failed to get custom template details");
} catch (error) {
console.error("Failed to get custom template details", error);
@ -24,7 +25,7 @@ class TemplateService {
static async deleteCustomTemplate(presentationId: string) {
try {
const response = await fetch(`/api/v1/ppt/template-management/delete-templates/${presentationId}`, { method: "DELETE" });
const response = await fetch(getApiUrl(`/api/v1/ppt/template-management/delete-templates/${presentationId}`), { method: "DELETE" });
return await ApiResponseHandler.handleResponseWithResult(response, "Failed to delete custom template");
} catch (error) {
console.error("Failed to delete custom template", error);

View file

@ -0,0 +1,121 @@
import { ApiResponseHandler } from "./api-error-handler"
import { getHeader, getHeaderForFormData } from "./header"
import { Theme, ThemeParams } from "./types"
import { getApiUrl } from "@/utils/api"
class ThemeApi {
static async getThemes(): Promise<Theme[]> {
try {
const response = await fetch(getApiUrl(`/api/v1/ppt/themes/all`), {
method: "GET",
headers: getHeader(),
cache: "no-store",
})
return await ApiResponseHandler.handleResponse(response, "Failed to get themes")
} catch (error) {
console.error("Error getting themes:", error)
throw error
}
}
static async createTheme(theme: ThemeParams) {
try {
const response = await fetch(getApiUrl(`/api/v1/ppt/themes/create`), {
method: "POST",
headers: getHeader(),
body: JSON.stringify(theme),
cache: "no-store",
})
return await ApiResponseHandler.handleResponse(response, "Failed to create theme")
}
catch (error) {
console.error("Error creating theme:", error)
throw error
}
}
static async updateTheme(theme: ThemeParams) {
try {
const response = await fetch(getApiUrl(`/api/v1/ppt/themes/update/${theme.id}`), {
method: "PATCH",
headers: getHeader(),
body: JSON.stringify(theme),
cache: "no-store",
})
return await ApiResponseHandler.handleResponse(response, "Failed to update theme")
}
catch (error) {
console.error("Error updating theme:", error)
throw error
}
}
static async deleteTheme(themeId: string) {
try {
const response = await fetch(getApiUrl(`/api/v1/ppt/themes/delete/${themeId}`), {
method: "DELETE",
headers: getHeader(),
cache: "no-store",
})
return await ApiResponseHandler.handleResponse(response, "Failed to delete theme")
}
catch (error) {
console.error("Error deleting theme:", error)
throw error
}
}
static async generateTheme({ primary, background }: { primary?: string, background?: string }) {
try {
let body = {}
if (primary || background) {
body = {
primary: primary ?? undefined,
background: background ?? undefined,
}
}
const response = await fetch(getApiUrl(`/api/v1/ppt/theme/generate`), {
method: "POST",
headers: getHeader(),
body: JSON.stringify(body),
})
return await ApiResponseHandler.handleResponse(response, "Failed to generate theme")
}
catch (error) {
console.error("Error generating theme:", error)
throw error
}
}
static async uploadFont(font: File) {
try {
const formData = new FormData();
formData.append("file", font);
const response = await fetch(getApiUrl(`/api/v1/ppt/fonts/upload`), {
method: "POST",
headers: getHeaderForFormData(),
body: formData,
})
return await ApiResponseHandler.handleResponse(response, "Failed to upload font")
}
catch (error) {
console.error("Error uploading font:", error)
throw error
}
}
static async getUserFonts() {
try {
const response = await fetch(getApiUrl(`/api/v1/ppt/fonts/uploaded`), {
method: "GET",
headers: getHeader(),
})
return await ApiResponseHandler.handleResponse(response, "Failed to get user fonts")
}
catch (error) {
console.error("Error getting user fonts:", error)
throw error
}
}
}
export default ThemeApi

View file

@ -25,8 +25,46 @@ export interface DeplotResponse {
}
export interface ImageAssetResponse {
message:string;
path:string;
id:string;
file_url:string;
message: string;
path: string;
id: string;
file_url?: string;
}
export interface DefaultTheme {
id: string;
name: string;
description: string;
data: any;
}
export interface Theme {
id: string;
name: string;
description: string;
user: string;
logo: string; // image id
logo_url?: string; // preview url
company_name?: string;
data: any;
}
export interface ThemeParams {
id?: string;
name: string;
description: string;
logo: string | null; // image id
logo_url?: string | null; // preview url
data: any;
company_name?: string | null;
}
export interface DefaultTheme {
id: string;
name: string;
description: string;
data: any;
}

View file

@ -1,11 +1,11 @@
'use client';
import { useEffect, useRef, useCallback, useState } from 'react';
import { getFastAPIUrl } from '@/utils/api';
import { getHeader } from '@/app/(presentation-generator)/services/api/header';
import { ApiResponseHandler } from '@/app/(presentation-generator)/services/api/api-error-handler';
import { ProcessedSlide } from '@/app/(presentation-generator)/custom-template/types';
import { CustomTemplateLayout } from '@/app/hooks/useCustomTemplates';
import { getApiUrl } from '@/utils/api';
interface LayoutPayload {
layout_id: string;
@ -13,10 +13,16 @@ interface LayoutPayload {
layout_name: string;
}
/** Slide state for template preview: ProcessedSlide plus saved layout code and name */
export type TemplatePreviewSlideState = ProcessedSlide & {
react?: string;
layout_name?: string;
};
interface UseTemplateLayoutsAutoSaveOptions {
templateId: string | null;
layouts: CustomTemplateLayout[];
slideStates: ProcessedSlide[];
slideStates: TemplatePreviewSlideState[];
debounceMs?: number;
enabled?: boolean;
}
@ -72,7 +78,7 @@ export const useTemplateLayoutsAutoSave = ({
setSaveStatus('saving');
console.log('🔄 Auto-saving template layouts...');
const response = await fetch(`${getFastAPIUrl()}/api/v1/ppt/template/update`, {
const response = await fetch(getApiUrl('/api/v1/ppt/template/update'), {
method: 'PUT',
headers: getHeader(),
body: JSON.stringify({

View file

@ -1,22 +1,308 @@
import GroupLayoutPreview from './components/TemplatePreviewClient';
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { useParams, usePathname, useRouter } from "next/navigation";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { ArrowLeft, Home, Loader2, Trash2 } from "lucide-react";
// Allow dynamic params for custom templates
export const dynamicParams = true;
import { useFontLoader } from "../../hooks/useFontLoader";
import { MixpanelEvent, trackEvent } from "@/utils/mixpanel";
import TemplateService from "../../services/api/template";
import Header from "../../(dashboard)/dashboard/components/Header";
import { toast } from "sonner";
import { CustomTemplateLayout, useCustomTemplateDetails } from "@/app/hooks/useCustomTemplates";
import { templates as templateGroups, getTemplatesByTemplateName } from "@/app/presentation-templates";
// Generate static params for built-in template groups
export async function generateStaticParams() {
// Pre-render built-in template routes at build time
// Custom templates (custom-*) will be generated on-demand
return [
{ slug: 'neo-general' },
{ slug: 'neo-standard' },
{ slug: 'neo-modern' },
{ slug: 'general' },
{ slug: 'modern' },
{ slug: 'standard' },
];
}
const GroupLayoutPreview = () => {
const params = useParams();
const router = useRouter();
const pathname = usePathname();
export default function GroupLayoutPreviewPage() {
return <GroupLayoutPreview />;
}
const templateParams = params.slug as string;
// Check if this is a custom template
const isCustom = templateParams.startsWith("custom-");
const customTemplateId = isCustom ? templateParams.split("custom-")[1] : null;
// Fetch static templates if not custom
const staticTemplates = !isCustom ? getTemplatesByTemplateName(templateParams) : [];
const staticGroup = !isCustom ? templateGroups.find((g: { id: string }) => g.id === templateParams) : null;
// Fetch custom template details if custom
const {
template: customTemplate,
loading: customLoading,
error: customError,
fonts: customFonts,
} = useCustomTemplateDetails({ id: templateParams?.split("custom-")[1] || "", name: "", description: "" });
useEffect(() => {
const existingScript = document.querySelector('script[src*="tailwindcss.com"]');
if (!existingScript) {
const script = document.createElement("script");
script.src = "https://cdn.tailwindcss.com";
script.async = true;
document.head.appendChild(script);
}
}, [templateParams]);
const handleDeleteCustomTemplate = async () => {
if (!customTemplateId) return;
const confirmed = window.confirm(
"Are you sure you want to delete this template? This action cannot be undone."
);
if (!confirmed) return;
const success = await TemplateService.deleteCustomTemplate(customTemplateId);
if (success.success) {
toast.success("Template deleted successfully");
router.push("/template-preview");
} else {
toast.error("Failed to delete template");
}
};
// Loading state for custom templates
if (isCustom && (customLoading)) {
return (
<div className="min-h-screen bg-gray-50">
<Header />
<div className="flex items-center justify-center py-24">
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
<span className="ml-3 text-gray-600">Compiling templates...</span>
</div>
</div>
);
}
// Error state
if (isCustom && customError) {
return (
<div className="min-h-screen bg-gray-50">
<Header />
<div className="flex flex-col items-center justify-center py-24">
<h2 className="text-2xl font-bold text-red-600 mb-4">Error loading template</h2>
<p className="text-gray-600 mb-4">{customError}</p>
<Button onClick={() => router.push("/template-preview")}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Templates
</Button>
</div>
</div>
);
}
// Empty state
if (
(!isCustom && (!staticGroup || staticTemplates.length === 0)) ||
(isCustom && (!customTemplate))
) {
return (
<div className="min-h-screen bg-gray-50">
<Header />
<div className="flex flex-col items-center justify-center py-24">
<h2 className="text-2xl font-bold text-gray-900 mb-4">
Template not found
</h2>
<Button onClick={() => router.push("/template-preview")}>
<ArrowLeft className="w-4 h-4 mr-2" />
Back to Templates
</Button>
</div>
</div>
);
}
// Determine what to render
const templateName = isCustom ? customTemplate?.template.name || "Custom Template" : staticGroup?.name || "";
const templateDescription = isCustom
? customTemplate?.template.description || ""
: staticGroup?.description || "";
const layoutCount = isCustom
? customTemplate?.layouts.length || 0
: staticTemplates.length;
console.log('compileLayout', customTemplate)
return (
<div className="min-h-screen bg-gray-50">
<Header />
{/* Header */}
<header className="bg-white shadow-sm border-b sticky top-0 z-30">
<div className=" mx-auto px-6 py-6">
<div className="flex items-center justify-between mb-4 max-w-[1440px] mx-auto">
<div className="flex items-center gap-4">
<Button
variant="outline"
size="sm"
onClick={() => {
trackEvent(MixpanelEvent.TemplatePreview_Back_Button_Clicked, { pathname });
router.back();
}}
className="flex items-center gap-2"
>
<ArrowLeft className="w-4 h-4" />
Back
</Button>
<Button
variant="outline"
size="sm"
onClick={() => {
trackEvent(MixpanelEvent.TemplatePreview_All_Groups_Button_Clicked, { pathname });
router.push("/template-preview");
}}
className="flex items-center gap-2"
>
<Home className="w-4 h-4" />
All Templates
</Button>
</div>
{isCustom && (
<div className="flex items-center gap-4">
<Button
variant="outline"
size="sm"
onClick={() => {
trackEvent(MixpanelEvent.TemplatePreview_Delete_Templates_Button_Clicked, { pathname });
trackEvent(MixpanelEvent.TemplatePreview_Delete_Templates_API_Call);
handleDeleteCustomTemplate();
}}
className="flex items-center gap-2 border-red-200 text-red-700 hover:bg-red-50"
>
<Trash2 className="w-4 h-4" />
Delete Template
</Button>
</div>
)}
</div>
<div className="text-center">
<div className="flex items-center justify-center gap-2 mb-2">
<h1 className="text-3xl font-bold text-gray-900">{templateName}</h1>
{isCustom && (
<span className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-sm">
Custom
</span>
)}
</div>
<p className="text-gray-600">
{layoutCount} layout{layoutCount !== 1 ? "s" : ""} {" "}
{templateDescription}
</p>
</div>
</div>
</header>
{/* Layout Grid - Wrapped in SchemaHighlightProvider for custom templates */}
<main className="mx-auto px-2 py-8" id="presentation-page">
{/* Static Templates */}
{!isCustom && (
<div className="space-y-12 w-[1440px] h-[720px] aspect-video mx-auto">
{staticTemplates.map((template: any, index: number) => {
const LayoutComponent = template.component;
return (
<Card
key={`${templateParams}-${template.layoutId}-${index}`}
id={template.layoutId}
className="overflow-hidden shadow-md"
>
<div className="bg-white px-6 py-4 border-b">
<div className="flex items-center justify-between">
<div>
<h3 className="text-xl font-semibold text-gray-900">
{template.layoutName}
</h3>
<p className="text-sm text-gray-500 mt-1 max-w-2xl">
{template.layoutDescription}
</p>
</div>
<div className="flex items-center gap-3">
<span className="px-3 py-1 bg-gray-100 text-gray-600 rounded text-sm font-mono">
{template.layoutId}
</span>
<span className="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm font-medium">
#{index + 1}
</span>
</div>
</div>
</div>
<div className="bg-gray-100 p-6 flex justify-center overflow-x-auto">
<div
className="flex-shrink-0"
style={{ width: "1280px", height: "720px" }}
>
<LayoutComponent data={template.sampleData} />
</div>
</div>
</Card>
);
})}
</div>
)}
{/* Custom Templates - with page-level schema editor */}
{isCustom && (
<div className="flex flex-col items-center justify-center w-full gap-10 aspect-video mx-auto">
{/* Slides List */}
{customTemplate && customTemplate.layouts.map((layout: CustomTemplateLayout, index: number) => {
const LayoutComponent = layout.component;
return (
<Card
key={`${templateParams}-${layout.layoutId}-${index}`}
id={layout.layoutId}
className="overflow-hidden shadow-md"
>
<div className="bg-white px-6 py-4 border-b">
<div className="flex items-center justify-between">
<div>
<h3 className="text-xl font-semibold text-gray-900">
{layout.rawLayoutName}
</h3>
<p className="text-sm text-gray-500 mt-1 max-w-2xl">
{layout.layoutDescription}
</p>
</div>
</div>
<div className="flex items-end justify-end ">
<span className="px-3 py-1 bg-gray-100 text-gray-600 rounded text-sm font-mono">
{templateParams}:{layout.layoutId}
</span>
</div>
</div>
<div className="bg-gray-100 p-6 flex justify-center overflow-x-auto">
<div
className="flex-shrink-0"
style={{ width: "1280px", height: "720px" }}
>
<LayoutComponent data={layout.sampleData} />
</div>
</div>
</Card>
);
})}
</div>
)}
</main>
</div>
);
};
export default GroupLayoutPreview;

View file

@ -13,7 +13,7 @@ import {
CustomTemplates,
} from "@/app/hooks/useCustomTemplates";
import { CompiledLayout } from "@/app/hooks/compileLayout";
import Header from "../dashboard/components/Header";
import Header from "../(dashboard)/dashboard/components/Header";
// Component for rendering custom template card with lazy-loaded previews
const CustomTemplateCard = ({ template }: { template: CustomTemplates }) => {
@ -219,4 +219,4 @@ const LayoutPreview = () => {
);
};
export default LayoutPreview;
export default LayoutPreview;

View file

@ -0,0 +1,172 @@
import ToolTip from '@/components/ToolTip'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Textarea } from '@/components/ui/textarea'
import { SlidersHorizontal } from 'lucide-react'
import React, { useState } from 'react'
import { PresentationConfig, ToneType, VerbosityType } from '../type'
interface ConfigurationSelectsProps {
config: PresentationConfig;
onConfigChange: (key: keyof PresentationConfig, value: any) => void;
}
const AdvanceSettings = ({ config, onConfigChange }: ConfigurationSelectsProps) => {
const [openAdvanced, setOpenAdvanced] = useState(false);
const [advancedDraft, setAdvancedDraft] = useState({
tone: config.tone,
verbosity: config.verbosity,
instructions: config.instructions,
includeTableOfContents: config.includeTableOfContents,
includeTitleSlide: config.includeTitleSlide,
webSearch: config.webSearch,
});
const handleOpenAdvancedChange = (open: boolean) => {
if (open) {
setAdvancedDraft({
tone: config.tone,
verbosity: config.verbosity,
instructions: config.instructions,
includeTableOfContents: config.includeTableOfContents,
includeTitleSlide: config.includeTitleSlide,
webSearch: config.webSearch,
});
}
setOpenAdvanced(open);
};
const handleSaveAdvanced = () => {
onConfigChange("tone", advancedDraft.tone);
onConfigChange("verbosity", advancedDraft.verbosity);
onConfigChange("instructions", advancedDraft.instructions);
onConfigChange("includeTableOfContents", advancedDraft.includeTableOfContents);
onConfigChange("includeTitleSlide", advancedDraft.includeTitleSlide);
onConfigChange("webSearch", advancedDraft.webSearch);
setOpenAdvanced(false);
};
return (
<div className=''>
<ToolTip content="Advanced settings" className='w-full h-full'>
<button
aria-label="Advanced settings"
title="Advanced settings"
type="button"
onClick={() => handleOpenAdvancedChange(true)}
className=" w-full h-full flex items-center px-3 py-1 text-sm bg-[#F7F6F9] hover:bg-[#F7F6F9] border-[#EDEEEF] focus-visible:ring-[#5141E5] border-none rounded-[48px] font-instrument_sans font-medium"
>
<SlidersHorizontal className="h-4 w-4" aria-hidden="true" />
</button>
</ToolTip>
<Dialog open={openAdvanced} onOpenChange={handleOpenAdvancedChange}>
<DialogContent className="max-w-2xl font-instrument_sans">
<DialogHeader>
<DialogTitle>Advanced settings</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
{/* Tone */}
<div className="w-full flex flex-col gap-2">
<label className="text-sm font-semibold text-gray-700">Tone</label>
<p className="text-xs text-gray-500">Controls the writing style (e.g., casual, professional, funny).</p>
<Select
value={advancedDraft.tone}
onValueChange={(value) => setAdvancedDraft((prev) => ({ ...prev, tone: value as ToneType }))}
>
<SelectTrigger className="w-full font-instrument_sans capitalize font-medium bg-blue-100 border-blue-200 focus-visible:ring-blue-300">
<SelectValue placeholder="Select tone" />
</SelectTrigger>
<SelectContent className="font-instrument_sans">
{Object.values(ToneType).map((tone) => (
<SelectItem key={tone} value={tone} className="text-sm font-medium capitalize">
{tone}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Verbosity */}
<div className="w-full flex flex-col gap-2">
<label className="text-sm font-semibold text-gray-700">Verbosity</label>
<p className="text-xs text-gray-500">Controls how detailed slide descriptions are: concise, standard, or text-heavy.</p>
<Select
value={advancedDraft.verbosity}
onValueChange={(value) => setAdvancedDraft((prev) => ({ ...prev, verbosity: value as VerbosityType }))}
>
<SelectTrigger className="w-full font-instrument_sans capitalize font-medium bg-blue-100 border-blue-200 focus-visible:ring-blue-300">
<SelectValue placeholder="Select verbosity" />
</SelectTrigger>
<SelectContent className="font-instrument_sans">
{Object.values(VerbosityType).map((verbosity) => (
<SelectItem key={verbosity} value={verbosity} className="text-sm font-medium capitalize">
{verbosity}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Toggles */}
<div className="w-full flex flex-col gap-2 p-3 rounded-md bg-blue-100 border-blue-200">
<div className="flex items-center justify-between">
<label className="text-sm font-semibold text-gray-700">Include table of contents</label>
<Switch
checked={advancedDraft.includeTableOfContents}
onCheckedChange={(checked) => setAdvancedDraft((prev) => ({ ...prev, includeTableOfContents: checked }))}
/>
</div>
<p className="text-xs text-gray-600">Add an index slide summarizing sections (requires 3+ slides).</p>
</div>
<div className="w-full flex flex-col gap-2 p-3 rounded-md bg-blue-100 border-blue-200">
<div className="flex items-center justify-between">
<label className="text-sm font-semibold text-gray-700">Title slide</label>
<Switch
checked={advancedDraft.includeTitleSlide}
onCheckedChange={(checked) => setAdvancedDraft((prev) => ({ ...prev, includeTitleSlide: checked }))}
/>
</div>
<p className="text-xs text-gray-600">Include a title slide as the first slide.</p>
</div>
<div className="w-full flex flex-col gap-2 p-3 rounded-md bg-blue-100 border-blue-200">
<div className="flex items-center justify-between">
<label className="text-sm font-semibold text-gray-700">Web search</label>
<Switch
checked={advancedDraft.webSearch}
onCheckedChange={(checked) => setAdvancedDraft((prev) => ({ ...prev, webSearch: checked }))}
/>
</div>
<p className="text-xs text-gray-600">Allow the model to consult the web for fresher facts.</p>
</div>
{/* Instructions */}
<div className="w-full sm:col-span-2 flex flex-col gap-2">
<label className="text-sm font-semibold text-gray-700">Instructions</label>
<p className="text-xs text-gray-500">Optional guidance for the AI. These override defaults except format constraints.</p>
<Textarea
value={advancedDraft.instructions}
rows={4}
onChange={(e) => setAdvancedDraft((prev) => ({ ...prev, instructions: e.target.value }))}
placeholder="Example: Focus on enterprise buyers, emphasize ROI and security compliance. Keep slides data-driven, avoid jargon, and include a short call-to-action on the final slide."
className="py-2 px-3 border-2 font-medium text-sm min-h-[100px] max-h-[200px] border-blue-200 focus-visible:ring-offset-0 focus-visible:ring-blue-300"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenAdvancedChange(false)}>Cancel</Button>
<Button onClick={handleSaveAdvanced} className="bg-[#5141e5] text-white hover:bg-[#5141e5]/90">Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
export default AdvanceSettings

View file

@ -1,26 +1,26 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { LanguageType, PresentationConfig, ToneType, VerbosityType } from "../type";
import { LanguageType, PresentationConfig, ToneType, VerbosityType } from "../type";
import { useState } from "react";
import { Check, ChevronsUpDown, SlidersHorizontal } from "lucide-react";
import { Check, ChevronsUpDown, GalleryVertical, Languages, SlidersHorizontal } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
@ -31,8 +31,8 @@ import ToolTip from "@/components/ToolTip";
// Types
interface ConfigurationSelectsProps {
config: PresentationConfig;
onConfigChange: (key: keyof PresentationConfig, value: any) => void;
config: PresentationConfig;
onConfigChange: (key: keyof PresentationConfig, value: any) => void;
}
type SlideOption = "5" | "8" | "9" | "10" | "11" | "12" | "13" | "14" | "15" | "16" | "17" | "18" | "19" | "20";
@ -44,321 +44,327 @@ const SLIDE_OPTIONS: SlideOption[] = ["5", "8", "9", "10", "11", "12", "13", "14
* Renders a select component for slide count
*/
const SlideCountSelect: React.FC<{
value: string | null;
onValueChange: (value: string) => void;
value: string | null;
onValueChange: (value: string) => void;
}> = ({ value, onValueChange }) => {
const [customInput, setCustomInput] = useState(
value && !SLIDE_OPTIONS.includes(value as SlideOption) ? value : ""
);
const [customInput, setCustomInput] = useState(
value && !SLIDE_OPTIONS.includes(value as SlideOption) ? value : ""
);
const sanitizeToPositiveInteger = (raw: string): string => {
const digitsOnly = raw.replace(/\D+/g, "");
if (!digitsOnly) return "";
// Remove leading zeros
const noLeadingZeros = digitsOnly.replace(/^0+/, "");
return noLeadingZeros;
};
const sanitizeToPositiveInteger = (raw: string): string => {
const digitsOnly = raw.replace(/\D+/g, "");
if (!digitsOnly) return "";
// Remove leading zeros
const noLeadingZeros = digitsOnly.replace(/^0+/, "");
return noLeadingZeros;
};
const applyCustomValue = () => {
const sanitized = sanitizeToPositiveInteger(customInput);
if (sanitized && Number(sanitized) > 0) {
onValueChange(sanitized);
}
};
const applyCustomValue = () => {
const sanitized = sanitizeToPositiveInteger(customInput);
if (sanitized && Number(sanitized) > 0) {
onValueChange(sanitized);
}
};
return (
<Select value={value || ""} onValueChange={onValueChange} name="slides">
<SelectTrigger
className="w-[180px] font-instrument_sans font-medium bg-blue-100 border-blue-200 focus-visible:ring-blue-300"
data-testid="slides-select"
>
<SelectValue placeholder="Select Slides" />
</SelectTrigger>
<SelectContent className="font-instrument_sans">
{/* Sticky custom input at the top */}
<div
className="sticky top-0 z-10 bg-white p-2 border-b"
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2">
<Input
inputMode="numeric"
pattern="[0-9]*"
value={customInput}
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
onChange={(e) => {
const next = sanitizeToPositiveInteger(e.target.value);
setCustomInput(next);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
applyCustomValue();
}
}}
onBlur={applyCustomValue}
placeholder="--"
className="h-8 w-16 px-2 text-sm"
/>
<span className="text-sm font-medium">slides</span>
</div>
</div>
return (
<Select value={value || ""} onValueChange={onValueChange} name="slides">
<SelectTrigger
className="w-[140px] font-instrument_sans font-medium bg-white text-slate-700 hover:bg-slate-50 focus-visible:ring-[#5146E5]/30 flex items-center gap-2 h-10 rounded-xl px-3 ring-1 ring-inset ring-slate-200 shadow-sm"
data-testid="slides-select"
>
<div className="flex items-center gap-2.5"><GalleryVertical className="w-4 h-4" /> <SelectValue placeholder="Select Slides" /></div>
</SelectTrigger>
<SelectContent className="font-instrument_sans">
{/* Sticky custom input at the top */}
<div
className="sticky top-0 z-10 bg-white p-2 border-b"
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2">
<Input
inputMode="numeric"
pattern="[0-9]*"
value={customInput}
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
onChange={(e) => {
const next = sanitizeToPositiveInteger(e.target.value);
setCustomInput(next);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
applyCustomValue();
}
}}
onBlur={applyCustomValue}
placeholder="--"
className="h-8 w-16 px-2 text-sm"
/>
<span className="text-sm font-medium">slides</span>
</div>
</div>
{/* Hidden item to allow SelectValue to render custom selection */}
{value && !SLIDE_OPTIONS.includes(value as SlideOption) && (
<SelectItem value={value} className="hidden">
{value} slides
</SelectItem>
)}
{/* Hidden item to allow SelectValue to render custom selection */}
{value && !SLIDE_OPTIONS.includes(value as SlideOption) && (
<SelectItem value={value} className="hidden">
{value} slides
</SelectItem>
)}
{SLIDE_OPTIONS.map((option) => (
<SelectItem
key={option}
value={option}
className="font-instrument_sans text-sm font-medium"
role="option"
>
{option} slides
</SelectItem>
))}
</SelectContent>
</Select>
);
{SLIDE_OPTIONS.map((option) => (
<SelectItem
key={option}
value={option}
className="font-instrument_sans text-sm font-medium"
role="option"
>
{option} slides
</SelectItem>
))}
</SelectContent>
</Select>
);
};
/**
* Renders a language selection component with search functionality
*/
const LanguageSelect: React.FC<{
value: string | null;
onValueChange: (value: string) => void;
open: boolean;
onOpenChange: (open: boolean) => void;
value: string | null;
onValueChange: (value: string) => void;
open: boolean;
onOpenChange: (open: boolean) => void;
}> = ({ value, onValueChange, open, onOpenChange }) => (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
name="language"
data-testid="language-select"
aria-expanded={open}
className="w-[200px] justify-between font-instrument_sans font-semibold overflow-hidden bg-blue-100 hover:bg-blue-100 border-blue-200 focus-visible:ring-blue-300 border-none"
>
<p className="text-sm font-medium truncate">
{value || "Select language"}
</p>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="end">
<Command>
<CommandInput
placeholder="Search language..."
className="font-instrument_sans"
/>
<CommandList>
<CommandEmpty>No language found.</CommandEmpty>
<CommandGroup>
{Object.values(LanguageType).map((language) => (
<CommandItem
key={language}
value={language}
role="option"
onSelect={(currentValue) => {
onValueChange(currentValue);
onOpenChange(false);
}}
className="font-instrument_sans"
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === language ? "opacity-100" : "opacity-0"
)}
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
name="language"
data-testid="language-select"
aria-expanded={open}
className="w-[180px] flex justify-between items-center gap-2 font-instrument_sans font-semibold overflow-hidden bg-white text-slate-700 h-10 rounded-xl px-3 ring-1 ring-inset ring-slate-200 shadow-sm"
>
<span className="flex justify-center items-center gap-2.5">
<span className="border border-slate-200 rounded-md p-1">
<Languages className="w-4 h-4" />
</span>
<span className="text-sm font-medium truncate">
{value || "Select language"}
</span>
</span>
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="end">
<Command>
<CommandInput
placeholder="Search language..."
className="font-instrument_sans"
/>
{language}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<CommandList>
<CommandEmpty>No language found.</CommandEmpty>
<CommandGroup>
{Object.values(LanguageType).map((language) => (
<CommandItem
key={language}
value={language}
role="option"
onSelect={(currentValue) => {
onValueChange(currentValue);
onOpenChange(false);
}}
className="font-instrument_sans"
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === language ? "opacity-100" : "opacity-0"
)}
/>
{language}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
export function ConfigurationSelects({
config,
onConfigChange,
config,
onConfigChange,
}: ConfigurationSelectsProps) {
const [openLanguage, setOpenLanguage] = useState(false);
const [openAdvanced, setOpenAdvanced] = useState(false);
const [openLanguage, setOpenLanguage] = useState(false);
const [openAdvanced, setOpenAdvanced] = useState(false);
const [advancedDraft, setAdvancedDraft] = useState({
tone: config.tone,
verbosity: config.verbosity,
instructions: config.instructions,
includeTableOfContents: config.includeTableOfContents,
includeTitleSlide: config.includeTitleSlide,
webSearch: config.webSearch,
});
const handleOpenAdvancedChange = (open: boolean) => {
if (open) {
setAdvancedDraft({
const [advancedDraft, setAdvancedDraft] = useState({
tone: config.tone,
verbosity: config.verbosity,
instructions: config.instructions,
includeTableOfContents: config.includeTableOfContents,
includeTitleSlide: config.includeTitleSlide,
webSearch: config.webSearch,
});
}
setOpenAdvanced(open);
};
});
const handleSaveAdvanced = () => {
onConfigChange("tone", advancedDraft.tone);
onConfigChange("verbosity", advancedDraft.verbosity);
onConfigChange("instructions", advancedDraft.instructions);
onConfigChange("includeTableOfContents", advancedDraft.includeTableOfContents);
onConfigChange("includeTitleSlide", advancedDraft.includeTitleSlide);
onConfigChange("webSearch", advancedDraft.webSearch);
setOpenAdvanced(false);
};
const handleOpenAdvancedChange = (open: boolean) => {
if (open) {
setAdvancedDraft({
tone: config.tone,
verbosity: config.verbosity,
instructions: config.instructions,
includeTableOfContents: config.includeTableOfContents,
includeTitleSlide: config.includeTitleSlide,
webSearch: config.webSearch,
});
}
setOpenAdvanced(open);
};
return (
<div className="flex flex-wrap order-1 gap-4 items-center">
<SlideCountSelect
value={config.slides}
onValueChange={(value) => onConfigChange("slides", value)}
/>
<LanguageSelect
value={config.language}
onValueChange={(value) => onConfigChange("language", value)}
open={openLanguage}
onOpenChange={setOpenLanguage}
/>
<ToolTip content="Advanced settings">
const handleSaveAdvanced = () => {
onConfigChange("tone", advancedDraft.tone);
onConfigChange("verbosity", advancedDraft.verbosity);
onConfigChange("instructions", advancedDraft.instructions);
onConfigChange("includeTableOfContents", advancedDraft.includeTableOfContents);
onConfigChange("includeTitleSlide", advancedDraft.includeTitleSlide);
onConfigChange("webSearch", advancedDraft.webSearch);
setOpenAdvanced(false);
};
<button
aria-label="Advanced settings"
title="Advanced settings"
type="button"
onClick={() => handleOpenAdvancedChange(true)}
className="ml-auto flex items-center gap-2 text-sm underline underline-offset-4 bg-blue-100 hover:bg-blue-100 border-blue-200 focus-visible:ring-blue-300 border-none p-2 rounded-md font-instrument_sans font-medium"
>
<SlidersHorizontal className="h-4 w-4" aria-hidden="true" />
</button>
</ToolTip>
return (
<div className="flex flex-wrap order-1 gap-4 items-center">
<SlideCountSelect
value={config.slides}
onValueChange={(value) => onConfigChange("slides", value)}
/>
<LanguageSelect
value={config.language}
onValueChange={(value) => onConfigChange("language", value)}
open={openLanguage}
onOpenChange={setOpenLanguage}
/>
<ToolTip content="Advanced settings">
<Dialog open={openAdvanced} onOpenChange={handleOpenAdvancedChange}>
<DialogContent className="max-w-2xl font-instrument_sans">
<DialogHeader>
<DialogTitle>Advanced settings</DialogTitle>
</DialogHeader>
<button
aria-label="Advanced settings"
title="Advanced settings"
type="button"
onClick={() => handleOpenAdvancedChange(true)}
className="ml-auto flex items-center gap-2 text-sm bg-white text-slate-700 hover:bg-slate-50 focus-visible:ring-[#5146E5]/30 h-10 rounded-xl px-3 ring-1 ring-inset ring-slate-200 shadow-sm font-instrument_sans font-medium"
data-testid="advanced-settings-button"
>
<SlidersHorizontal className="h-4 w-4" aria-hidden="true" />
</button>
</ToolTip>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
{/* Tone */}
<div className="w-full flex flex-col gap-2">
<label className="text-sm font-semibold text-gray-700">Tone</label>
<p className="text-xs text-gray-500">Controls the writing style (e.g., casual, professional, funny).</p>
<Select
value={advancedDraft.tone}
onValueChange={(value) => setAdvancedDraft((prev) => ({ ...prev, tone: value as ToneType }))}
>
<SelectTrigger className="w-full font-instrument_sans capitalize font-medium bg-blue-100 border-blue-200 focus-visible:ring-blue-300">
<SelectValue placeholder="Select tone" />
</SelectTrigger>
<SelectContent className="font-instrument_sans">
{Object.values(ToneType).map((tone) => (
<SelectItem key={tone} value={tone} className="text-sm font-medium capitalize">
{tone}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Dialog open={openAdvanced} onOpenChange={handleOpenAdvancedChange}>
<DialogContent className="max-w-2xl font-instrument_sans">
<DialogHeader>
<DialogTitle>Advanced settings</DialogTitle>
</DialogHeader>
{/* Verbosity */}
<div className="w-full flex flex-col gap-2">
<label className="text-sm font-semibold text-gray-700">Verbosity</label>
<p className="text-xs text-gray-500">Controls how detailed slide descriptions are: concise, standard, or text-heavy.</p>
<Select
value={advancedDraft.verbosity}
onValueChange={(value) => setAdvancedDraft((prev) => ({ ...prev, verbosity: value as VerbosityType }))}
>
<SelectTrigger className="w-full font-instrument_sans capitalize font-medium bg-blue-100 border-blue-200 focus-visible:ring-blue-300">
<SelectValue placeholder="Select verbosity" />
</SelectTrigger>
<SelectContent className="font-instrument_sans">
{Object.values(VerbosityType).map((verbosity) => (
<SelectItem key={verbosity} value={verbosity} className="text-sm font-medium capitalize">
{verbosity}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-5">
{/* Tone */}
<div className="w-full flex flex-col gap-2">
<label className="text-sm font-semibold text-gray-700">Tone</label>
<p className="text-xs text-gray-500">Controls the writing style (e.g., casual, professional, funny).</p>
<Select
value={advancedDraft.tone}
onValueChange={(value) => setAdvancedDraft((prev) => ({ ...prev, tone: value as ToneType }))}
>
<SelectTrigger className="w-full font-instrument_sans capitalize font-medium bg-white border-slate-300 hover:bg-slate-50 focus-visible:ring-slate-300">
<SelectValue placeholder="Select tone" />
</SelectTrigger>
<SelectContent className="font-instrument_sans">
{Object.values(ToneType).map((tone) => (
<SelectItem key={tone} value={tone} className="text-sm font-medium capitalize">
{tone}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Verbosity */}
<div className="w-full flex flex-col gap-2">
<label className="text-sm font-semibold text-gray-700">Verbosity</label>
<p className="text-xs text-gray-500">Controls how detailed slide descriptions are: concise, standard, or text-heavy.</p>
<Select
value={advancedDraft.verbosity}
onValueChange={(value) => setAdvancedDraft((prev) => ({ ...prev, verbosity: value as VerbosityType }))}
>
<SelectTrigger className="w-full font-instrument_sans capitalize font-medium bg-white border-slate-300 hover:bg-slate-50 focus-visible:ring-slate-300">
<SelectValue placeholder="Select verbosity" />
</SelectTrigger>
<SelectContent className="font-instrument_sans">
{Object.values(VerbosityType).map((verbosity) => (
<SelectItem key={verbosity} value={verbosity} className="text-sm font-medium capitalize">
{verbosity}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Toggles */}
<div className="w-full flex flex-col gap-2 p-3 rounded-md bg-blue-100 border-blue-200">
<div className="flex items-center justify-between">
<label className="text-sm font-semibold text-gray-700">Include table of contents</label>
<Switch
checked={advancedDraft.includeTableOfContents}
onCheckedChange={(checked) => setAdvancedDraft((prev) => ({ ...prev, includeTableOfContents: checked }))}
/>
</div>
<p className="text-xs text-gray-600">Add an index slide summarizing sections (requires 3+ slides).</p>
</div>
<div className="w-full flex flex-col gap-2 p-3 rounded-md bg-blue-100 border-blue-200">
<div className="flex items-center justify-between">
<label className="text-sm font-semibold text-gray-700">Title slide</label>
<Switch
checked={advancedDraft.includeTitleSlide}
onCheckedChange={(checked) => setAdvancedDraft((prev) => ({ ...prev, includeTitleSlide: checked }))}
/>
</div>
<p className="text-xs text-gray-600">Include a title slide as the first slide.</p>
</div>
<div className="w-full flex flex-col gap-2 p-3 rounded-md bg-blue-100 border-blue-200">
<div className="flex items-center justify-between">
<label className="text-sm font-semibold text-gray-700">Web search</label>
<Switch
checked={advancedDraft.webSearch}
onCheckedChange={(checked) => setAdvancedDraft((prev) => ({ ...prev, webSearch: checked }))}
/>
</div>
<p className="text-xs text-gray-600">Allow the model to consult the web for fresher facts.</p>
</div>
{/* Instructions */}
<div className="w-full sm:col-span-2 flex flex-col gap-2">
<label className="text-sm font-semibold text-gray-700">Instructions</label>
<p className="text-xs text-gray-500">Optional guidance for the AI. These override defaults except format constraints.</p>
<Textarea
value={advancedDraft.instructions}
rows={4}
onChange={(e) => setAdvancedDraft((prev) => ({ ...prev, instructions: e.target.value }))}
placeholder="Example: Focus on enterprise buyers, emphasize ROI and security compliance. Keep slides data-driven, avoid jargon, and include a short call-to-action on the final slide."
className="py-2 px-3 border-2 font-medium text-sm min-h-[100px] max-h-[200px] border-blue-200 focus-visible:ring-offset-0 focus-visible:ring-blue-300"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenAdvancedChange(false)}>Cancel</Button>
<Button onClick={handleSaveAdvanced} className="bg-[#5141e5] text-white hover:bg-[#5141e5]/90">Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
{/* Toggles */}
<div className="w-full flex flex-col gap-2 p-3 rounded-md bg-slate-50 border-slate-200">
<div className="flex items-center justify-between">
<label className="text-sm font-semibold text-gray-700">Include table of contents</label>
<Switch
checked={advancedDraft.includeTableOfContents}
onCheckedChange={(checked) => setAdvancedDraft((prev) => ({ ...prev, includeTableOfContents: checked }))}
/>
</div>
<p className="text-xs text-gray-600">Add an index slide summarizing sections (requires 3+ slides).</p>
</div>
<div className="w-full flex flex-col gap-2 p-3 rounded-md bg-slate-50 border-slate-200">
<div className="flex items-center justify-between">
<label className="text-sm font-semibold text-gray-700">Title slide</label>
<Switch
checked={advancedDraft.includeTitleSlide}
onCheckedChange={(checked) => setAdvancedDraft((prev) => ({ ...prev, includeTitleSlide: checked }))}
/>
</div>
<p className="text-xs text-gray-600">Include a title slide as the first slide.</p>
</div>
<div className="w-full flex flex-col gap-2 p-3 rounded-md bg-slate-50 border-slate-200">
<div className="flex items-center justify-between">
<label className="text-sm font-semibold text-gray-700">Web search</label>
<Switch
checked={advancedDraft.webSearch}
onCheckedChange={(checked) => setAdvancedDraft((prev) => ({ ...prev, webSearch: checked }))}
/>
</div>
<p className="text-xs text-gray-600">Allow the model to consult the web for fresher facts.</p>
</div>
{/* Instructions */}
<div className="w-full sm:col-span-2 flex flex-col gap-2">
<label className="text-sm font-semibold text-gray-700">Instructions</label>
<p className="text-xs text-gray-500">Optional guidance for the AI. These override defaults except format constraints.</p>
<Textarea
value={advancedDraft.instructions}
rows={4}
onChange={(e) => setAdvancedDraft((prev) => ({ ...prev, instructions: e.target.value }))}
placeholder="Example: Focus on enterprise buyers, emphasize ROI and security compliance. Keep slides data-driven, avoid jargon, and include a short call-to-action on the final slide."
className="py-2 px-3 border-2 font-medium text-sm min-h-[100px] max-h-[200px] border-blue-200 focus-visible:ring-offset-0 focus-visible:ring-blue-300"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenAdvancedChange(false)}>Cancel</Button>
<Button onClick={handleSaveAdvanced} className="bg-[#5141e5] text-white hover:bg-[#5141e5]/90">Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -0,0 +1,69 @@
import { Button } from '@/components/ui/button';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Check, ChevronDown } from 'lucide-react';
import React, { useState } from 'react'
import { LanguageType } from '../type';
import { cn } from '@/lib/utils';
export const LanguageSelector: React.FC<{
value: string | null;
onValueChange: (value: string) => void;
}> = ({ value, onValueChange }) => {
const [openLanguage, setOpenLanguage] = useState(false);
return (
<Popover open={openLanguage} onOpenChange={setOpenLanguage}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
name="language"
data-testid="language-select"
aria-expanded={openLanguage}
className="px-3.5 py-1 justify-between rounded-[48px] font-instrument_sans font-semibold overflow-hidden bg-[#F7F6F9] border-[#EDEEEF] focus-visible:ring-[#5141E5] border-none"
>
<p className="text-sm font-medium truncate">
{value || "Select language"}
</p>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="end">
<Command>
<CommandInput
placeholder="Search language..."
className="font-instrument_sans"
/>
<CommandList>
<CommandEmpty>No language found.</CommandEmpty>
<CommandGroup>
{Object.values(LanguageType).map((language) => (
<CommandItem
key={language}
value={language}
role="option"
onSelect={(currentValue) => {
onValueChange(currentValue);
setOpenLanguage(false);
}}
className="font-instrument_sans"
>
<Check
className={cn(
"mr-2 h-4 w-4",
value === language ? "opacity-100" : "opacity-0"
)}
/>
{language}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View file

@ -0,0 +1,90 @@
import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import React, { useState } from 'react'
const SLIDE_OPTIONS: string[] = ["5", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20"];
const NumberOfSlide = ({ value, onValueChange }: { value: string, onValueChange: (value: string) => void }) => {
const [customInput, setCustomInput] = useState(
value && !SLIDE_OPTIONS.includes(value) ? value : ""
);
const sanitizeToPositiveInteger = (raw: string): string => {
const digitsOnly = raw.replace(/\D+/g, "");
if (!digitsOnly) return "";
// Remove leading zeros
const noLeadingZeros = digitsOnly.replace(/^0+/, "");
return noLeadingZeros;
};
const applyCustomValue = () => {
const sanitized = sanitizeToPositiveInteger(customInput);
if (sanitized && Number(sanitized) > 0) {
onValueChange(sanitized);
}
};
return (
<Select value={value || ""} onValueChange={onValueChange} name="slides">
<SelectTrigger
className="w-[180px] font-instrument_sans font-medium bg-blue-100 border-blue-200 focus-visible:ring-blue-300"
data-testid="slides-select"
>
<SelectValue placeholder="Select Slides" />
</SelectTrigger>
<SelectContent className="font-instrument_sans">
{/* Sticky custom input at the top */}
<div
className="sticky top-0 z-10 bg-white p-2 border-b"
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2">
<Input
inputMode="numeric"
pattern="[0-9]*"
value={customInput}
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
onChange={(e) => {
const next = sanitizeToPositiveInteger(e.target.value);
setCustomInput(next);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
applyCustomValue();
}
}}
onBlur={applyCustomValue}
placeholder="--"
className="h-8 w-16 px-2 text-sm"
/>
<span className="text-sm font-medium">slides</span>
</div>
</div>
{/* Hidden item to allow SelectValue to render custom selection */}
{value && !SLIDE_OPTIONS.includes(value) && (
<SelectItem value={value} className="hidden">
{value} slides
</SelectItem>
)}
{SLIDE_OPTIONS.map((option) => (
<SelectItem
key={option}
value={option}
className="font-instrument_sans text-sm font-medium"
role="option"
>
{option} slides
</SelectItem>
))}
</SelectContent>
</Select>
)
}
export default NumberOfSlide

View file

@ -1,25 +1,21 @@
import { Textarea } from "@/components/ui/textarea";
import { useState } from "react";
interface PromptInputProps {
value: string;
onChange: (value: string) => void;
}
export function PromptInput({
value,
onChange,
export function PromptInput({ value, onChange }: PromptInputProps) {
}: PromptInputProps) {
const [showHint, setShowHint] = useState(false);
const handleChange = (value: string) => {
setShowHint(value.length > 0);
onChange(value);
const handleChange = (val: string) => {
onChange(val);
};
return (
<div className="space-y-2">
<div className="space-y-2 font-syne">
<div className="relative">
<Textarea
value={value}
@ -30,13 +26,7 @@ export function PromptInput({
className={`py-4 px-5 border-2 font-medium font-instrument_sans text-base min-h-[150px] max-h-[300px] border-[#5146E5] focus-visible:ring-offset-0 focus-visible:ring-[#5146E5] overflow-y-auto custom_scrollbar `}
/>
</div>
<p
className={`text-sm text-gray-500 font-inter font-medium ${showHint ? "opacity-100" : "opacity-0"
}`}
>
Provide specific details about your presentation needs (e.g., topic,
style, key points) for more accurate results
</p>
</div>
);
}
}

View file

@ -1,230 +1,240 @@
'use client'
import React, { useRef, useState } from 'react'
import { File, X, Upload } from 'lucide-react'
import React, { ChangeEvent, useEffect, useMemo, useState } from 'react'
import { File, Paperclip, X } from 'lucide-react'
import { toast } from 'sonner'
import { cn } from '@/lib/utils'
interface FileWithId extends File {
id: string;
}
interface SupportingDocProps {
files: File[];
onFilesChange: (files: File[]) => void;
files: File[]
onFilesChange: (files: File[]) => void
accept?: string
multiple?: boolean
}
const SupportingDoc = ({ files, onFilesChange }: SupportingDocProps) => {
const PDF_TYPES = ['.pdf']
const TEXT_TYPES = ['.txt']
const POWERPOINT_TYPES = ['.pptx']
const WORD_TYPES = ['.docx']
const ACCEPT_DEFAULT = [
'application/pdf',
'text/plain',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
...PDF_TYPES,
...TEXT_TYPES,
...POWERPOINT_TYPES,
...WORD_TYPES,
].join(',')
const ALLOWED_MIME_PREFIXES: string[] = []
const ALLOWED_MIME_TYPES = [
'application/pdf',
'application/x-pdf',
'application/acrobat',
'applications/pdf',
'text/pdf',
'application/vnd.pdf',
'text/plain',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
]
const ALLOWED_EXTENSIONS = [
...PDF_TYPES,
...TEXT_TYPES,
...POWERPOINT_TYPES,
...WORD_TYPES,
]
const SupportingDoc = ({
files,
onFilesChange,
accept = ACCEPT_DEFAULT,
multiple = true,
}: SupportingDocProps) => {
const [isDragging, setIsDragging] = useState(false)
const fileInputRef = useRef<HTMLInputElement>(null)
const [previewUrls, setPreviewUrls] = useState<(string | null)[]>([])
// Convert Files to FileWithId with proper type checking
const filesWithIds: FileWithId[] = files.map(file => {
const fileWithId = file as FileWithId
fileWithId.id = `${file.name || 'unnamed'}-${file.lastModified || Date.now()}-${file.size || 0}`
return fileWithId
})
const hasFiles = files.length > 0
const formatFileSize = (bytes: number): string => {
if (!bytes || bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
const filteredFiles = useMemo(() => {
return files.filter(isAllowedFile)
}, [files])
useEffect(() => {
const urls = filteredFiles.map((file) => (file.type.startsWith('image/') ? URL.createObjectURL(file) : null))
setPreviewUrls(urls)
return () => {
urls.forEach((url) => {
if (url) URL.revokeObjectURL(url)
})
}
}, [filteredFiles])
const handleValidate = (filesToReview: File[]) => {
const disallowed = filesToReview.filter((file) => !isAllowedFile(file))
if (disallowed.length > 0) {
toast.error('Some files are not supported', {
description: 'Only PDF, TXT, PPTX, and DOCX files are allowed.',
})
}
}
const handleDragEvents = (e: React.DragEvent<HTMLDivElement>, isDragging: boolean) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(isDragging)
const handleFilesSelected = (e: ChangeEvent<HTMLInputElement>) => {
const selectedFiles = Array.from(e.target.files ?? [])
if (selectedFiles.length === 0) return
const nextFiles = multiple ? [...files, ...selectedFiles] : [selectedFiles[0]]
const allowedFiles = nextFiles.filter(isAllowedFile)
onFilesChange(allowedFiles)
handleValidate(nextFiles)
if (allowedFiles.length > files.length) {
toast.success('Files selected', {
description: `${allowedFiles.length - files.length} file(s) have been added`,
})
}
e.currentTarget.value = ''
}
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
const handleDrop = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault()
e.stopPropagation()
setIsDragging(false)
const droppedFiles = Array.from(e.dataTransfer.files);
const hasPdf = files.some(file => file.type === 'application/pdf');
const droppedFiles = Array.from(e.dataTransfer.files ?? [])
if (droppedFiles.length === 0) return
const validTypes = [
'application/pdf',
'text/plain',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
];
const invalidFiles = droppedFiles.filter(file => !validTypes.includes(file.type));
if (invalidFiles.length > 0) {
toast.error('Invalid file type', {
description: 'Please upload only PDF, TXT, PPTX, or DOCX files',
});
return;
}
if (hasPdf && droppedFiles.some(file => file.type === 'application/pdf')) {
toast.error('Multiple PDF files are not allowed', {
description: 'Please select only one PDF file',
});
return;
}
const validFiles = droppedFiles.filter(file => {
return !(hasPdf && file.type === 'application/pdf');
});
if (validFiles.length > 0) {
const updatedFiles = [...files, ...validFiles]
onFilesChange(updatedFiles)
const nextFiles = multiple ? [...files, ...droppedFiles] : [droppedFiles[0]]
const allowedFiles = nextFiles.filter(isAllowedFile)
onFilesChange(allowedFiles)
handleValidate(nextFiles)
if (allowedFiles.length > files.length) {
toast.success('Files selected', {
description: `${validFiles.length} file(s) have been added`,
description: `${allowedFiles.length - files.length} file(s) have been added`,
})
}
}
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = Array.from(e.target.files || []);
const hasPdf = files.some(file => file.type === 'application/pdf');
const validFiles = selectedFiles.filter(file => {
return !(hasPdf && file.type === 'application/pdf');
});
if (validFiles.length > 0) {
const updatedFiles = [...files, ...validFiles]
onFilesChange(updatedFiles)
toast.success('Files selected', {
description: `${validFiles.length} file(s) have been added`,
})
}
const handleDragOver = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault()
setIsDragging(true)
}
const removeFile = (fileId: string) => {
const updatedFiles = files.filter(file => {
const currentFileId = `${file.name || 'unnamed'}-${file.lastModified || Date.now()}-${file.size || 0}`
return currentFileId !== fileId
})
onFilesChange(updatedFiles)
const handleDragLeave = (e: React.DragEvent<HTMLLabelElement>) => {
e.preventDefault()
setIsDragging(false)
}
const handleRemoveFileAt = (index: number) => {
const nextFiles = filteredFiles.filter((_, i) => i !== index)
onFilesChange(nextFiles)
}
const handleClearFiles = () => {
if (!hasFiles) return
onFilesChange([])
}
return (
<div className="w-full">
<h2 className="text-[#444] font-instrument_sans pt-4 text-lg mb-4">Supporting Documents</h2>
<div
onClick={() => fileInputRef.current?.click()}
className={cn(
"w-full border-2 border-dashed border-gray-400 rounded-lg",
"transition-all duration-300 ease-in-out bg-white",
"min-h-[300px] flex flex-col mb-8",
isDragging && "border-purple-400 bg-purple-50"
)}
onDragOver={(e) => handleDragEvents(e, true)}
onDragLeave={(e) => handleDragEvents(e, false)}
<div className="space-y-2" data-testid="attachments-uploader">
<div className="flex items-center justify-between">
<p className="text-sm text-gray-600 font-syne">
{hasFiles ? `${filteredFiles.length} attachment${filteredFiles.length > 1 ? 's' : ''}` : 'No attachments yet'}
</p>
<button
type="button"
onClick={handleClearFiles}
disabled={!hasFiles}
className={`text-sm font-medium font-syne ${!hasFiles ? 'cursor-not-allowed text-gray-400' : 'text-red-600 hover:text-red-700'}`}
data-testid="attachments-clear-button"
aria-disabled={!hasFiles}
>
Clear all
</button>
</div>
<label
className={`mt-1 block cursor-pointer rounded-lg border-2 border-dashed px-4 py-6 text-center transition-colors ${isDragging ? 'border-[#5146E5] bg-[#5146E5]/5' : 'border-gray-200 hover:border-[#5146E5]'}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<div className="flex-1 flex flex-col items-center justify-center p-6">
<Upload className={cn(
"w-12 h-12 text-gray-400 mb-4",
isDragging && "text-purple-400"
)} />
<p className="text-gray-600 text-center mb-2">
{isDragging
? 'Drop your file here'
: 'Drag and drop your file here or click below button'
}
<input
type="file"
className="hidden"
onChange={handleFilesSelected}
accept={accept}
multiple={multiple}
data-testid="file-upload-input"
/>
<div className="flex flex-col items-center gap-2">
<Paperclip className="h-6 w-6 text-[#5146E5]" />
<p className="text-sm font-medium text-gray-800 font-syne">
Drag and drop PDF, TXT, PPTX, DOCX, or <span className="text-[#5146E5]">click to browse</span>
</p>
<p className="text-gray-400 text-sm text-center mb-4">
Supports PDFs, Text files, PPTX, DOCX
</p>
<input
type="file"
accept=".pdf,.txt,.pptx,.docx"
onChange={handleFileInput}
className="hidden"
id="file-upload"
ref={fileInputRef}
multiple
data-testid="file-upload-input"
/>
<button
onClick={(e) => {
e.stopPropagation()
fileInputRef.current?.click()
}}
className="px-6 py-2 bg-purple-600 text-white rounded-full
hover:bg-purple-700 transition-colors duration-200
font-medium text-sm"
>
Choose Files
</button>
</div>
</label>
{files.length > 0 && (
<div className="border-t border-gray-200 bg-gray-50 rounded-b-lg">
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-gray-700">
Selected Files ({files.length})
</h3>
</div>
<div data-testid="file-list" className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
{filesWithIds.map((file) => {
{hasFiles && (
<div className="mt-2">
<ul data-testid="file-list" className="grid grid-cols-1 gap-2 sm:grid-cols-2" aria-label="Attached files">
{filteredFiles.map((file, idx) => (
<li
key={`${file.name}-${idx}`}
className="flex items-center gap-3 rounded-md border border-gray-200 px-3 py-2"
data-testid="attached-file-item"
>
{previewUrls[idx] ? (
<img src={previewUrls[idx] as string} alt="Preview" className="h-10 w-10 flex-none rounded object-cover" />
) : (
<div className="flex h-10 w-10 flex-none items-center justify-center rounded bg-gray-100 text-gray-600">
<File className="h-5 w-5" />
</div>
)}
return (
(
<div key={file.id}
className="bg-white rounded-lg border border-gray-200 overflow-hidden
hover:border-purple-200 group relative"
>
<div className="p-4 bg-purple-50 group-hover:bg-purple-100
transition-colors flex items-center justify-center relative"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-gray-900 font-syne" title={file.name}>
{file.name}
</p>
<p className="text-xs text-gray-500 font-syne">{formatFileSize(file.size)}</p>
</div>
<File className="w-8 h-8 text-purple-600" />
<button
onClick={(e) => {
e.stopPropagation()
removeFile(file.id)
}}
className="absolute top-1 right-2 p-1.5
bg-white/80 backdrop-blur-sm rounded-full
text-gray-500 hover:text-red-500
shadow-sm hover:shadow-md
transition-all duration-200"
aria-label="Remove file"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="p-3 relative">
<p className="text-sm font-medium text-gray-700 truncate mb-1 pr-2">
{file.name || 'Unnamed File'}
</p>
<p className="text-xs text-gray-500">
{formatFileSize(file.size)}
</p>
</div>
</div>
)
)
})}
</div>
</div>
</div>
)}
</div>
<button
type="button"
onClick={() => handleRemoveFileAt(idx)}
className="ml-2 inline-flex h-8 w-8 items-center justify-center rounded text-red-600 hover:bg-red-50 hover:text-red-700"
aria-label={`Remove ${file.name}`}
data-testid="remove-file-button"
>
<X className="h-5 w-5" />
</button>
</li>
))}
</ul>
{filteredFiles.length !== files.length && (
<p className="mt-2 text-xs text-amber-600 font-syne">
Some files were skipped. Only PDF, TXT, PPTX, and DOCX files are supported.
</p>
)}
</div>
)}
</div>
)
}
const formatFileSize = (bytes: number): string => {
if (!bytes || bytes <= 0) return '0 KB'
return `${(bytes / 1024).toFixed(1)} KB`
}
function isAllowedFile(file: File): boolean {
const type = (file.type || '').toLowerCase()
const name = (file.name || '').toLowerCase()
const typeAllowed = ALLOWED_MIME_TYPES.includes(type) || ALLOWED_MIME_PREFIXES.some((prefix) => type.startsWith(prefix))
if (typeAllowed) return true
return ALLOWED_EXTENSIONS.some((ext) => name.endsWith(ext))
}
export default SupportingDoc

View file

@ -14,9 +14,8 @@ import React, { useState } from "react";
import { useRouter, usePathname } from "next/navigation";
import { useDispatch } from "react-redux";
import { clearOutlines, setPresentationId } from "@/store/slices/presentationGeneration";
import { ConfigurationSelects } from "./ConfigurationSelects";
import { PromptInput } from "./PromptInput";
import { LanguageType, PresentationConfig, ToneType, VerbosityType } from "../type";
import { LanguageType, PresentationConfig, ToneType, VerbosityType } from "../type";
import SupportingDoc from "./SupportingDoc";
import { Button } from "@/components/ui/button";
import { ChevronRight } from "lucide-react";
@ -26,6 +25,7 @@ import { OverlayLoader } from "@/components/ui/overlay-loader";
import Wrapper from "@/components/Wrapper";
import { setPptGenUploadState } from "@/store/slices/presentationGenUpload";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
import { ConfigurationSelects } from "./ConfigurationSelects";
// Types for loading state
interface LoadingState {
@ -44,7 +44,7 @@ const UploadPage = () => {
// State management
const [files, setFiles] = useState<File[]>([]);
const [config, setConfig] = useState<PresentationConfig>({
slides: "8",
slides: "5",
language: LanguageType.English,
prompt: "",
tone: ToneType.Default,
@ -202,36 +202,58 @@ const UploadPage = () => {
duration={loadingState.duration}
extra_info={loadingState.extra_info}
/>
<div className="flex flex-col gap-4 md:items-center md:flex-row justify-between py-4">
<p></p>
<ConfigurationSelects
config={config}
onConfigChange={handleConfigChange}
/>
</div>
<div className="rounded-2xl border border-slate-200/70 bg-white/80 shadow-sm backdrop-blur supports-[backdrop-filter]:bg-white/60" >
<div className="flex flex-col gap-4 md:items-center md:flex-row justify-between p-4">
<div >
<h2 className="text-lg font-unbounded tracking-tight text-slate-900 ">Configuration</h2>
<p className="text-sm text-slate-500 font-syne">Choose slides, tone, and language preferences.</p>
</div>
<ConfigurationSelects
config={config}
onConfigChange={handleConfigChange}
/>
</div>
<div className="border-t border-slate-200/70" />
<div className="p-4 md:p-6">
<h3 className="text-base font-normal font-unbounded text-slate-900 mb-2">Content</h3>
<div className="relative">
<PromptInput
value={config.prompt}
onChange={(value) => handleConfigChange("prompt", value)}
data-testid="prompt-input"
/>
</div>
</div>
<div className="border-t border-slate-200/70" />
<div className="p-4 md:p-6">
<h3 className="text-base font-normal font-unbounded text-slate-900 mb-2">Attachments (optional)</h3>
<SupportingDoc
files={[...files]}
onFilesChange={setFiles}
data-testid="file-upload-input"
/>
</div>
<div className="border-t border-slate-200/70" />
<div className="p-4 md:p-6">
<Button
onClick={handleGeneratePresentation}
className="w-full rounded-[28px] flex items-center justify-center py-5 bg-[#5141e5] text-white font-syne font-semibold text-lg hover:bg-[#5141e5]/85 focus-visible:ring-2 focus-visible:ring-[#5141e5]/40"
data-testid="next-button"
>
<span>Generate Presentation</span>
<ChevronRight className="!w-5 !h-5 ml-1.5" />
</Button>
</div>
<div className="relative">
<PromptInput
value={config.prompt}
onChange={(value) => handleConfigChange("prompt", value)}
data-testid="prompt-input"
/>
</div>
<SupportingDoc
files={[...files]}
onFilesChange={setFiles}
data-testid="file-upload-input"
/>
<Button
onClick={handleGeneratePresentation}
className="w-full rounded-[32px] flex items-center justify-center py-6 bg-[#5141e5] text-white font-instrument_sans font-semibold text-xl hover:bg-[#5141e5]/80 transition-colors duration-300"
data-testid="next-button"
>
<span>Next</span>
<ChevronRight className="!w-6 !h-6" />
</Button>
</Wrapper>
);
};
export default UploadPage;
export default UploadPage;

View file

@ -1,4 +1,4 @@
import Header from "@/app/(presentation-generator)/dashboard/components/Header";
import Header from "@/app/(presentation-generator)/(dashboard)/dashboard/components/Header";
import { Skeleton } from "@/components/ui/skeleton";
import React from "react";

View file

@ -1,7 +1,7 @@
import React from "react";
import UploadPage from "./components/UploadPage";
import Header from "@/app/(presentation-generator)/dashboard/components/Header";
import Header from "@/app/(presentation-generator)/(dashboard)/dashboard/components/Header";
import { Metadata } from "next";
export const metadata: Metadata = {
@ -45,11 +45,11 @@ const page = () => {
return (
<div className="relative">
<Header />
<div className="flex flex-col items-center justify-center py-8">
<h1 className="text-3xl font-semibold font-instrument_sans">
Create Presentation{" "}
<div className="flex flex-col items-center justify-center mb-8">
<h1 className="text-[64px] font-normal font-unbounded text-[#101323] ">
AI Presentation
</h1>
{/* <p className='text-sm text-gray-500'>We will generate a presentation for you</p> */}
<p className="text-xl font-syne text-[#101323CC]">Choose a design, set preferences, and generate polished slides.</p>
</div>
<UploadPage />

View file

@ -4,7 +4,7 @@
body {
font-family: var(--font-inter), var(--font-roboto), sans-serif;
font-family: var(--font-inter), var(--font-unbounded), var(--font-syne), sans-serif;
}
@layer utilities {
@ -449,7 +449,7 @@ thead {
}
.container__editor {
font-variant-ligatures: common-ligatures;
background-color: #fafafa;
border-radius: 3px;
@ -458,6 +458,7 @@ thead {
.container__editor textarea {
outline: 0;
}
/* Syntax highlighting */
.token.comment,
.token.prolog,
@ -465,12 +466,15 @@ thead {
.token.cdata {
color: #90a4ae;
}
.token.punctuation {
color: #9e9e9e;
}
.namespace {
opacity: 0.7;
}
.token.property,
.token.tag,
.token.boolean,
@ -480,6 +484,7 @@ thead {
.token.deleted {
color: #e91e63;
}
.token.selector,
.token.attr-name,
.token.string,
@ -488,6 +493,7 @@ thead {
.token.inserted {
color: #4caf50;
}
.token.operator,
.token.entity,
.token.url,
@ -495,26 +501,32 @@ thead {
.style .token.string {
color: #795548;
}
.token.atrule,
.token.attr-value,
.token.keyword {
color: #3f51b5;
}
.token.function {
color: #f44336;
}
.token.regex,
.token.important,
.token.variable {
color: #ff9800;
}
.token.important,
.token.bold {
font-weight: bold;
}
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}

View file

@ -1,5 +1,4 @@
import React from "react";
import { getFastAPIUrl } from "@/utils/api";
export type RemoteSvgOptions = {
strokeColor?: string;
@ -145,14 +144,8 @@ export function useRemoteSvgIcon(url?: string, options: RemoteSvgOptions = {}) {
return;
}
try {
// If URL starts with /static/, proxy it through FastAPI
let fetchUrl = url;
if (url.startsWith('/static/')) {
const fastApiUrl = getFastAPIUrl();
fetchUrl = `${fastApiUrl}${url}`;
}
const res = await fetch(fetchUrl);
const res = await fetch(url);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}

View file

@ -1,5 +1,6 @@
import type { Metadata } from "next";
import localFont from "next/font/local";
import { Syne, Unbounded } from "next/font/google";
import "./globals.css";
import { Providers } from "./providers";
import MixpanelInitializer from "./MixpanelInitializer";
@ -15,26 +16,16 @@ const inter = localFont({
variable: "--font-inter",
});
const instrument_sans = localFont({
src: [
{
path: "./fonts/Inter.ttf",
weight: "400",
style: "normal",
},
],
variable: "--font-instrument-sans",
const syne = Syne({
subsets: ["latin"],
weight: ["400", "500", "600", "700", "800"],
variable: "--font-syne",
});
const roboto = localFont({
src: [
{
path: "./fonts/Inter.ttf",
weight: "400",
style: "normal",
},
],
variable: "--font-roboto",
const unbounded = Unbounded({
subsets: ["latin"],
weight: ["400", "500", "600", "700", "800"],
variable: "--font-unbounded",
});
@ -91,7 +82,7 @@ export default function RootLayout({
return (
<html lang="en">
<body
className={`${inter.variable} ${roboto.variable} ${instrument_sans.variable} antialiased`}
className={`${inter.variable} ${unbounded.variable} ${syne.variable} antialiased`}
>
<Providers>
<MixpanelInitializer>

View file

@ -162,7 +162,7 @@ const ChartWithBulletsSlideLayout: React.FC<ChartWithBulletsSlideLayoutProps> =
<YAxis {...axisProps} />
{showTooltip && <Tooltip content={<CustomTooltip />} />}
{showLegend && <Legend wrapperStyle={{ fontSize: '10px' }} />}
<Bar dataKey={yAxis} barSize={70} radius={[8, 8, 0, 0]} isAnimationActive={false} >
<Bar dataKey={yAxis} barSize={70} radius={[8, 8, 0, 0]} >
{chartData.map((_, index) => (
<Cell key={`cell-${index}`} fill={`var(--graph-${index}, ${CHART_COLORS[index % CHART_COLORS.length]})`} />
))}
@ -182,7 +182,6 @@ const ChartWithBulletsSlideLayout: React.FC<ChartWithBulletsSlideLayoutProps> =
type="monotone"
dataKey={yAxis}
strokeWidth={3}
isAnimationActive={false}
dot={{ fill: `var(--graph-0, ${CHART_COLORS[0]})`, strokeWidth: 2, r: 4 }}
>
{chartData.map((_, index) => (
@ -204,7 +203,6 @@ const ChartWithBulletsSlideLayout: React.FC<ChartWithBulletsSlideLayoutProps> =
type="monotone"
dataKey={yAxis}
fillOpacity={0.6}
isAnimationActive={false}
>
{chartData.map((_, index) => (
<Cell key={`cell-${index}`} fill={`var(--graph-${index}, ${CHART_COLORS[index % CHART_COLORS.length]})`} />
@ -224,7 +222,6 @@ const ChartWithBulletsSlideLayout: React.FC<ChartWithBulletsSlideLayoutProps> =
fill={`var(--background-text, ${color})`}
dataKey={yAxis}
label={renderPieLabel}
isAnimationActive={false}
>
{chartData.map((_, index) => (
<Cell key={`cell-${index}`} fill={`var(--graph-${index}, ${CHART_COLORS[index % CHART_COLORS.length]})`} />
@ -241,7 +238,7 @@ const ChartWithBulletsSlideLayout: React.FC<ChartWithBulletsSlideLayoutProps> =
<YAxis dataKey={yAxis} type="number" {...axisProps} />
{showTooltip && <Tooltip content={<CustomTooltip />} />}
{showLegend && <Legend wrapperStyle={{ fontSize: '10px' }} />}
<Scatter dataKey="value" isAnimationActive={false} >
<Scatter dataKey="value" >
{chartData.map((_, index) => (
<Cell key={`cell-${index}`} fill={`var(--graph-${index}, ${CHART_COLORS[index % CHART_COLORS.length]})`} />
))}
@ -297,13 +294,13 @@ const ChartWithBulletsSlideLayout: React.FC<ChartWithBulletsSlideLayoutProps> =
</p>
{/* Chart Container */}
<div className="flex-1 min-h-[280px] rounded-lg shadow-sm border border-gray-100 p-4"
<div className="flex-1 rounded-lg shadow-sm border border-gray-100 p-4"
style={{
borderColor: 'var(--stroke, #F8F9FA)',
}}
>
{/* <ChartContainer config={chartConfig} className="h-full w-full"> */}
<ResponsiveContainer width="100%" height="100%" className="">
<ResponsiveContainer maxHeight={460} height='100%' className="">
{renderChart()}
</ResponsiveContainer>

View file

@ -176,6 +176,7 @@ import neoStandardSettings from "./neo-standard/settings.json";
import neoModernSettings from "./neo-modern/settings.json";
import neoSwiftSettings from "./neo-swift/settings.json";
// Helper to create template entry
@ -223,6 +224,7 @@ export const neoGeneralTemplates: TemplateWithData[] = [
createTemplateEntry(TitleDescriptionMultiChartGridWithMetricsLayout, TitleDescriptionMultiChartGridWithMetricsSchema, TitleDescriptionMultiChartGridWithMetricsId, TitleDescriptionMultiChartGridWithMetricsName, TitleDescriptionMultiChartGridWithMetricsDesc, "neo-general", "TitleDescriptionMultiChartGridWithMetrics"),
createTemplateEntry(TitleDescriptionMultiChartGridWithBulletsLayout, TitleDescriptionMultiChartGridWithBulletsSchema, TitleDescriptionMultiChartGridWithBulletsId, TitleDescriptionMultiChartGridWithBulletsName, TitleDescriptionMultiChartGridWithBulletsDesc, "neo-general", "TitleDescriptionMultiChartGridWithBullets"),
]
export const neoStandardTemplates: TemplateWithData[] = [
createTemplateEntry(TitleBadgeChartLayout, TitleBadgeChartSchema, TitleBadgeChartId, TitleBadgeChartName, TitleBadgeChartDesc, "neo-standard", "TitleBadgeChartLayout"),
createTemplateEntry(TitleDescriptionBulletListStandardLayout, TitleDescriptionBulletListStandardSchema, TitleDescriptionBulletListStandardId, TitleDescriptionBulletListStandardName, TitleDescriptionBulletListStandardDesc, "neo-standard", "TitleDescriptionBulletList"),
@ -351,6 +353,7 @@ export const allLayouts: TemplateWithData[] = [
...standardTemplates,
...swiftTemplates,
];

View file

@ -13,8 +13,8 @@ import {
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { getApiUrl } from "@/utils/api";
import { Switch } from "./ui/switch";
import { getApiUrl } from "@/utils/api";
interface AnthropicConfigProps {
anthropicApiKey: string;
@ -54,7 +54,7 @@ export default function AnthropicConfig({
setModelsLoading(true);
try {
const response = await fetch(getApiUrl('api/v1/ppt/anthropic/models/available'), {
const response = await fetch(getApiUrl('/api/v1/ppt/anthropic/models/available'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@ -85,167 +85,172 @@ export default function AnthropicConfig({
};
return (
<div className="space-y-6">
<div className="space-y-6 ">
{/* API Key Input */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Anthropic API Key
</label>
<div className="relative">
<input
type="text"
value={anthropicApiKey}
onChange={(e) => onApiKeyChange(e.target.value)}
className="w-full px-4 py-2.5 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
placeholder="Enter your Anthropic API key"
/>
</div>
<p className="mt-2 text-sm text-gray-500 flex items-center gap-2">
<span className="block w-1 h-1 rounded-full bg-gray-400"></span>
Your API key will be stored locally and never shared
</p>
</div>
{/* Extended Reasoning Toggle */}
{/* <div>
<div className="flex items-center justify-between mb-4 bg-green-50 p-2 rounded-sm">
<label className="text-sm font-medium text-gray-700">
Extended Reasoning
</label>
<Switch
checked={extendedReasoning}
onCheckedChange={(checked) => onInputChange(checked, "extended_reasoning")}
/>
</div>
<p className="mt-2 text-sm text-gray-500 flex items-center gap-2">
<span className="block w-1 h-1 rounded-full bg-gray-400"></span>
Enable extended reasoning for more detailed and thorough responses
</p>
</div> */}
{/* Check for available models button - show when no models checked or no models found */}
{(!modelsChecked || (modelsChecked && availableModels.length === 0)) && (
<div className="mb-4">
<button
onClick={fetchAvailableModels}
disabled={modelsLoading || !anthropicApiKey}
className={`w-full py-2.5 px-4 rounded-lg transition-all duration-200 border-2 ${modelsLoading || !anthropicApiKey
? "bg-gray-100 border-gray-300 cursor-not-allowed text-gray-500"
: "bg-white border-blue-600 text-blue-600 hover:bg-blue-50 focus:ring-2 focus:ring-blue-500/20"
}`}
>
{modelsLoading ? (
<div className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
Checking for models...
</div>
) : (
"Check for available models"
)}
</button>
</div>
)}
{/* Show message if no models found */}
{modelsChecked && availableModels.length === 0 && (
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm text-yellow-800">
No models found. Please make sure your API key is valid and has access to Anthropic models.
<div className="mb-4 flex items-center justify-between bg-white p-10">
<div className="">
<h3 className="text-xl font-normal text-[#191919]">Anthropic API key</h3>
<p className="mt-2 text-sm max-w-[205px] text-gray-500">
Your API key will be stored locally and never shared
</p>
</div>
)}
<div className="flex items-center gap-4">
<div className="relative w-[275px] ">
<div className="flex flex-col justify-start gap-2">
<label className="block text-sm font-medium text-gray-700 mb-2">
Anthropic API Key
</label>
<input
type="text"
value={anthropicApiKey}
onChange={(e) => onApiKeyChange(e.target.value)}
className="w-full px-2 py-3 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
placeholder="Enter your Anthropic API key"
/>
</div>
{/* Model Selection - only show if models are available */}
{modelsChecked && availableModels.length > 0 ? (
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
Select Anthropic Model
</label>
<div className="w-full">
<Popover
open={openModelSelect}
onOpenChange={setOpenModelSelect}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openModelSelect}
className="w-full h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between"
>
<div className="flex gap-3 items-center">
<span className="text-sm font-medium text-gray-900">
{anthropicModel
? availableModels.find(model => model === anthropicModel) || anthropicModel
: "Select a model"}
</span>
</div>
<ChevronsUpDown className="w-4 h-4 text-gray-500" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
align="start"
style={{ width: "var(--radix-popover-trigger-width)" }}
{/* Check for available models button - show when no models checked or no models found */}
{(!modelsChecked || (modelsChecked && availableModels.length === 0)) && (
<button
onClick={fetchAvailableModels}
disabled={modelsLoading || !anthropicApiKey}
className={` mt-7 py-2.5 bg-[#F7F6F9] px-3.5 rounded-[48px] text-xs font-semibold text-[#101323] transition-all duration-200 border ${modelsLoading || !anthropicApiKey
? " border-gray-300 cursor-not-allowed text-gray-500"
: " border-[#EDEEEF] text-blue-600 hover:bg-[#E8F0FF]/90 focus:ring-2 focus:ring-blue-500/20"
}`}
>
<Command>
<CommandInput placeholder="Search models..." />
<CommandList>
<CommandEmpty>No model found.</CommandEmpty>
<CommandGroup>
{availableModels.map((model, index) => (
<CommandItem
key={index}
value={model}
onSelect={(value) => {
onInputChange(value, "anthropic_model");
setOpenModelSelect(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
anthropicModel === model
? "opacity-100"
: "opacity-0"
)}
/>
<div className="flex gap-3 items-center">
<div className="flex flex-col space-y-1 flex-1">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-gray-900">
{model}
</span>
</div>
</div>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{modelsLoading ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
Checking for models...
</span>
) : (
"Check for available models"
)}
</button>
)}
</div>
<div className="w-[295px]">
{/* Show message if no models found */}
{modelsChecked && availableModels.length === 0 && (
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm text-yellow-800">
No models found. Please make sure your API key is valid and has access to Anthropic models.
</p>
</div>
)}
{/* Model Selection - only show if models are available */}
{modelsChecked && availableModels.length > 0 ? (
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
Select Anthropic Model
</label>
<div className="w-full">
<Popover
open={openModelSelect}
onOpenChange={setOpenModelSelect}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openModelSelect}
className="w-full h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between"
>
<div className="flex gap-3 items-center">
<span className="text-sm font-medium text-gray-900">
{anthropicModel
? availableModels.find(model => model === anthropicModel) || anthropicModel
: "Select a model"}
</span>
</div>
<ChevronsUpDown className="w-4 h-4 text-gray-500" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
align="start"
style={{ width: "var(--radix-popover-trigger-width)" }}
>
<Command>
<CommandInput placeholder="Search models..." />
<CommandList>
<CommandEmpty>No model found.</CommandEmpty>
<CommandGroup>
{availableModels.map((model, index) => (
<CommandItem
key={index}
value={model}
onSelect={(value) => {
onInputChange(value, "anthropic_model");
setOpenModelSelect(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
anthropicModel === model
? "opacity-100"
: "opacity-0"
)}
/>
<div className="flex gap-3 items-center">
<div className="flex flex-col space-y-1 flex-1">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-gray-900">
{model}
</span>
</div>
</div>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
) : null}
</div>
</div>
) : null}
</div>
{/* Web Grounding Toggle - at the end, below models dropdown */}
<div>
<div className="flex items-center justify-between mb-4 bg-green-50 p-2 rounded-sm">
<label className="text-sm font-medium text-gray-700">
Enable Web Grounding
</label>
<Switch
checked={!!webGrounding}
onCheckedChange={(checked) => onInputChange(checked, "web_grounding")}
/>
{/* Web Grounding Toggle - show at the end, below models dropdown */}
<div className="bg-white flex justify-between items-center p-10 rounded-[12px]">
<div>
<h4 className="text-xl font-normal text-[#191919]">Model Controls</h4>
<p className="mt-2 text-sm max-w-[205px] text-gray-500">
Configure web access and advanced AI features.
</p>
</div>
<div className="flex items-center gap-4">
<div className="w-[275px]">
<div className="flex items-center mb-4 gap-2.5 ">
<Switch
checked={!!webGrounding}
onCheckedChange={(checked) => onInputChange(checked, "web_grounding")}
/>
<label className="text-sm font-medium text-gray-700">
Enable Web Grounding
</label>
</div>
{/* Extended Reasoning Toggle */}
{/* <div className="flex items-center mb-4 gap-2.5 ">
<Switch
checked={extendedReasoning}
onCheckedChange={(checked) => onInputChange(checked, "extended_reasoning")}
/>
<label className="text-sm font-medium text-gray-700">
Extended Reasoning
</label>
</div> */}
</div>
<div className="w-[295px]"></div>
</div>
<p className="mt-2 text-sm text-gray-500 flex items-center gap-2">
<span className="block w-1 h-1 rounded-full bg-gray-400"></span>
If enabled, the model can use web search grounding when available.
</p>
</div>
</div>
);
}
}

View file

@ -83,7 +83,7 @@ export default function CodexConfig({
const checkCurrentAuthStatus = async () => {
try {
const res = await fetch(getApiUrl("api/v1/ppt/codex/auth/status"));
const res = await fetch(getApiUrl("/api/v1/ppt/codex/auth/status"));
if (!res.ok) {
setAuthStatus("unauthenticated");
return;
@ -102,7 +102,7 @@ export default function CodexConfig({
const handleSignIn = async () => {
try {
const res = await fetch(getApiUrl("api/v1/ppt/codex/auth/initiate"), {
const res = await fetch(getApiUrl("/api/v1/ppt/codex/auth/initiate"), {
method: "POST",
});
if (!res.ok) throw new Error("Failed to initiate auth");
@ -117,7 +117,7 @@ export default function CodexConfig({
pollIntervalRef.current = setInterval(async () => {
try {
const pollRes = await fetch(
getApiUrl(`api/v1/ppt/codex/auth/status/${session_id}`)
getApiUrl(`/api/v1/ppt/codex/auth/status/${session_id}`)
);
if (!pollRes.ok) return;
const pollData: StatusResponse = await pollRes.json();
@ -151,7 +151,7 @@ export default function CodexConfig({
if (!sessionId || !manualCode.trim()) return;
setIsExchanging(true);
try {
const res = await fetch(getApiUrl("api/v1/ppt/codex/auth/exchange"), {
const res = await fetch(getApiUrl("/api/v1/ppt/codex/auth/exchange"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: sessionId, code: manualCode.trim() }),
@ -187,7 +187,7 @@ export default function CodexConfig({
const handleSignOut = async () => {
setIsLoggingOut(true);
try {
await fetch(getApiUrl("api/v1/ppt/codex/auth/logout"), { method: "POST" });
await fetch(getApiUrl("/api/v1/ppt/codex/auth/logout"), { method: "POST" });
setAuthStatus("unauthenticated");
setAccountId(null);
onInputChange("", "codex_model");
@ -202,7 +202,7 @@ export default function CodexConfig({
const handleRefreshToken = async () => {
setIsRefreshing(true);
try {
const res = await fetch(getApiUrl("api/v1/ppt/codex/auth/refresh"), {
const res = await fetch(getApiUrl("/api/v1/ppt/codex/auth/refresh"), {
method: "POST",
});
if (!res.ok) throw new Error("Refresh failed");

View file

@ -61,7 +61,7 @@ export default function CustomConfig({
try {
setCustomModelsLoading(true);
const response = await fetch(getApiUrl("api/v1/ppt/openai/models/available"), {
const response = await fetch(getApiUrl("/api/v1/ppt/openai/models/available"), {
method: "POST",
headers: {
"Content-Type": "application/json",

View file

@ -13,8 +13,8 @@ import {
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { getApiUrl } from "@/utils/api";
import { Switch } from "./ui/switch";
import { getApiUrl } from "@/utils/api";
interface GoogleConfigProps {
googleApiKey: string;
@ -51,7 +51,7 @@ export default function GoogleConfig({
setModelsLoading(true);
try {
const response = await fetch(getApiUrl('api/v1/ppt/google/models/available'), {
const response = await fetch(getApiUrl('/api/v1/ppt/google/models/available'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
@ -82,150 +82,162 @@ export default function GoogleConfig({
};
return (
<div className="space-y-6">
<div className="space-y-6 ">
{/* API Key Input */}
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Google API Key
</label>
<div className="relative">
<input
type="text"
value={googleApiKey}
onChange={(e) => onApiKeyChange(e.target.value)}
className="w-full px-4 py-2.5 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
placeholder="Enter your API key"
/>
</div>
<p className="mt-2 text-sm text-gray-500 flex items-center gap-2">
<span className="block w-1 h-1 rounded-full bg-gray-400"></span>
Your API key will be stored locally and never shared
</p>
</div>
{/* Check for available models button - show when no models checked or no models found */}
{(!modelsChecked || (modelsChecked && availableModels.length === 0)) && (
<div className="mb-4">
<button
onClick={fetchAvailableModels}
disabled={modelsLoading || !googleApiKey}
className={`w-full py-2.5 px-4 rounded-lg transition-all duration-200 border-2 ${modelsLoading || !googleApiKey
? "bg-gray-100 border-gray-300 cursor-not-allowed text-gray-500"
: "bg-white border-blue-600 text-blue-600 hover:bg-blue-50 focus:ring-2 focus:ring-blue-500/20"
}`}
>
{modelsLoading ? (
<div className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
Checking for models...
</div>
) : (
"Check for available models"
)}
</button>
</div>
)}
{/* Show message if no models found */}
{modelsChecked && availableModels.length === 0 && (
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm text-yellow-800">
No models found. Please make sure your API key is valid and has access to Google models.
<div className="mb-4 flex items-center justify-between bg-white p-10">
<div className="">
<h3 className="text-xl font-normal text-[#191919]">Google API key</h3>
<p className="mt-2 text-sm max-w-[205px] text-gray-500">
Your API key will be stored locally and never shared
</p>
</div>
)}
<div className="flex items-center gap-4">
<div className="relative w-[275px] ">
<div className="flex flex-col justify-start gap-2">
<label className="block text-sm font-medium text-gray-700 mb-2">
Google API Key
</label>
<input
type="text"
value={googleApiKey}
onChange={(e) => onApiKeyChange(e.target.value)}
className="w-full px-2 py-3 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
placeholder="Enter your API key"
/>
</div>
{/* Model Selection - only show if models are available */}
{modelsChecked && availableModels.length > 0 ? (
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
Select Google Model
</label>
<div className="w-full">
<Popover
open={openModelSelect}
onOpenChange={setOpenModelSelect}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openModelSelect}
className="w-full h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between"
>
<div className="flex gap-3 items-center">
<span className="text-sm font-medium text-gray-900">
{googleModel
? availableModels.find(model => model === googleModel) || googleModel
: "Select a model"}
</span>
</div>
<ChevronsUpDown className="w-4 h-4 text-gray-500" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
align="start"
style={{ width: "var(--radix-popover-trigger-width)" }}
{/* Check for available models button - show when no models checked or no models found */}
{(!modelsChecked || (modelsChecked && availableModels.length === 0)) && (
<button
onClick={fetchAvailableModels}
disabled={modelsLoading || !googleApiKey}
className={` mt-7 py-2.5 bg-[#F7F6F9] px-3.5 rounded-[48px] text-xs font-semibold text-[#101323] transition-all duration-200 border ${modelsLoading || !googleApiKey
? " border-gray-300 cursor-not-allowed text-gray-500"
: " border-[#EDEEEF] text-blue-600 hover:bg-[#E8F0FF]/90 focus:ring-2 focus:ring-blue-500/20"
}`}
>
<Command>
<CommandInput placeholder="Search models..." />
<CommandList>
<CommandEmpty>No model found.</CommandEmpty>
<CommandGroup>
{availableModels.map((model, index) => (
<CommandItem
key={index}
value={model}
onSelect={(value) => {
onInputChange(value, "google_model");
setOpenModelSelect(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
googleModel === model
? "opacity-100"
: "opacity-0"
)}
/>
<div className="flex gap-3 items-center">
<div className="flex flex-col space-y-1 flex-1">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-gray-900">
{model}
</span>
</div>
</div>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{modelsLoading ? (
<span className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
Checking for models...
</span>
) : (
"Check for available models"
)}
</button>
)}
</div>
<div className="w-[295px]">
{/* Show message if no models found */}
{modelsChecked && availableModels.length === 0 && (
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm text-yellow-800">
No models found. Please make sure your API key is valid and has access to Google models.
</p>
</div>
)}
{/* Model Selection - only show if models are available */}
{modelsChecked && availableModels.length > 0 ? (
<div>
<label className="block text-sm font-medium text-gray-700 mb-3">
Select Google Model
</label>
<div className="w-full">
<Popover
open={openModelSelect}
onOpenChange={setOpenModelSelect}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openModelSelect}
className="w-full h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between"
>
<div className="flex gap-3 items-center">
<span className="text-sm font-medium text-gray-900">
{googleModel
? availableModels.find(model => model === googleModel) || googleModel
: "Select a model"}
</span>
</div>
<ChevronsUpDown className="w-4 h-4 text-gray-500" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
align="start"
style={{ width: "var(--radix-popover-trigger-width)" }}
>
<Command>
<CommandInput placeholder="Search models..." />
<CommandList>
<CommandEmpty>No model found.</CommandEmpty>
<CommandGroup>
{availableModels.map((model, index) => (
<CommandItem
key={index}
value={model}
onSelect={(value) => {
onInputChange(value, "google_model");
setOpenModelSelect(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
googleModel === model
? "opacity-100"
: "opacity-0"
)}
/>
<div className="flex gap-3 items-center">
<div className="flex flex-col space-y-1 flex-1">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-gray-900">
{model}
</span>
</div>
</div>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
) : null}
</div>
</div>
) : null}
</div>
{/* Web Grounding Toggle - at the end, below models dropdown */}
<div>
<div className="flex items-center justify-between mb-4 bg-green-50 p-2 rounded-sm">
<label className="text-sm font-medium text-gray-700">
Enable Web Grounding
</label>
<Switch
checked={!!webGrounding}
onCheckedChange={(checked) => onInputChange(checked, "web_grounding")}
/>
{/* Web Grounding Toggle - show at the end, below models dropdown */}
<div className="bg-white flex justify-between items-center p-10 rounded-[12px]">
<div>
<h4 className="text-xl font-normal text-[#191919]">Model Controls</h4>
<p className="mt-2 text-sm max-w-[205px] text-gray-500">
Configure web access and advanced AI features.
</p>
</div>
<div className="flex items-center gap-4">
<div className="w-[275px]">
<div className="flex items-center mb-4 gap-2.5 ">
<Switch
checked={!!webGrounding}
onCheckedChange={(checked) => onInputChange(checked, "web_grounding")}
/>
<label className="text-sm font-medium text-gray-700">
Enable Web Grounding
</label>
</div>
</div>
<div className="w-[295px]"></div>
</div>
<p className="mt-2 text-sm text-gray-500 flex items-center gap-2">
<span className="block w-1 h-1 rounded-full bg-gray-400"></span>
If enabled, the model can use web search grounding when available.
</p>
</div>
</div>
);
}
}

View file

@ -14,6 +14,12 @@ import {
import { LLMConfig } from "@/types/llm_config";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
import { usePathname } from "next/navigation";
import OnBoardingSlidebar from "./OnBoarding/OnBoardingSlidebar";
import OnBoardingHeader from "./OnBoarding/OnBoardingHeader";
import ModeSelectStep from "./OnBoarding/ModeSelectStep";
import PresentonMode from "./OnBoarding/PresentonMode";
import GenerationWithImage from "./OnBoarding/GenerationWithImage";
import FinalStep from "./OnBoarding/FinalStep";
// Button state interface
interface ButtonState {
@ -25,9 +31,59 @@ interface ButtonState {
status?: string;
}
const FINAL_STEP_CONFETTI_PIECES = [
// left: denser at top
{ side: "left", offset: 1, top: 3, width: 28, height: 10, color: "#F59E0B", rotate: 12 },
{ side: "left", offset: 7, top: 5, width: 18, height: 7, color: "#7C3AED", rotate: -10 },
{ side: "left", offset: 12, top: 7, width: 20, height: 7, color: "#14B8A6", rotate: 22 },
{ side: "left", offset: 3, top: 10, width: 22, height: 8, color: "#22C55E", rotate: -18 },
{ side: "left", offset: 9, top: 12, width: 24, height: 8, color: "#E11D48", rotate: 18 },
{ side: "left", offset: 14, top: 15, width: 18, height: 7, color: "#F43F5E", rotate: 23 },
{ side: "left", offset: 5, top: 18, width: 20, height: 7, color: "#0EA5E9", rotate: -12 },
{ side: "left", offset: 11, top: 21, width: 26, height: 9, color: "#2563EB", rotate: 20 },
{ side: "left", offset: 2, top: 24, width: 19, height: 7, color: "#14B8A6", rotate: -16 },
{ side: "left", offset: 8, top: 28, width: 21, height: 8, color: "#FB7185", rotate: 27 },
{ side: "left", offset: 13, top: 32, width: 20, height: 7, color: "#06B6D4", rotate: 16 },
{ side: "left", offset: 3, top: 36, width: 24, height: 9, color: "#EAB308", rotate: -22 },
{ side: "left", offset: 10, top: 41, width: 18, height: 7, color: "#A855F7", rotate: -14 },
{ side: "left", offset: 2, top: 50, width: 30, height: 10, color: "#EC4899", rotate: -28 },
{ side: "left", offset: 13, top: 58, width: 19, height: 7, color: "#22C55E", rotate: 17 },
{ side: "left", offset: 5, top: 66, width: 24, height: 8, color: "#8B5CF6", rotate: 14 },
{ side: "left", offset: 11, top: 74, width: 18, height: 7, color: "#3B82F6", rotate: 12 },
{ side: "left", offset: 4, top: 82, width: 20, height: 7, color: "#14B8A6", rotate: 21 },
{ side: "left", offset: 7, top: 90, width: 24, height: 8, color: "#D946EF", rotate: -26 },
// right: denser at top
{ side: "right", offset: 1, top: 4, width: 30, height: 10, color: "#F97316", rotate: -14 },
{ side: "right", offset: 8, top: 6, width: 19, height: 7, color: "#0EA5E9", rotate: 12 },
{ side: "right", offset: 13, top: 9, width: 20, height: 7, color: "#22C55E", rotate: -20 },
{ side: "right", offset: 4, top: 12, width: 24, height: 8, color: "#EC4899", rotate: 20 },
{ side: "right", offset: 10, top: 15, width: 22, height: 8, color: "#06B6D4", rotate: -18 },
{ side: "right", offset: 15, top: 18, width: 20, height: 7, color: "#22C55E", rotate: -25 },
{ side: "right", offset: 5, top: 21, width: 18, height: 7, color: "#8B5CF6", rotate: 19 },
{ side: "right", offset: 12, top: 24, width: 21, height: 8, color: "#F43F5E", rotate: 14 },
{ side: "right", offset: 2, top: 28, width: 26, height: 9, color: "#84CC16", rotate: 15 },
{ side: "right", offset: 9, top: 33, width: 21, height: 8, color: "#F97316", rotate: -11 },
{ side: "right", offset: 14, top: 38, width: 20, height: 7, color: "#A855F7", rotate: -19 },
{ side: "right", offset: 4, top: 44, width: 19, height: 7, color: "#F43F5E", rotate: 20 },
{ side: "right", offset: 2, top: 52, width: 28, height: 10, color: "#FACC15", rotate: 25 },
{ side: "right", offset: 12, top: 60, width: 18, height: 7, color: "#14B8A6", rotate: -15 },
{ side: "right", offset: 6, top: 68, width: 24, height: 8, color: "#22C55E", rotate: -17 },
{ side: "right", offset: 1, top: 76, width: 20, height: 7, color: "#A855F7", rotate: 14 },
{ side: "right", offset: 13, top: 84, width: 20, height: 7, color: "#3B82F6", rotate: -24 },
{ side: "right", offset: 5, top: 92, width: 26, height: 9, color: "#EAB308", rotate: 18 },
] as const;
const getTaperedSideOffset = (offset: number, top: number) => {
const taperMultiplier = Math.max(0.72, 1.85 - top * 0.012);
return Math.min(29, Number((offset * taperMultiplier).toFixed(2)));
};
export default function Home() {
const router = useRouter();
const pathname = usePathname();
const [step, setStep] = useState<number>(1)
const [selectedMode, setSelectedMode] = useState<string>("presenton")
const config = useSelector((state: RootState) => state.userConfig);
const [llmConfig, setLlmConfig] = useState<LLMConfig>(config.llm_config);
@ -144,124 +200,154 @@ export default function Home() {
}
return (
<div className="h-screen bg-gradient-to-b font-instrument_sans from-gray-50 to-white flex flex-col overflow-hidden">
<main className="flex-1 container mx-auto px-4 max-w-3xl overflow-hidden flex flex-col">
{/* Branding Header */}
<div className="text-center mb-2 mt-4 flex-shrink-0">
<div className="flex items-center justify-center gap-3 mb-2">
<img src="/Logo.png" alt="Presenton Logo" className="h-12" />
</div>
<p className="text-gray-600 text-sm">
Open-source AI presentation generator
</p>
</div>
// <div className="h-screen bg-gradient-to-b font-instrument_sans from-gray-50 to-white flex flex-col overflow-hidden">
// <main className="flex-1 container mx-auto px-4 max-w-3xl overflow-hidden flex flex-col">
// {/* Branding Header */}
// <div className="text-center mb-2 mt-4 flex-shrink-0">
// <div className="flex items-center justify-center gap-3 mb-2">
// <img src="/Logo.png" alt="Presenton Logo" className="h-12" />
// </div>
// <p className="text-gray-600 text-sm">
// Open-source AI presentation generator
// </p>
// </div>
{/* Main Configuration Card */}
<div className="flex-1 overflow-hidden">
<LLMProviderSelection
initialLLMConfig={llmConfig}
onConfigChange={setLlmConfig}
buttonState={buttonState}
setButtonState={setButtonState}
/>
</div>
// {/* Main Configuration Card */}
// <div className="flex-1 overflow-hidden">
// <LLMProviderSelection
// initialLLMConfig={llmConfig}
// onConfigChange={setLlmConfig}
// buttonState={buttonState}
// setButtonState={setButtonState}
// />
// </div>
// </main>
// {/* Download Progress Modal */}
// {showDownloadModal && downloadingModel && (
// <div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
// <div className="bg-white/95 backdrop-blur-md rounded-xl shadow-2xl max-w-md w-full p-6 relative">
// {/* Modal Content */}
// <div className="text-center">
// {/* Icon */}
// <div className="mb-4">
// {downloadingModel.done ? (
// <CheckCircle className="w-12 h-12 text-green-600 mx-auto" />
// ) : (
// <Download className="w-12 h-12 text-blue-600 mx-auto animate-pulse" />
// )}
// </div>
// {/* Title */}
// <h3 className="text-lg font-semibold text-gray-900 mb-2">
// {downloadingModel.done ? "Download Complete!" : "Downloading Model"}
// </h3>
// {/* Model Name */}
// <p className="text-sm text-gray-600 mb-6">
// {llmConfig.OLLAMA_MODEL}
// </p>
// {/* Progress Bar */}
// {downloadProgress > 0 && (
// <div className="mb-4">
// <div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
// <div
// className="bg-blue-600 h-3 rounded-full transition-all duration-300 ease-out"
// style={{ width: `${downloadProgress}%` }}
// />
// </div>
// <p className="text-sm text-gray-600 mt-2">
// {downloadProgress}% Complete
// </p>
// </div>
// )}
// {/* Status */}
// {downloadingModel.status && (
// <div className="flex items-center justify-center gap-2 mb-4">
// <CheckCircle className="w-4 h-4 text-green-600" />
// <span className="text-sm font-medium text-green-700 capitalize">
// {downloadingModel.status}
// </span>
// </div>
// )}
// {/* Status Message */}
// {downloadingModel.status && downloadingModel.status !== "pulled" && (
// <div className="text-xs text-gray-500">
// {downloadingModel.status === "downloading" && "Downloading model files..."}
// {downloadingModel.status === "verifying" && "Verifying model integrity..."}
// {downloadingModel.status === "pulling" && "Pulling model from registry..."}
// </div>
// )}
// {/* Download Info */}
// {downloadingModel.downloaded && downloadingModel.size && (
// <div className="mt-4 p-3 bg-gray-50 rounded-lg">
// <div className="flex justify-between text-xs text-gray-600">
// <span>Downloaded: {(downloadingModel.downloaded / 1024 / 1024).toFixed(1)} MB</span>
// <span>Total: {(downloadingModel.size / 1024 / 1024).toFixed(1)} MB</span>
// </div>
// </div>
// )}
// </div>
// </div>
// </div>
// )}
// {/* Fixed Bottom Button */}
// <div className="flex-shrink-0 bg-white border-t border-gray-200 p-4">
// <div className="container mx-auto max-w-3xl">
// <button
// onClick={handleSaveConfig}
// disabled={buttonState.isDisabled}
// className={`w-full font-semibold py-3 px-4 rounded-lg transition-all duration-500 ${buttonState.isDisabled
// ? "bg-gray-400 cursor-not-allowed"
// : "bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 focus:ring-4 focus:ring-blue-200"
// } text-white`}
// >
// {buttonState.isLoading ? (
// <div className="flex items-center justify-center gap-2">
// <Loader2 className="w-4 h-4 animate-spin" />
// {buttonState.text}
// </div>
// ) : (
// buttonState.text
// )}
// </button>
// </div>
// </div>
// </div>
<div className="flex h-screen">
<OnBoardingSlidebar />
<main className="w-full pl-20 pr-8 max-w-[1440px] mx-auto relative z-10">
{step === 3 && (
<div className="pointer-events-none fixed inset-0 z-0 overflow-hidden" aria-hidden>
{FINAL_STEP_CONFETTI_PIECES.map((piece, index) => (
<span
key={`${piece.side}-${index}`}
className="absolute rounded-[3px]"
style={{
top: `${piece.top}%`,
...(piece.side === "left"
? { left: `${getTaperedSideOffset(piece.offset, piece.top)}%` }
: { right: `${getTaperedSideOffset(piece.offset, piece.top)}%` }),
width: `${piece.width}px`,
height: `${piece.height}px`,
backgroundColor: piece.color,
transform: `rotate(${piece.rotate}deg)`,
}}
/>
))}
</div>
)}
<OnBoardingHeader currentStep={step} />
{step === 1 && <ModeSelectStep setStep={setStep} setSelectedMode={setSelectedMode} />}
{step === 2 && selectedMode === "presenton" && <PresentonMode currentStep={step} setStep={setStep} />}
{step === 2 && selectedMode === "image" && <GenerationWithImage />}
{step === 3 && <FinalStep />}
</main>
{/* Download Progress Modal */}
{showDownloadModal && downloadingModel && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-white/95 backdrop-blur-md rounded-xl shadow-2xl max-w-md w-full p-6 relative">
{/* Modal Content */}
<div className="text-center">
{/* Icon */}
<div className="mb-4">
{downloadingModel.done ? (
<CheckCircle className="w-12 h-12 text-green-600 mx-auto" />
) : (
<Download className="w-12 h-12 text-blue-600 mx-auto animate-pulse" />
)}
</div>
{/* Title */}
<h3 className="text-lg font-semibold text-gray-900 mb-2">
{downloadingModel.done ? "Download Complete!" : "Downloading Model"}
</h3>
{/* Model Name */}
<p className="text-sm text-gray-600 mb-6">
{llmConfig.OLLAMA_MODEL}
</p>
{/* Progress Bar */}
{downloadProgress > 0 && (
<div className="mb-4">
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
<div
className="bg-blue-600 h-3 rounded-full transition-all duration-300 ease-out"
style={{ width: `${downloadProgress}%` }}
/>
</div>
<p className="text-sm text-gray-600 mt-2">
{downloadProgress}% Complete
</p>
</div>
)}
{/* Status */}
{downloadingModel.status && (
<div className="flex items-center justify-center gap-2 mb-4">
<CheckCircle className="w-4 h-4 text-green-600" />
<span className="text-sm font-medium text-green-700 capitalize">
{downloadingModel.status}
</span>
</div>
)}
{/* Status Message */}
{downloadingModel.status && downloadingModel.status !== "pulled" && (
<div className="text-xs text-gray-500">
{downloadingModel.status === "downloading" && "Downloading model files..."}
{downloadingModel.status === "verifying" && "Verifying model integrity..."}
{downloadingModel.status === "pulling" && "Pulling model from registry..."}
</div>
)}
{/* Download Info */}
{downloadingModel.downloaded && downloadingModel.size && (
<div className="mt-4 p-3 bg-gray-50 rounded-lg">
<div className="flex justify-between text-xs text-gray-600">
<span>Downloaded: {(downloadingModel.downloaded / 1024 / 1024).toFixed(1)} MB</span>
<span>Total: {(downloadingModel.size / 1024 / 1024).toFixed(1)} MB</span>
</div>
</div>
)}
</div>
</div>
</div>
)}
{/* Fixed Bottom Button */}
<div className="flex-shrink-0 bg-white border-t border-gray-200 p-4">
<div className="container mx-auto max-w-3xl">
<button
onClick={handleSaveConfig}
disabled={buttonState.isDisabled}
className={`w-full font-semibold py-3 px-4 rounded-lg transition-all duration-500 ${buttonState.isDisabled
? "bg-gray-400 cursor-not-allowed"
: "bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 focus:ring-4 focus:ring-blue-200"
} text-white`}
>
{buttonState.isLoading ? (
<div className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
{buttonState.text}
</div>
) : (
buttonState.text
)}
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,357 @@
import React from 'react'
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
import { Button } from './ui/button';
import { Check, ChevronsUpDown } from 'lucide-react';
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from './ui/command';
import { LLMConfig } from '@/types/llm_config';
import { IMAGE_PROVIDERS } from '@/utils/providerConstants';
import { cn } from '@/lib/utils';
import { Select, SelectItem, SelectContent, SelectTrigger, SelectValue } from './ui/select';
const DALLE_3_QUALITY_OPTIONS = [
{
label: "Standard",
value: "standard",
description: "Faster generation with lower cost",
},
{
label: "HD",
value: "hd",
description: "Higher quality images with increased cost",
},
];
const GPT_IMAGE_1_5_QUALITY_OPTIONS = [
{
label: "Low",
value: "low",
description: "Fastest and most cost-effective",
},
{
label: "Medium",
value: "medium",
description: "Balanced quality and speed",
},
{
label: "High",
value: "high",
description: "Best quality with longer generation time",
},
];
const renderQualitySelector = (llmConfig: LLMConfig, input_field_changed: (value: string, field: string) => void) => {
if (llmConfig.IMAGE_PROVIDER === "dall-e-3") {
return (
<div className="w-[295px]">
<label className="block text-sm font-medium text-gray-700 mb-2">
DALL·E 3 Image Quality
</label>
<div className="">
<Select value={llmConfig.DALL_E_3_QUALITY} onValueChange={(value) => input_field_changed(value, "dall_e_3_quality")}>
<SelectTrigger className="w-full h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between">
<SelectValue placeholder="Select a quality" />
</SelectTrigger>
<SelectContent>
{DALLE_3_QUALITY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
{/* {DALLE_3_QUALITY_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
className={cn(
"border rounded-lg p-3 text-left transition-colors",
llmConfig.DALL_E_3_QUALITY === option.value
? "border-blue-500 bg-blue-50"
: "border-gray-200 hover:border-gray-300"
)}
onClick={() =>
input_field_changed(option.value, "dall_e_3_quality")
}
>
<div className="text-sm font-medium text-gray-900">
{option.label}
</div>
<div className="text-xs text-gray-600 mt-1">
{option.description}
</div>
</button>
))} */}
</div>
</div>
);
}
if (llmConfig.IMAGE_PROVIDER === "gpt-image-1.5") {
return (
<div className="w-[295px]">
<label className="block text-sm font-medium text-gray-700 mb-2">
GPT Image 1.5 Quality
</label>
<div className="">
<Select
value={llmConfig.GPT_IMAGE_1_5_QUALITY}
onValueChange={(value) => input_field_changed(value, "gpt_image_1_5_quality")}
>
<SelectTrigger
className="w-full h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between">
<SelectValue placeholder="Select a quality" />
</SelectTrigger>
<SelectContent>
{GPT_IMAGE_1_5_QUALITY_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
))}
</SelectContent>
</Select>
{/* {GPT_IMAGE_1_5_QUALITY_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
className={cn(
"border rounded-lg p-3 text-left transition-colors",
llmConfig.GPT_IMAGE_1_5_QUALITY === option.value
? "border-blue-500 bg-blue-50"
: "border-gray-200 hover:border-gray-300"
)}
onClick={() =>
input_field_changed(option.value, "gpt_image_1_5_quality")
}
>
<div className="text-sm font-medium text-gray-900">
{option.label}
</div>
<div className="text-xs text-gray-600 mt-1">
{option.description}
</div>
</button>
))} */}
</div>
</div>
);
}
return null;
};
const ImageSelectionConfig = ({ isImageGenerationDisabled, openImageProviderSelect, setOpenImageProviderSelect, llmConfig, input_field_changed, getApiKeyValue, handleApiKeyInputChange }: { isImageGenerationDisabled: boolean, openImageProviderSelect: boolean, setOpenImageProviderSelect: (open: boolean) => void, llmConfig: LLMConfig, input_field_changed: (value: string, field: string) => void, getApiKeyValue: (field: string) => string, handleApiKeyInputChange: (field: string, value: string) => void }) => {
return (
<div className='mt-7'>
<div className="p-10 flex justify-between items-center bg-white rounded-[12px]">
<div>
<h4 className="text-xl font-normal text-[#191919]">Image Generation Settings</h4>
<p className="mt-2 text-sm max-w-[205px] text-gray-500">
Choosing where images come from.
</p>
</div>
<div className='flex items-center gap-4'>
{!isImageGenerationDisabled && (
<>
{/* Image Provider Selection */}
<div className="my-8">
<label className="block text-sm font-medium text-gray-700 mb-3">
Select Image Provider
</label>
<div className="w-full">
<Popover
open={openImageProviderSelect}
onOpenChange={setOpenImageProviderSelect}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openImageProviderSelect}
className="w-[275px] h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between"
>
<div className="flex gap-3 items-center">
<span className="text-sm font-medium text-gray-900">
{llmConfig.IMAGE_PROVIDER
? IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER]
?.label || llmConfig.IMAGE_PROVIDER
: "Select image provider"}
</span>
</div>
<ChevronsUpDown className="w-4 h-4 text-gray-500" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
align="start"
style={{ width: "var(--radix-popover-trigger-width)" }}
>
<Command>
<CommandInput placeholder="Search provider..." />
<CommandList>
<CommandEmpty>No provider found.</CommandEmpty>
<CommandGroup>
{Object.values(IMAGE_PROVIDERS).map(
(provider, index) => (
<CommandItem
key={index}
value={provider.value}
onSelect={(value) => {
input_field_changed(value, "image_provider");
setOpenImageProviderSelect(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
llmConfig.IMAGE_PROVIDER === provider.value
? "opacity-100"
: "opacity-0"
)}
/>
<div className="flex gap-3 items-center">
<div className="flex flex-col space-y-1 flex-1">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-gray-900 capitalize">
{provider.label}
</span>
</div>
<span className="text-xs text-gray-600 leading-relaxed">
{provider.description}
</span>
</div>
</div>
</CommandItem>
)
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
{renderQualitySelector(llmConfig, input_field_changed)}
{/* Dynamic API Key Input for Image Provider */}
{llmConfig.IMAGE_PROVIDER &&
IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER] &&
(() => {
const provider = IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER];
// Show info message when using same API key as main provider
if (
provider.value === "dall-e-3" &&
llmConfig.LLM === "openai"
) {
return <></>;
}
if (
provider.value === "gpt-image-1.5" &&
llmConfig.LLM === "openai"
) {
return <></>;
}
if (
provider.value === "gemini_flash" &&
llmConfig.LLM === "google"
) {
return <></>;
}
if (
provider.value === "nanobanana_pro" &&
llmConfig.LLM === "google"
) {
return <></>;
}
// Show ComfyUI configuration
if (provider.value === "comfyui") {
return (
<div className=" space-y-4 w-[295px]">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
ComfyUI Server URL
</label>
<div className="relative">
<input
type="text"
placeholder="http://192.168.1.7:8188"
className="w-full px-4 py-2.5 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
value={llmConfig.COMFYUI_URL || ""}
onChange={(e) => {
input_field_changed(
e.target.value,
"comfyui_url"
);
}}
/>
</div>
<p className="mt-2 text-sm text-gray-500 flex items-center gap-2">
<span className="block w-1 h-1 rounded-full bg-gray-400"></span>
Use your machine IP address (not localhost) when
running in Docker
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Workflow JSON
</label>
<div className="relative">
<textarea
placeholder='Paste your ComfyUI workflow JSON here (export via "Export (API)" in ComfyUI)'
className="w-full px-4 py-2.5 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors font-mono text-xs"
rows={6}
value={llmConfig.COMFYUI_WORKFLOW || ""}
onChange={(e) => {
input_field_changed(
e.target.value,
"comfyui_workflow"
);
}}
/>
</div>
<p className="mt-2 text-sm text-gray-500">
Export your workflow from ComfyUI using &quot;Export
(API)&quot; and paste the JSON here.
</p>
</div>
</div>
);
}
// Show API key input for other providers
return (
<div className=" w-[295px]">
<label className="block text-sm font-medium text-gray-700 mb-2">
{provider.apiKeyFieldLabel}
</label>
<div className="relative">
<input
type="text"
placeholder={`Enter your ${provider.apiKeyFieldLabel}`}
className="w-full px-4 py-2.5 h-12 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
value={getApiKeyValue(provider.apiKeyField || "")}
onChange={(e) =>
handleApiKeyInputChange(
provider.apiKeyField || "",
e.target.value
)
}
/>
</div>
</div>
);
})()}
</>
)}
</div>
</div>
</div>
)
}
export default ImageSelectionConfig

View file

@ -1,19 +1,5 @@
"use client";
import { useState, useEffect } from "react";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "./ui/tabs";
import { Check, ChevronsUpDown, Info } from "lucide-react";
import { Button } from "./ui/button";
import { Switch } from "./ui/switch";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "./ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { cn } from "@/lib/utils";
import OpenAIConfig from "./OpenAIConfig";
import GoogleConfig from "./GoogleConfig";
import AnthropicConfig from "./AnthropicConfig";
@ -24,39 +10,12 @@ import {
updateLLMConfig,
changeProvider as changeProviderUtil,
} from "@/utils/providerUtils";
import { IMAGE_PROVIDERS, LLM_PROVIDERS } from "@/utils/providerConstants";
import { LLMConfig } from "@/types/llm_config";
import ImageSelectionConfig from "./ImageSelectionConfig";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
const DALLE_3_QUALITY_OPTIONS = [
{
label: "Standard",
value: "standard",
description: "Faster generation with lower cost",
},
{
label: "HD",
value: "hd",
description: "Higher quality images with increased cost",
},
];
const GPT_IMAGE_1_5_QUALITY_OPTIONS = [
{
label: "Low",
value: "low",
description: "Fastest and most cost-effective",
},
{
label: "Medium",
value: "medium",
description: "Balanced quality and speed",
},
{
label: "High",
value: "high",
description: "Best quality with longer generation time",
},
];
// Button state interface
interface ButtonState {
@ -77,6 +36,7 @@ interface LLMProviderSelectionProps {
) => void;
}
export default function LLMProviderSelection({
initialLLMConfig,
onConfigChange,
@ -85,7 +45,6 @@ export default function LLMProviderSelection({
const [llmConfig, setLlmConfig] = useState<LLMConfig>(initialLLMConfig);
const [openImageProviderSelect, setOpenImageProviderSelect] = useState(false);
const isImageGenerationDisabled = llmConfig.DISABLE_IMAGE_GENERATION ?? false;
useEffect(() => {
onConfigChange(llmConfig);
}, [llmConfig]);
@ -135,12 +94,12 @@ export default function LLMProviderSelection({
text: needsModelSelection
? "Please Select a Model"
: needsApiKey
? "Please Enter API Key"
: needsOllamaUrl
? "Please Enter Ollama URL"
: needsComfyUIConfig
? "Please Configure ComfyUI"
: "Save Configuration",
? "Please Enter API Key"
: needsOllamaUrl
? "Please Enter Ollama URL"
: needsComfyUIConfig
? "Please Configure ComfyUI"
: "Save Configuration",
showProgress: false,
});
}, [llmConfig]);
@ -256,77 +215,7 @@ export default function LLMProviderSelection({
});
}, [llmConfig.IMAGE_PROVIDER]);
const renderQualitySelector = () => {
if (llmConfig.IMAGE_PROVIDER === "dall-e-3") {
return (
<div className="mb-8">
<label className="block text-sm font-medium text-gray-700 mb-2">
DALL·E 3 Image Quality
</label>
<div className="grid grid-cols-2 gap-3">
{DALLE_3_QUALITY_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
className={cn(
"border rounded-lg p-3 text-left transition-colors",
llmConfig.DALL_E_3_QUALITY === option.value
? "border-blue-500 bg-blue-50"
: "border-gray-200 hover:border-gray-300"
)}
onClick={() =>
input_field_changed(option.value, "dall_e_3_quality")
}
>
<div className="text-sm font-medium text-gray-900">
{option.label}
</div>
<div className="text-xs text-gray-600 mt-1">
{option.description}
</div>
</button>
))}
</div>
</div>
);
}
if (llmConfig.IMAGE_PROVIDER === "gpt-image-1.5") {
return (
<div className="mb-8">
<label className="block text-sm font-medium text-gray-700 mb-2">
GPT Image 1.5 Quality
</label>
<div className="grid grid-cols-3 gap-3">
{GPT_IMAGE_1_5_QUALITY_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
className={cn(
"border rounded-lg p-3 text-left transition-colors",
llmConfig.GPT_IMAGE_1_5_QUALITY === option.value
? "border-blue-500 bg-blue-50"
: "border-gray-200 hover:border-gray-300"
)}
onClick={() =>
input_field_changed(option.value, "gpt_image_1_5_quality")
}
>
<div className="text-sm font-medium text-gray-900">
{option.label}
</div>
<div className="text-xs text-gray-600 mt-1">
{option.description}
</div>
</button>
))}
</div>
</div>
);
}
return null;
};
return (
<div className="h-full flex flex-col mt-10">
@ -358,6 +247,7 @@ export default function LLMProviderSelection({
{/* OpenAI Content */}
<TabsContent value="openai" className="mt-6">
<OpenAIConfig
llmConfig={llmConfig}
openaiApiKey={llmConfig.OPENAI_API_KEY || ""}
openaiModel={llmConfig.OPENAI_MODEL || ""}
webGrounding={llmConfig.WEB_GROUNDING || false}
@ -418,7 +308,16 @@ export default function LLMProviderSelection({
</Tabs>
{/* Image Generation Toggle */}
<div className="my-8">
<ImageSelectionConfig
isImageGenerationDisabled={isImageGenerationDisabled}
openImageProviderSelect={openImageProviderSelect}
setOpenImageProviderSelect={setOpenImageProviderSelect}
llmConfig={llmConfig}
input_field_changed={input_field_changed}
getApiKeyValue={getApiKeyValue}
handleApiKeyInputChange={handleApiKeyInputChange}
/>
{/* <div className="my-8">
<div className="flex items-center justify-between mb-4 bg-green-50 p-2 rounded-sm">
<label className="text-sm font-medium text-gray-700">
Disable Image Generation
@ -438,213 +337,12 @@ export default function LLMProviderSelection({
When enabled, slides will not include automatically generated
images.
</p>
</div>
</div> */}
{!isImageGenerationDisabled && (
<>
{/* Image Provider Selection */}
<div className="my-8">
<label className="block text-sm font-medium text-gray-700 mb-3">
Select Image Provider
</label>
<div className="w-full">
<Popover
open={openImageProviderSelect}
onOpenChange={setOpenImageProviderSelect}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openImageProviderSelect}
className="w-full h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between"
>
<div className="flex gap-3 items-center">
<span className="text-sm font-medium text-gray-900">
{llmConfig.IMAGE_PROVIDER
? IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER]
?.label || llmConfig.IMAGE_PROVIDER
: "Select image provider"}
</span>
</div>
<ChevronsUpDown className="w-4 h-4 text-gray-500" />
</Button>
</PopoverTrigger>
<PopoverContent
className="p-0"
align="start"
style={{ width: "var(--radix-popover-trigger-width)" }}
>
<Command>
<CommandInput placeholder="Search provider..." />
<CommandList>
<CommandEmpty>No provider found.</CommandEmpty>
<CommandGroup>
{Object.values(IMAGE_PROVIDERS).map(
(provider, index) => (
<CommandItem
key={index}
value={provider.value}
onSelect={(value) => {
input_field_changed(value, "image_provider");
setOpenImageProviderSelect(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
llmConfig.IMAGE_PROVIDER === provider.value
? "opacity-100"
: "opacity-0"
)}
/>
<div className="flex gap-3 items-center">
<div className="flex flex-col space-y-1 flex-1">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium text-gray-900 capitalize">
{provider.label}
</span>
</div>
<span className="text-xs text-gray-600 leading-relaxed">
{provider.description}
</span>
</div>
</div>
</CommandItem>
)
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
{renderQualitySelector()}
{/* Dynamic API Key Input for Image Provider */}
{llmConfig.IMAGE_PROVIDER &&
IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER] &&
(() => {
const provider = IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER];
// Show info message when using same API key as main provider
if (
provider.value === "dall-e-3" &&
llmConfig.LLM === "openai"
) {
return <></>;
}
if (
provider.value === "gpt-image-1.5" &&
llmConfig.LLM === "openai"
) {
return <></>;
}
if (
provider.value === "gemini_flash" &&
llmConfig.LLM === "google"
) {
return <></>;
}
if (
provider.value === "nanobanana_pro" &&
llmConfig.LLM === "google"
) {
return <></>;
}
// Show ComfyUI configuration
if (provider.value === "comfyui") {
return (
<div className="mb-8 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
ComfyUI Server URL
</label>
<div className="relative">
<input
type="text"
placeholder="http://192.168.1.7:8188"
className="w-full px-4 py-2.5 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
value={llmConfig.COMFYUI_URL || ""}
onChange={(e) => {
input_field_changed(
e.target.value,
"comfyui_url"
);
}}
/>
</div>
<p className="mt-2 text-sm text-gray-500 flex items-center gap-2">
<span className="block w-1 h-1 rounded-full bg-gray-400"></span>
Use your machine IP address (not localhost) when
running in Docker
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Workflow JSON
</label>
<div className="relative">
<textarea
placeholder='Paste your ComfyUI workflow JSON here (export via "Export (API)" in ComfyUI)'
className="w-full px-4 py-2.5 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors font-mono text-xs"
rows={6}
value={llmConfig.COMFYUI_WORKFLOW || ""}
onChange={(e) => {
input_field_changed(
e.target.value,
"comfyui_workflow"
);
}}
/>
</div>
<p className="mt-2 text-sm text-gray-500">
Export your workflow from ComfyUI using &quot;Export
(API)&quot; and paste the JSON here.
</p>
</div>
</div>
);
}
// Show API key input for other providers
return (
<div className="mb-8">
<label className="block text-sm font-medium text-gray-700 mb-2">
{provider.apiKeyFieldLabel}
</label>
<div className="relative">
<input
type="text"
placeholder={`Enter your ${provider.apiKeyFieldLabel}`}
className="w-full px-4 py-2.5 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
value={getApiKeyValue(provider.apiKeyField)}
onChange={(e) =>
handleApiKeyInputChange(
provider.apiKeyField,
e.target.value
)
}
/>
</div>
<p className="mt-2 text-sm text-gray-500 flex items-center gap-2">
<span className="block w-1 h-1 rounded-full bg-gray-400"></span>
API key for {provider.label} image generation
</p>
</div>
);
})()}
</>
)}
{/* Model Information */}
<div className="mb-8 p-4 bg-blue-50 rounded-lg border border-blue-100">
{/* <div className="mb-8 p-4 bg-blue-50 rounded-lg border border-blue-100">
<div className="flex items-start gap-3">
<Info className="w-5 h-5 text-blue-500 mt-0.5" />
<div>
@ -673,7 +371,7 @@ export default function LLMProviderSelection({
<>
and{" "}
{llmConfig.IMAGE_PROVIDER &&
IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER]
IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER]
? IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER].label
: "xxxxx"}{" "}
for images
@ -682,7 +380,28 @@ export default function LLMProviderSelection({
</p>
</div>
</div>
</div>
</div> */}
{/* <button
onClick={handleSaveConfig}
disabled={buttonState.isDisabled}
style={{
background: "linear-gradient(270deg, #D5CAFC 2.4%, #E3D2EB 27.88%, #F4DCD3 69.23%, #FDE4C2 100%)",
color: "#101323",
}}
className={`w-full font-semibold py-3 px-4 rounded-lg transition-all duration-500 ${buttonState.isDisabled
? "bg-gray-400 cursor-not-allowed"
: "bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 focus:ring-4 focus:ring-blue-200"
} text-white`}
>
{buttonState.isLoading ? (
<div className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
{buttonState.text}
</div>
) : (
buttonState.text
)}
</button> */}
</div>
</div>
);

View file

@ -0,0 +1,38 @@
"use client";
import React, { useState, useEffect } from "react";
import { marked } from "marked";
import { cn } from "@/lib/utils";
interface MarkdownRendererProps {
content: string;
className?: string;
}
const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, className }) => {
const [markdownContent, setMarkdownContent] = useState<string>("");
useEffect(() => {
const parseMarkdown = async () => {
try {
const parsed = await marked.parse(content);
setMarkdownContent(parsed);
} catch (error) {
console.error("Error parsing markdown:", error);
setMarkdownContent("");
}
};
parseMarkdown();
}, [content]);
return (
<div
className={cn("prose prose-slate max-w-none mb-10", className)}
dangerouslySetInnerHTML={{ __html: markdownContent }}
/>
);
};
export default MarkdownRenderer;

Some files were not shown because too many files have changed in this diff Show more