Merge pull request #428 from presenton/feat/revamp_design
feat/revamp design
This commit is contained in:
commit
e7d3a39e0d
75 changed files with 10107 additions and 2508 deletions
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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}
|
||||
/>
|
||||
))}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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 */}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import React from 'react'
|
||||
import TemplatePanel from './components/TemplatePanel'
|
||||
|
||||
const page = () => {
|
||||
return (
|
||||
<TemplatePanel />
|
||||
)
|
||||
}
|
||||
|
||||
export default page
|
||||
|
|
@ -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" })}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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"
|
||||
/>
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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]'
|
||||
}`}
|
||||
>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
||||
|
||||
];
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
357
servers/nextjs/components/ImageSelectionConfig.tsx
Normal file
357
servers/nextjs/components/ImageSelectionConfig.tsx
Normal 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 "Export
|
||||
(API)" 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
|
||||
|
|
@ -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 "Export
|
||||
(API)" 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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
5311
servers/nextjs/public/card_bg.svg
Normal file
5311
servers/nextjs/public/card_bg.svg
Normal file
File diff suppressed because it is too large
Load diff
|
After Width: | Height: | Size: 1,021 KiB |
BIN
servers/nextjs/public/create_presentation.png
Normal file
BIN
servers/nextjs/public/create_presentation.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 227 KiB |
9
servers/nextjs/public/image-markup.svg
Normal file
9
servers/nextjs/public/image-markup.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 23 KiB |
BIN
servers/nextjs/public/logo-with-bg.png
Normal file
BIN
servers/nextjs/public/logo-with-bg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.8 KiB |
BIN
servers/nextjs/public/providers/image-provider.png
Normal file
BIN
servers/nextjs/public/providers/image-provider.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
BIN
servers/nextjs/public/providers/openai.png
Normal file
BIN
servers/nextjs/public/providers/openai.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.9 KiB |
|
|
@ -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",
|
||||
},
|
||||
];
|
||||
Loading…
Add table
Reference in a new issue