Merge pull request #428 from presenton/feat/revamp_design

feat/revamp design
This commit is contained in:
Sudip Parajuli 2026-03-02 19:38:46 +05:45 committed by GitHub
commit e7d3a39e0d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
75 changed files with 10107 additions and 2508 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,149 @@
"use client";
import React from "react";
import { LayoutDashboard, Star, Brain, Settings } 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>
{/* <div className="mt-3">
{mounted && (auth?.user || auth?.userEmail) ? (
<Link
prefetch={false}
href="/profile"
className="w-full flex gap-3 items-center cursor-pointer rounded-2xl ring-1 ring-inset ring-slate-200 bg-white/80 hover:bg-white transition-colors px-3 py-2"
aria-label="Open profile"
title="Profile"
>
<div className="h-8 w-8 rounded-full bg-[#5146E5]/10 flex items-center justify-center text-[#5146E5] text-xs font-semibold">
{(auth?.user?.name?.[0] || auth?.userEmail?.[0] || "?").toUpperCase()}
</div>
<div className="min-w-0 text-left">
<div className="text-xs font-semibold text-slate-900 truncate">{auth?.user?.name || auth?.userEmail}</div>
{auth?.userEmail && <div className="text-[10px] text-slate-500 truncate">{auth.userEmail}</div>}
</div>
</Link>
) : (
<div
className="w-full flex items-center cursor-pointer rounded-2xl ring-1 ring-inset ring-slate-200 bg-white/80 px-3 py-2 gap-3"
>
<UserRoundCog className="h-4 w-4 text-slate-700" />
<div className="flex-1">
<div className="bg-slate-100 animate-pulse rounded w-full h-4 mb-1"></div>
<div className="bg-slate-100 animate-pulse rounded w-2/3 h-3"></div>
</div>
</div>
)}
</div> */}
<nav className="pt-6" 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>
</div>
</nav>
</div>
<div className=" pt-5 border-t border-slate-200/60 "
>
{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-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>}
{/* {
<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,34 @@
"use client";
import Wrapper from "@/components/Wrapper";
import React from "react";
import Link from "next/link";
import BackBtn from "@/components/BackBtn";
import { usePathname } from "next/navigation";
import HeaderNav from "@/app/(presentation-generator)/components/HeaderNab";
import { Layout, FilePlus2 } from "lucide-react";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
const Header = () => {
const pathname = usePathname();
return (
<div className="w-full sticky top-0 z-50 py-7">
<Wrapper>
<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-[33px]"
/>
</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 "@/app/(presentation-generator)/documents-preview/components/MarkdownRenderer";
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] 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-2.5 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-2">
{<MarkdownRenderer content={title} />}
</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

@ -3,6 +3,7 @@ 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[];
@ -41,35 +42,44 @@ export const PresentationGrid = ({
const CreateNewCard = () => (
<div
onClick={handleCreateNewPresentation}
className="flex flex-col gap-4 min-h-[200px] cursor-pointer group border border-gray-400 hover:border-primary/60 bg-white/70 hover:bg-white/80 rounded-lg items-center justify-center transition-all duration-300"
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"
>
<div className="rounded-full bg-gray-200 group-hover:bg-primary/10 p-4 transition-all duration-300">
<PlusIcon className="w-8 h-8 text-gray-500 group-hover:text-primary transition-all duration-300" />
</div>
<div className="text-center">
<h3 className="font-semibold text-gray-700 group-hover:text-gray-900 mb-1">
Create {type === "slide" ? "New" : "Video"} Presentation
</h3>
<p className="text-sm text-gray-500 group-hover:text-gray-600 px-4">
Start from scratch and bring your ideas to life
</p>
<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 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="flex flex-col gap-4 min-h-[200px] cursor-pointer group border border-gray-400 bg-white/70 rounded-lg items-center justify-center animate-pulse">
<div className="rounded-full bg-gray-200 p-4">
<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-gray-200 rounded w-32 mx-auto"></div>
<div className="h-3 bg-gray-200 rounded w-48 mx-auto"></div>
<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(3)].map((_, i) => (
{[...Array(15)].map((_, i) => (
<ShimmerCard key={i} />
))}
</div>
@ -105,8 +115,7 @@ export const PresentationGrid = ({
key={presentation.id}
id={presentation.id}
title={presentation.title}
created_at={presentation.created_at}
slide={presentation.slides[0]}
presentation={presentation}
onDeleted={onPresentationDeleted}
/>
))}

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 @@
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,363 @@
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 shouldHideImageApiKeyInput = (providerValue: string, providerApiKeyField?: string) => {
if (!providerApiKeyField) return true;
if (providerValue === "comfyui") return false;
return providerApiKeyField === getTextProviderApiField();
};
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-[20px] ">
{/* API Key Input */}
<div className="mb-4 bg-white p-10 pt-5">
<ToolTip content="Enable/Disable Image Generation" className='flex justify-end items-center'>
<div className='flex justify-end items-center'>
<Switch
checked={isImageGenerationDisabled}
className=''
onCheckedChange={(checked) => handleChangeImageGenerationDisabled(checked)}
/>
</div>
</ToolTip>
<div className='flex items-center justify-between'>
<div className=" max-w-[290px]">
<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 info message when using same API key as main provider
if (shouldHideImageApiKeyInput(provider.value, provider.apiKeyField)) {
return <></>;
}
// 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>
<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

@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect } from "react";
import { Loader2, Download, CheckCircle } from "lucide-react";
import { Loader2, Download, CheckCircle, ChevronRight } from "lucide-react";
import { toast } from "sonner";
import { RootState } from "@/store/store";
import { useSelector } from "react-redux";
@ -10,10 +10,12 @@ import {
pullOllamaModel,
} from "@/utils/providerUtils";
import { useRouter, usePathname } from "next/navigation";
import LLMProviderSelection from "@/components/LLMSelection";
import Header from "../dashboard/components/Header";
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 {
@ -28,6 +30,8 @@ interface ButtonState {
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
@ -152,43 +156,101 @@ const SettingsPage = () => {
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 bg-gradient-to-b font-instrument_sans from-gray-50 to-white flex flex-col overflow-hidden">
<Header />
<main className="flex-1 container mx-auto px-4 max-w-3xl overflow-hidden flex flex-col">
{/* LLM Selection Component */}
<div className="flex-1 overflow-hidden">
<LLMProviderSelection
initialLLMConfig={llmConfig}
onConfigChange={setLlmConfig}
buttonState={buttonState}
setButtonState={setButtonState}
/>
<div className="h-screen font-instrument_sans 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="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"
<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 flex items-center justify-center gap-2 font-semibold 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
)}
</button>
</div>
>
{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 */}

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,494 @@
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 { LLM_PROVIDERS } from '@/utils/providerConstants';
import { Check, ChevronsUpDown, Loader2, Eye, EyeOff, ChevronUp } 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 modelLabel = selectedProviderMeta?.label || selectedProvider;
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
setAvailableModels([]);
setModelsChecked(false);
if (currentModelField) {
onInputChange('', currentModelField);
}
}, [selectedProvider, currentApiKey, currentCustomUrl, currentOllamaUrl]);
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('/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('/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('/api/v1/ppt/ollama/models/supported');
} else {
response = await fetch('/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-[20px] ">
{/* API Key Input */}
<div className="mb-4 flex items-center justify-between bg-white p-10">
<div className=" max-w-[290px]">
<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 className="flex items-start gap-4 justify-end">
<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 ">
<label className="block text-sm font-medium capitalize text-gray-700 mb-2">
{selectedProvider === 'ollama' ? 'Ollama URL' : selectedProvider === 'custom' ? 'Custom LLM API Key' : `${llmConfig.LLM} API Key`}
</label>
<div className="relative">
<input
type={selectedProvider === 'ollama' ? 'text' : showApiKey ? 'text' : 'password'}
value={selectedProvider === 'ollama' ? currentOllamaUrl : 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={selectedProvider === 'ollama' ? 'http://localhost:11434' : `Enter your ${llmConfig.LLM} API key`}
/>
{selectedProvider !== 'ollama' && (
<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' && (!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>
{/* 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-[275px]">
<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,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'>
<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 { 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 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 text-gray-900">
{template.name}
</h3>
<div className="flex items-center gap-2">
<ExternalLink 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 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>
<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">
<ExternalLink 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">
<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-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 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

@ -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

@ -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

@ -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

@ -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

@ -1,71 +0,0 @@
"use client";
import React, { useState, useEffect } from "react";
import Wrapper from "@/components/Wrapper";
import { DashboardApi } from "@/app/(presentation-generator)/services/api/dashboard";
import { PresentationGrid } from "@/app/(presentation-generator)/dashboard/components/PresentationGrid";
import Header from "@/app/(presentation-generator)/dashboard/components/Header";
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 bg-[#E9E8F8]">
<Header />
<Wrapper>
<main className="container mx-auto px-4 py-8">
<section>
<h2 className="text-2xl font-roboto font-medium mb-6">
Slide Presentation
</h2>
<PresentationGrid
presentations={presentations}
type="slide"
isLoading={isLoading}
error={error}
onPresentationDeleted={removePresentation}
/>
</section>
</main>
</Wrapper>
</div>
);
};
export default DashboardPage;

View file

@ -1,56 +0,0 @@
"use client";
import Wrapper from "@/components/Wrapper";
import React from "react";
import Link from "next/link";
import BackBtn from "@/components/BackBtn";
import { usePathname } from "next/navigation";
import HeaderNav from "@/app/(presentation-generator)/components/HeaderNab";
import { Layout, FilePlus2 } from "lucide-react";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
const Header = () => {
const pathname = usePathname();
return (
<div className="bg-[#5146E5] w-full shadow-lg sticky top-0 z-50">
<Wrapper>
<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-white.png"
alt="Presentation logo"
className="h-16"
/>
</Link>
</div>
<div className="flex items-center gap-3">
<Link
href="/custom-template"
prefetch={false}
onClick={() => trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/custom-template" })}
className="flex items-center gap-2 px-3 py-2 text-white hover:bg-primary/80 rounded-md transition-colors outline-none"
role="menuitem"
>
<FilePlus2 className="w-5 h-5" />
<span className="text-sm font-medium font-inter">Create Template</span>
</Link>
<Link
href="/template-preview"
prefetch={false}
onClick={() => trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/template-preview" })}
className="flex items-center gap-2 px-3 py-2 text-white hover:bg-primary/80 rounded-md transition-colors outline-none"
role="menuitem"
>
<Layout className="w-5 h-5" />
<span className="text-sm font-medium font-inter">Templates</span>
</Link>
<HeaderNav />
</div>
</div>
</Wrapper>
</div>
);
};
export default Header;

View file

@ -1,120 +0,0 @@
import React, { useMemo } from "react";
import { Card } from "@/components/ui/card";
import { DashboardApi } from "@/app/(presentation-generator)/services/api/dashboard";
import { DotsVerticalIcon, TrashIcon } from "@radix-ui/react-icons";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "@/components/ui/popover";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import SlideScale from "../../components/PresentationRender";
export const PresentationCard = ({
id,
title,
created_at,
slide,
onDeleted
}: {
id: string;
title: string;
created_at: string;
slide: any;
onDeleted?: (presentationId: string) => void;
}) => {
const router = useRouter();
const handlePreview = (e: React.MouseEvent) => {
e.preventDefault();
router.push(`/presentation?id=${id}`);
};
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");
}
};
return (
<Card
onClick={handlePreview}
className="bg-white rounded-[8px] slide-theme cursor-pointer overflow-hidden p-4"
>
<div className="space-y-4">
{/* Date */}
<div className="flex items-center justify-between">
<p className="text-[#667085] text-sm font-roboto pt-2">
{new Date(created_at).toLocaleDateString()}
</p>
<Popover>
<PopoverTrigger className="w-6 h-6 rounded-full flex items-center justify-center text-gray-500 hover:text-gray-700" onClick={(e) => e.stopPropagation()}>
<DotsVerticalIcon className="w-4 h-4 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>
<TrashIcon className="w-4 h-4 text-red-500" />
</button>
</PopoverContent>
</Popover>
</div>
<div className=" slide-box relative overflow-hidden border aspect-video"
style={{
}}
>
<div className="absolute 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%]">
<SlideScale slide={slide} />
</div>
</div>
{/* Icon and Title */}
<div className="flex items-center gap-2 pb-1">
<svg
xmlns="http://www.w3.org/2000/svg"
className="w-full h-full max-w-[20px] max-h-[20px]"
viewBox="0 0 24 24"
fill="none"
>
<path
d="M15.75 0.75V6C15.75 6.42 16.08 6.75 16.5 6.75H21.75M9.75 17.25H7.5C7.08 17.25 6.75 16.92 6.75 16.5V12C6.75 11.58 7.08 11.25 7.5 11.25H13.5C13.92 11.25 14.25 11.58 14.25 12V14.25M21.75 6.3V22.5C21.75 22.92 21.42 23.25 21 23.25H3C2.58 23.25 2.25 22.92 2.25 22.5V1.5C2.25 1.08 2.58 0.75 3 0.75H16.275C16.47 0.75 16.665 0.825 16.815 0.975L21.54 5.775C21.675 5.925 21.75 6.105 21.75 6.3ZM10.5 14.25H16.5C16.92 14.25 17.25 14.58 17.25 15V19.5C17.25 19.92 16.92 20.25 16.5 20.25H10.5C10.08 20.25 9.75 19.92 9.75 19.5V15C9.75 14.58 10.08 14.25 10.5 14.25Z"
stroke="black"
strokeWidth="1.5"
/>
</svg>
<p className="text-[#667085] text-sm ml-1 line-clamp-2 font-roboto">
{title}
</p>
</div>
</div>
</Card>
);
};

View file

@ -1,29 +0,0 @@
import { Skeleton } from '@/components/ui/skeleton'
import React from 'react'
import Header from './components/Header'
import Wrapper from '@/components/Wrapper'
const loading = () => {
return (
<div className=''>
<Header />
<Wrapper className=''>
<div className='container mx-auto px-4 py-8'>
<h2 className="text-2xl font-roboto font-medium my-6">
Slide Presentation
</h2>
<div className=" mx-auto pb-10 grid xl:grid-cols-4 grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 ">
{
Array.from({ length: 8 }).map((_, index) => (
<Skeleton key={index} className="h-72 w-full bg-gray-300 aspect-video mx-auto" />
))
}
</div>
</div>
</Wrapper>
</div>
)
}
export default loading

View file

@ -27,7 +27,7 @@ import MarkdownRenderer from "./MarkdownRenderer";
import { getIconFromFile } from "../../utils/others";
import { ChevronRight, PanelRightOpen, X } from "lucide-react";
import ToolTip from "@/components/ToolTip";
import Header from "@/app/(presentation-generator)/dashboard/components/Header";
import Header from "@/app/(presentation-generator)/(dashboard)/dashboard/components/Header";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
// Types
@ -147,7 +147,7 @@ const DocumentsPreviewPage: React.FC = () => {
(fileItem: FileItem) => fileItem.file_path
);
trackEvent(MixpanelEvent.DocumentsPreview_Create_Presentation_API_Call);
const createResponse = await PresentationGenerationApi.createPresentation(
const createResponse = await PresentationGenerationApi.createPresentation(
{
content: config?.prompt ?? "",
n_slides: config?.slides ? parseInt(config.slides) : null,
@ -240,9 +240,8 @@ const DocumentsPreviewPage: React.FC = () => {
<div
key={key}
onClick={() => updateSelectedDocument(key)}
className={`${
selectedDocument === key ? "border border-blue-500" : ""
} flex p-2 rounded-sm gap-2 items-center cursor-pointer`}
className={`${selectedDocument === key ? "border border-blue-500" : ""
} flex p-2 rounded-sm gap-2 items-center cursor-pointer`}
>
<img
className="h-6 w-6 border border-gray-200"

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' : ''} 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

@ -4,6 +4,10 @@ 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";
<<<<<<< feat/revamp_design
import { ChevronRight } from "lucide-react";
=======
>>>>>>> main
interface GenerateButtonProps {
loadingState: LoadingState;
@ -50,34 +54,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"
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

@ -91,41 +91,30 @@ const OutlineContent: React.FC<OutlineContentProps> = ({
)}
{/* Outlines content */}
{outlines && outlines.length > 0 && (
<div>
<div className="bg-[#F9F8F8] p-7 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,26 @@ export function OutlineItem({
}, [isStreaming, isActiveStreaming, isStableStreaming, slideOutline.content])
return (
<div className="mb-2">
{/* Main Title Row */}
<div className="mb-4 bg-white rounded-[12px] shadow-sm p-10 relative">
<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] ${isDragging ? "opacity-50" : ""}`}
>
{/* 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>
<GripHorizontal className="w-4 h-4 text-black/80" />
</div>
{/* Main Title Input - Add onFocus handler */}
<div id={`outline-item-${index}`} className="flex flex-col basis-full gap-2">
<p className="text-black/80 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 +164,15 @@ export function OutlineItem({
</div>
{/* Action Buttons */}
<div className="flex gap-1 sm:gap-2 items-center">
<div className="absolute -top-3 -right-3 flex 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,10 @@ import { useOutlineManagement } from "../hooks/useOutlineManagement";
import { usePresentationGeneration } from "../hooks/usePresentationGeneration";
import TemplateSelection from "./TemplateSelection";
import { TemplateLayoutsWithSettings } from "@/app/presentation-templates/utils";
<<<<<<< feat/revamp_design
import { Separator } from "@/components/ui/separator";
=======
>>>>>>> main
const OutlinePage: React.FC = () => {
const { presentation_id, outlines } = useSelector(
@ -39,7 +43,15 @@ const OutlinePage: React.FC = () => {
return (
<div className="h-[calc(100vh-72px)]">
<div className="">
<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%)',
}}
/>
<OverlayLoader
show={loadingState.isLoading}
text={loadingState.message}
@ -47,12 +59,23 @@ 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">
<div className="flex-grow w-full overflow-y-hidden 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-[#DFDFE1] bg-[#F8F8F9] 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-[#E9E2F8] 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-[#E9E2F8] data-[state=active]:text-[#7E3AF2] data-[state=active]:shadow-none"
>
Select Template
</TabsTrigger>
</TabsList>
<div className="flex-grow w-full mx-auto">
@ -81,11 +104,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-36 -right-10 z-50">
<GenerateButton
outlineCount={outlines.length}
loadingState={loadingState}
@ -95,6 +116,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,86 @@ import { TemplateWithData } from "@/app/presentation-templates/utils";
import { CustomTemplates, useCustomTemplateSummaries } from "@/app/hooks/useCustomTemplates";
import { Loader2 } from "lucide-react";
import { CustomTemplateCard } from "./CustomTemplateCard";
// 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 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">
{template.name}
</h3>
<p className="text-xs text-gray-600 mb-4 line-clamp-2">
{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 +97,101 @@ 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">
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
<span className="ml-3 text-gray-600">Loading custom templates...</span>
</div>
);
}
if (customTemplates.length === 0) {
return (
<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>
);
}
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>
{/* 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-lg font-semibold text-gray-900">Custom</h3>
</div>
{customTemplateCards}
</div>
{/* In Built Templates */}
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-3">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;

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,10 +4,15 @@ import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { clearPresentationData } from "@/store/slices/presentationGeneration";
import { PresentationGenerationApi } from "../../services/api/presentation-generation";
<<<<<<< feat/revamp_design
import { LoadingState, TABS } from "../types/index";
=======
import { Template, LoadingState, TABS } from "../types/index";
import { MixpanelEvent, trackEvent } from "@/utils/mixpanel";
import { TemplateLayoutsWithSettings } from "@/app/presentation-templates/utils";
>>>>>>> main
import { getCustomTemplateDetails } from "@/app/hooks/useCustomTemplates";
import { TemplateLayoutsWithSettings } from "@/app/presentation-templates/utils";
const DEFAULT_LOADING_STATE: LoadingState = {
message: "",
@ -129,7 +134,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 = {

View file

@ -1,298 +0,0 @@
"use client";
import { Button } from "@/components/ui/button";
import {
SquareArrowOutUpRight,
Play,
Loader2,
Redo2,
Undo2,
} from "lucide-react";
import React, { useState } from "react";
import Wrapper from "@/components/Wrapper";
import { useRouter, usePathname } from "next/navigation";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { PresentationGenerationApi } from "../../services/api/presentation-generation";
import { OverlayLoader } from "@/components/ui/overlay-loader";
import { useDispatch, useSelector } from "react-redux";
import Link from "next/link";
import { RootState } from "@/store/store";
import { toast } from "sonner";
import Announcement from "@/components/Announcement";
import { PptxPresentationModel } from "@/types/pptx_models";
import HeaderNav from "../../components/HeaderNab";
import PDFIMAGE from "@/public/pdf.svg";
import PPTXIMAGE from "@/public/pptx.svg";
import Image from "next/image";
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";
const Header = ({
presentation_id,
currentSlide,
}: {
presentation_id: string;
currentSlide?: number;
}) => {
const [open, setOpen] = useState(false);
const [showLoader, setShowLoader] = useState(false);
const router = useRouter();
const pathname = usePathname();
const dispatch = useDispatch();
const { presentationData, isStreaming } = useSelector(
(state: RootState) => state.presentationGeneration
);
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 handleExportPptx = async () => {
if (isStreaming) return;
try {
setOpen(false);
setShowLoader(true);
// Save the presentation data before exporting
trackEvent(MixpanelEvent.Header_UpdatePresentationContent_API_Call);
await PresentationGenerationApi.updatePresentationContent(presentationData);
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);
setShowLoader(false);
toast.error("Having trouble exporting!", {
description:
"We are having trouble exporting your presentation. Please try again.",
});
} finally {
setShowLoader(false);
}
};
const handleExportPdf = async () => {
if (isStreaming) return;
try {
setOpen(false);
setShowLoader(true);
// Save the presentation data before exporting
trackEvent(MixpanelEvent.Header_UpdatePresentationContent_API_Call);
await PresentationGenerationApi.updatePresentationContent(presentationData);
trackEvent(MixpanelEvent.Header_ExportAsPDF_API_Call);
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 {
setShowLoader(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={`space-y-2 max-md:mt-4 ${mobile ? "" : "bg-white"} rounded-lg`}>
<Button
onClick={() => {
trackEvent(MixpanelEvent.Header_Export_PDF_Button_Clicked, { pathname });
handleExportPdf();
}}
variant="ghost"
className={`pb-4 border-b rounded-none border-gray-300 w-full flex justify-start text-[#5146E5] ${mobile ? "bg-white py-6 border-none rounded-lg" : ""}`} >
<Image src={PDFIMAGE} alt="pdf export" width={30} height={30} />
Export as PDF
</Button>
<Button
onClick={() => {
trackEvent(MixpanelEvent.Header_Export_PPTX_Button_Clicked, { pathname });
handleExportPptx();
}}
variant="ghost"
className={`w-full flex justify-start text-[#5146E5] ${mobile ? "bg-white py-6" : ""}`}
>
<Image src={PPTXIMAGE} alt="pptx export" width={30} height={30} />
Export as PPTX
</Button>
</div>
);
const MenuItems = ({ mobile }: { mobile: boolean }) => (
<div className="flex flex-col lg:flex-row items-center gap-4">
{/* undo redo */}
<button onClick={handleReGenerate} disabled={isStreaming || !presentationData} className="text-white disabled:opacity-50" >
Re-Generate
</button>
<div className="flex items-center gap-2 ">
<ToolTip content="Undo">
<button disabled={!canUndo} className="text-white disabled:opacity-50" onClick={() => {
onUndo();
}}>
<Undo2 className="w-6 h-6 " />
</button>
</ToolTip>
<ToolTip content="Redo">
<button disabled={!canRedo} className="text-white disabled:opacity-50" onClick={() => {
onRedo();
}}>
<Redo2 className="w-6 h-6 " />
</button>
</ToolTip>
</div>
{/* Present Button */}
<Button
onClick={() => {
const to = `?id=${presentation_id}&mode=present&slide=${currentSlide || 0}`;
trackEvent(MixpanelEvent.Navigation, { from: pathname, to });
router.push(to);
}}
variant="ghost"
className="border border-white font-bold text-white rounded-[32px] transition-all duration-300 group"
>
<Play className="w-4 h-4 mr-1 stroke-white group-hover:stroke-black" />
Present
</Button>
{/* Desktop Export Button with Popover */}
<div style={{
zIndex: 100
}} className="hidden lg:block relative ">
<Popover open={open} onOpenChange={setOpen} >
<PopoverTrigger asChild>
<Button className={`border py-5 text-[#5146E5] font-bold rounded-[32px] transition-all duration-500 hover:border hover:bg-[#5146E5] hover:text-white w-full ${mobile ? "" : "bg-white"}`}>
<SquareArrowOutUpRight className="w-4 h-4 mr-1" />
Export
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-[250px] space-y-2 py-3 px-2 ">
<ExportOptions mobile={false} />
</PopoverContent>
</Popover>
</div>
{/* Mobile Export Section */}
<div className="lg:hidden flex flex-col w-full">
<ExportOptions mobile={true} />
</div>
</div>
);
return (
<>
<OverlayLoader
show={showLoader}
text="Exporting presentation..."
showProgress={true}
duration={40}
/>
<div
className="bg-[#5146E5] w-full shadow-lg sticky top-0 ">
<Announcement />
<Wrapper className="flex items-center justify-between py-1">
<Link href="/dashboard" className="min-w-[162px]">
<img
className="h-16"
src="/logo-white.png"
alt="Presentation logo"
/>
</Link>
{/* Desktop Menu */}
<div className="hidden lg:flex items-center gap-4 2xl:gap-6">
{isStreaming && (
<Loader2 className="animate-spin text-white font-bold w-6 h-6" />
)}
<MenuItems mobile={false} />
<HeaderNav />
</div>
{/* Mobile Menu */}
<div className="lg:hidden flex items-center gap-4">
<HeaderNav />
</div>
</Wrapper>
</div>
</>
);
};
export default Header;

View file

@ -2,9 +2,8 @@
import React, { useEffect, useState, memo, useCallback } from "react";
import { useDispatch } from "react-redux";
import { addNewSlide } from "@/store/slices/presentationGeneration";
import { Loader2 } from "lucide-react";
import { Loader2, X } from "lucide-react";
import { v4 as uuidv4 } from "uuid";
import { Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { getCustomTemplateDetails } from "@/app/hooks/useCustomTemplates";
import { getTemplatesByTemplateName } from "@/app/presentation-templates";
@ -95,7 +94,7 @@ const NewSlideV1 = ({
<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>
<Trash2
<X
onClick={() => setShowNewSlideSelection(false)}
className="text-gray-500 text-2xl cursor-pointer"
/>
@ -111,7 +110,7 @@ const NewSlideV1 = ({
<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>
<Trash2
<X
onClick={() => setShowNewSlideSelection(false)}
className="text-gray-500 text-2xl cursor-pointer"
/>

View file

@ -0,0 +1,265 @@
"use client";
import { Button } from "@/components/ui/button";
import {
Play,
Loader2,
Redo2,
Undo2,
RotateCcw,
ArrowRightFromLine,
ExternalLink,
MoveUpRight,
ArrowUpRight,
} from "lucide-react";
import React, { 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";
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 pathname = usePathname();
const dispatch = useDispatch();
const { presentationData, isStreaming } = useSelector(
(state: RootState) => state.presentationGeneration
);
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 handleExportPptx = 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_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);
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();
}}
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();
}}
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] pr-[25px] flex justify-between items-center">
<h2 className="text-lg text-[#101323] w-[600px] truncate">{presentationData?.title || "Presentation"}</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={{}} 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

@ -6,10 +6,13 @@ import {
X,
Minimize2,
Maximize2,
StickyNote,
EyeOff,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Slide } from "../types/slide";
import { V1ContentRender } from "./V1ContentRender";
import { Slide } from "../../types/slide";
import { V1ContentRender } from "../../components/V1ContentRender";
interface PresentationModeProps {
@ -38,6 +41,11 @@ const PresentationMode: React.FC<PresentationModeProps> = ({
return null;
}
const [showSpeakerNotes, setShowSpeakerNotes] = useState(true);
const currentSpeakerNote = useMemo(
() => slides[currentSlide]?.speaker_note?.trim() || "",
[slides, currentSlide]
);
const recomputeScale = useCallback(() => {
@ -90,6 +98,10 @@ const PresentationMode: React.FC<PresentationModeProps> = ({
case "F":
onFullscreenToggle();
break;
case "n":
case "N":
setShowSpeakerNotes((prev) => !prev);
break;
}
},
[currentSlide, slides.length, onSlideChange, onExit, onFullscreenToggle, isFullscreen]
@ -223,24 +235,70 @@ const PresentationMode: React.FC<PresentationModeProps> = ({
</>
)}
{/* Slides (all mounted, only current visible) */}
<div className={`flex-1 flex items-center justify-center ${isFullscreen ? "p-0" : "p-8"}`}>
<div className="w-full h-full flex items-center justify-center relative" >
<div
className={` rounded-sm font-inter relative w-full h-full flex items-center justify-center`}
>
{slides.length > 0 && slides.map((slide, index) => (
<div
key={slide.id}
className={index === currentSlide ? " w-full h-full flex items-center justify-center" : "hidden w-full h-full"}
>
<V1ContentRender slide={slide} isEditMode={true} />
</div>
))}
</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>
);
};

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,
@ -21,8 +19,9 @@ import {
import { PresentationPageProps } from "../types";
import LoadingState from "./LoadingState";
import { useFontLoader } from "../../hooks/useFontLoader";
import { usePresentationUndoRedo } from "../hooks/PresentationUndoRedo";
import PresentationHeader from "./PresentationHeader";
const PresentationPage: React.FC<PresentationPageProps> = ({
presentation_id,
}) => {
@ -32,7 +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);
const { presentationData, isStreaming } = useSelector(
@ -81,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];
@ -123,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 ">
<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)] 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,96 @@ 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
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>
<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" />
@ -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]"
>
<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"
>
<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 ${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"
>
<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"
>
<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-[#5141e5]' : 'border-[#EDEEEF]'
}`}
>

View file

@ -0,0 +1,120 @@
"use client";
import React, { useState } from 'react'
// import { Theme } from '@/app/(presentation-generator)/services/api/types'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Palette } from 'lucide-react';
import { useDispatch } from 'react-redux';
// import { updateTheme } from '@/store/slices/presentationGeneration';
import { useRouter } from 'next/navigation';
import { useFontLoader } from '../../hooks/useFontLoader';
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 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}"`)
// 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({} as any))
}
return (
<Popover>
<PopoverTrigger>
<button className="text-sm px-[18px] py-2.5 gap-1.5 flex items-center font-inter border border-[#EDEEEF] bg-[#F6F6F9] text-black hover:text-blue-500 duration-300 rounded-[88px] font-medium">
<Palette className="h-4 w-4" /> Theme
</button>
</PopoverTrigger>
<PopoverContent className="w-fit max-h-80 overflow-y-auto custom_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

@ -8,7 +8,7 @@ import { ArrowLeft, Home, Loader2, Trash2 } from "lucide-react";
import { useFontLoader } from "../../hooks/useFontLoader";
import { MixpanelEvent, trackEvent } from "@/utils/mixpanel";
import TemplateService from "../../services/api/template";
import Header from "../../dashboard/components/Header";
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";

View file

@ -5,15 +5,19 @@ import { Card } from "@/components/ui/card";
import { ExternalLink, Loader2, Plus } from "lucide-react";
import { templates } from "@/app/presentation-templates";
<<<<<<< feat/revamp_design
import { TemplateLayoutsWithSettings, TemplateWithData } from "@/app/presentation-templates/utils";
=======
import type { TemplateLayoutsWithSettings } from "@/app/presentation-templates/utils";
import { TemplateWithData } from "@/app/presentation-templates/utils";
>>>>>>> main
import {
useCustomTemplateSummaries,
useCustomTemplatePreview,
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 }) => {

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,23 +1,19 @@
import { Textarea } from "@/components/ui/textarea";
import { useState } from "react";
interface PromptInputProps {
value: string;
onChange: (value: string) => void;
}
export function PromptInput({
value,
onChange,
}: PromptInputProps) {
export function PromptInput({ value, onChange }: PromptInputProps) {
const [showHint, setShowHint] = useState(false);
const handleChange = (value: string) => {
setShowHint(value.length > 0);
onChange(value);
const handleChange = (val: string) => {
setShowHint(val.length > 0);
onChange(val);
};
return (
<div className="space-y-2">
<div className="relative">
@ -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">
{hasFiles ? `${filteredFiles.length} attachment${filteredFiles.length > 1 ? 's' : ''}` : 'No attachments yet'}
</p>
<button
type="button"
onClick={handleClearFiles}
disabled={!hasFiles}
className={`text-sm font-medium ${!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">
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" title={file.name}>
{file.name}
</p>
<p className="text-xs text-gray-500">{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">
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">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-instrument_sans 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-semibold font-instrument_sans 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

@ -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

@ -84,167 +84,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

@ -81,150 +81,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

@ -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,11 @@ 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";
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 +35,7 @@ interface LLMProviderSelectionProps {
) => void;
}
export default function LLMProviderSelection({
initialLLMConfig,
onConfigChange,
@ -85,7 +44,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 +93,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 +214,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">
@ -418,7 +306,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 +335,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 +369,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 +378,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

@ -14,25 +14,29 @@ import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { Switch } from "./ui/switch";
import { LLMConfig } from "@/types/llm_config";
interface OpenAIConfigProps {
openaiApiKey: string;
openaiModel: string;
webGrounding?: boolean;
onInputChange: (value: string | boolean, field: string) => void;
llmConfig: LLMConfig;
}
export default function OpenAIConfig({
openaiApiKey,
openaiModel,
webGrounding,
onInputChange
onInputChange,
llmConfig
}: OpenAIConfigProps) {
const [openModelSelect, setOpenModelSelect] = useState(false);
const [availableModels, setAvailableModels] = useState<string[]>([]);
const [modelsLoading, setModelsLoading] = useState(false);
const [modelsChecked, setModelsChecked] = useState(false);
const [apiKey, setApiKey] = useState(openaiApiKey);
const isImageGenerationDisabled = llmConfig.DISABLE_IMAGE_GENERATION ?? false;
const openaiUrl = "https://api.openai.com/v1";
@ -84,152 +88,189 @@ export default function OpenAIConfig({
};
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">
OpenAI API Key
</label>
<div className="relative">
<input
type="text"
value={openaiApiKey}
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>
<div className="mb-4 flex items-center justify-between bg-white p-10">
<div className="">
{/* 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 || !openaiApiKey}
className={`w-full py-2.5 px-4 rounded-lg transition-all duration-200 border-2 ${modelsLoading || !openaiApiKey
? "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 OpenAI models.
<h3 className="text-xl font-normal text-[#191919]">OpenAI 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">
{/* 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 OpenAI 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">
{openaiModel
? availableModels.find(model => model === openaiModel) || openaiModel
: "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)" }}
<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">
OpenAI API Key
</label>
<input
type="text"
value={openaiApiKey}
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>
{/* Check for available models button - show when no models checked or no models found */}
{(!modelsChecked || (modelsChecked && availableModels.length === 0)) && (
<button
onClick={fetchAvailableModels}
disabled={modelsLoading || !openaiApiKey}
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 || !openaiApiKey
? " 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, "openai_model");
setOpenModelSelect(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
openaiModel === 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 OpenAI 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 OpenAI 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">
{openaiModel
? availableModels.find(model => model === openaiModel) || openaiModel
: "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, "openai_model");
setOpenModelSelect(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
openaiModel === 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 - show 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")}
/>
<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, image generation, and advanced AI features.
</p>
</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 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 className="flex items-center mb-4 gap-2.5 ">
<Switch
checked={!!isImageGenerationDisabled}
onCheckedChange={(checked) => onInputChange(checked, "disable_image_generation")}
/>
<label className="text-sm font-medium text-gray-700">
Disable Image Generation
</label>
</div>
</div>
<div className="w-[295px]"></div>
</div>
</div>
</div>
);
}

View file

@ -3,9 +3,9 @@ import { TooltipProvider } from '@radix-ui/react-tooltip'
import React from 'react'
import { TooltipContent, TooltipTrigger, } from './ui/tooltip'
const ToolTip = ({ children, content }: { children: React.ReactNode, content: string }) => {
const ToolTip = ({ children, content, className }: { children: React.ReactNode, content: string, className?: string }) => {
return (
<div>
<div className={className}>
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>

File diff suppressed because it is too large Load diff

After

Width:  |  Height:  |  Size: 1,021 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

View file

@ -22,6 +22,7 @@ export interface LLMProviderOption {
description?: string;
model_value?: string;
model_label?: string;
url?: string;
}
export const IMAGE_PROVIDERS: Record<string, ImageProviderOption> = {
@ -95,16 +96,19 @@ export const LLM_PROVIDERS: Record<string, LLMProviderOption> = {
value: "openai",
label: "OpenAI",
description: "OpenAI's latest text generation model",
url: "https://api.openai.com/v1",
},
google: {
value: "google",
label: "Google",
description: "Google's primary text generation model",
url: "https://api.google.com/v1",
},
anthropic: {
value: "anthropic",
label: "Anthropic",
description: "Anthropic's Claude models",
url: "https://api.anthropic.com/v1",
},
ollama: {
value: "ollama",
@ -122,3 +126,34 @@ export const LLM_PROVIDERS: Record<string, LLMProviderOption> = {
description: "ChatGPT Plus/Pro via OAuth",
},
};
export 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",
},
];
export 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",
},
];