feat: add new image assets and update presentation generation state
- Added new image assets: image_mode.png, logo-with-bg.png, image-provider.png, and openai.png. - Enhanced presentation generation state to include theme property. - Introduced updateTheme action in presentation generation slice. - Updated user configuration with default LLM and image provider settings. - Modified Tailwind CSS configuration to include new font families. - Improved API utility functions for better handling of URLs in Electron environment. - Adjusted PPTX model utility for border radius handling. - Expanded provider constants to include URLs and icons for LLM providers. - Updated provider utility functions for consistent API URL usage. - Added new quality options for image generation providers. - Updated package-lock.json to include new dependencies for react-colorful and scheduler.
This commit is contained in:
parent
ecc004788a
commit
fc1bad2d7c
129 changed files with 14965 additions and 2169 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,128 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { LayoutDashboard, Star, Brain, Settings, Palette } from "lucide-react";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
|
||||
|
||||
export const defaultNavItems = [
|
||||
{ key: "dashboard" as const, label: "Dashboard", icon: LayoutDashboard },
|
||||
{ key: "templates" as const, label: "Standard", icon: Star },
|
||||
{ key: "designs" as const, label: "Smart", icon: Brain },
|
||||
|
||||
|
||||
|
||||
];
|
||||
export const BelongingNavItems = [
|
||||
{ key: "settings" as const, label: "Settings", icon: Settings },
|
||||
]
|
||||
|
||||
const DashboardSidebar = () => {
|
||||
|
||||
|
||||
const pathname = usePathname();
|
||||
const activeTab = pathname.split("?")[0].split("/").pop();
|
||||
const router = useRouter();
|
||||
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<aside
|
||||
className="sticky top-0 h-screen w-[115px] flex flex-col justify-between bg-[#F6F6F9] backdrop-blur border-r border-slate-200/60 px-4 py-8"
|
||||
aria-label="Dashboard sidebar"
|
||||
>
|
||||
<div>
|
||||
|
||||
<div onClick={() => router.push("/dashboard")} className="flex items-center pb-6 border-b border-slate-200/60 gap-2 ">
|
||||
<div className="bg-[#7C51F8] rounded-full cursor-pointer p-1 flex justify-center items-center mx-auto">
|
||||
<img src="/logo-with-bg.png" alt="Presenton logo" className="h-[40px] object-contain w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<nav className="pt-6 font-syne" aria-label="Dashboard sections">
|
||||
<div className=" space-y-6">
|
||||
|
||||
{/* Dashboard */}
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={`/dashboard`}
|
||||
className={[
|
||||
"flex flex-col tex-center items-center gap-2 transition-colors",
|
||||
pathname === "/dashboard" ? "" : "ring-transparent",
|
||||
].join(" ")}
|
||||
aria-label="Dashboard"
|
||||
title="Dashboard"
|
||||
>
|
||||
<LayoutDashboard className={["h-4 w-4", pathname === "/dashboard" ? "text-[#5146E5]" : "text-slate-600"].join(" ")} />
|
||||
<span className="text-[11px] text-slate-800">Dashboard</span>
|
||||
</Link>
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={`/templates`}
|
||||
className={[
|
||||
"flex flex-col tex-center items-center gap-2 transition-colors",
|
||||
pathname === "/templates" ? "" : "ring-transparent",
|
||||
].join(" ")}
|
||||
aria-label="Templates"
|
||||
title="Templates"
|
||||
>
|
||||
<div className="flex flex-col cursor-pointer tex-center items-center gap-2 transition-colors">
|
||||
<Star className={`h-4 w-4 ${pathname === "/templates" ? "text-[#5146E5]" : "text-slate-600"}`} />
|
||||
<span className="text-[11px] text-slate-800">Templates</span>
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
prefetch={false}
|
||||
href={`/theme`}
|
||||
className={[
|
||||
"flex flex-col tex-center items-center gap-2 transition-colors",
|
||||
pathname === "/theme" ? "" : "ring-transparent",
|
||||
].join(" ")}
|
||||
aria-label="Theme"
|
||||
title="Theme"
|
||||
>
|
||||
<div className="flex flex-col cursor-pointer tex-center items-center gap-2 transition-colors">
|
||||
<Palette className={`h-4 w-4 ${pathname === "/theme" ? "text-[#5146E5]" : "text-slate-600"}`} />
|
||||
<span className="text-[11px] text-slate-800">Themes</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div className=" pt-5 border-t border-slate-200/60 font-syne "
|
||||
>
|
||||
{BelongingNavItems.map(({ key, label: itemLabel, icon: Icon }) => {
|
||||
const isActive = activeTab === key;
|
||||
return (
|
||||
<Link
|
||||
prefetch={false}
|
||||
key={key}
|
||||
href={`/${key}`}
|
||||
className={[
|
||||
"flex flex-col tex-center items-center gap-2 transition-colors ",
|
||||
isActive ? "" : "ring-transparent",
|
||||
].join(" ")}
|
||||
aria-label={itemLabel}
|
||||
title={itemLabel}
|
||||
>
|
||||
<Icon className={["h-4 w-4", isActive ? "text-[#5146E5]" : "text-slate-600"].join(" ")} />
|
||||
<span className="text-[11px] text-slate-800">{itemLabel}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
</aside>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardSidebar;
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
|
||||
import { DashboardApi } from "@/app/(presentation-generator)/services/api/dashboard";
|
||||
import { PresentationGrid } from "@/app/(presentation-generator)/(dashboard)/dashboard/components/PresentationGrid";
|
||||
import Link from "next/link";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
|
||||
|
||||
|
||||
const DashboardPage: React.FC = () => {
|
||||
const [presentations, setPresentations] = useState<any>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
await fetchPresentations();
|
||||
};
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const fetchPresentations = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const data = await DashboardApi.getPresentations();
|
||||
data.sort(
|
||||
(a: any, b: any) =>
|
||||
new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime()
|
||||
);
|
||||
setPresentations(data);
|
||||
} catch (err) {
|
||||
setError(null);
|
||||
setPresentations([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removePresentation = (presentationId: string) => {
|
||||
setPresentations((prev: any) =>
|
||||
prev ? prev.filter((p: any) => p.id !== presentationId) : []
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full px-6 pb-10 relative">
|
||||
<div className="sticky top-0 right-0 z-50 py-[28px] backdrop-blur mb-4 ">
|
||||
<div className="flex xl:flex-row flex-col gap-6 xl:gap-0 items-center justify-between">
|
||||
<h3 className=" text-[28px] tracking-[-0.84px] font-unbounded font-normal text-[#101828] flex items-center gap-2">
|
||||
|
||||
Slide Presentations
|
||||
</h3>
|
||||
<div className="flex gap-2.5 max-sm:w-full max-md:justify-center max-sm:flex-wrap">
|
||||
|
||||
|
||||
|
||||
<Link
|
||||
href="/generate"
|
||||
className="inline-flex items-center gap-2 rounded-xl px-4 py-2.5 text-black text-sm font-semibold font-syne shadow-sm hover:shadow-md"
|
||||
aria-label="Create new presentation"
|
||||
style={{
|
||||
borderRadius: "48px",
|
||||
background: "linear-gradient(270deg, #D5CAFC 2.4%, #E3D2EB 27.88%, #F4DCD3 69.23%, #FDE4C2 100%)",
|
||||
}}
|
||||
>
|
||||
|
||||
<span className="hidden md:inline">New presentation</span>
|
||||
<span className="md:hidden">New</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Link>
|
||||
{/* {
|
||||
<Link
|
||||
href="/theme?tab=new-theme"
|
||||
className="inline-flex items-center font-inter font-normal gap-2 rounded-xl px-4 py-2.5 text-black text-sm shadow-sm hover:shadow-md"
|
||||
aria-label="Create new themes"
|
||||
style={{
|
||||
borderRadius: "48px",
|
||||
background: "linear-gradient(270deg, #D5CAFC 2.4%, #E3D2EB 27.88%, #F4DCD3 69.23%, #FDE4C2 100%)",
|
||||
}}
|
||||
>
|
||||
<span className="hidden md:inline">New Themes</span>
|
||||
<span className="md:hidden">New</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Link>
|
||||
} */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PresentationGrid
|
||||
presentations={presentations}
|
||||
type="slide"
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
onPresentationDeleted={removePresentation}
|
||||
/>
|
||||
<div
|
||||
className='fixed z-0 bottom-[-16.5rem] left-0 w-full h-full'
|
||||
style={{
|
||||
height: "341px",
|
||||
borderRadius: '1440px',
|
||||
background: 'radial-gradient(5.92% 104.69% at 50% 100%, rgba(122, 90, 248, 0.00) 0%, rgba(255, 255, 255, 0.00) 100%), radial-gradient(50% 50% at 50% 50%, rgba(122, 90, 248, 0.80) 0%, rgba(122, 90, 248, 0.00) 100%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DashboardPage;
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
import React from 'react';
|
||||
|
||||
export const EmptyState = () => {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[70vh] bg-white/50 rounded-lg">
|
||||
<div className="mb-4">
|
||||
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M42 14.4V33.6C42 40.8 38 44.8 30.8 44.8H17.2C10 44.8 6 40.8 6 33.6V14.4C6 7.2 10 3.2 17.2 3.2H30.8C38 3.2 42 7.2 42 14.4Z" stroke="#667085" strokeWidth="3" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M6.96002 16.4188H41.04" stroke="#667085" strokeWidth="3" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M19.04 3.21875V15.1388" stroke="#667085" strokeWidth="3" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M28.96 3.21875V14.2388" stroke="#667085" strokeWidth="3" strokeMiterlimit="10" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-[#101828] text-lg font-roboto font-medium mb-1">
|
||||
You don't have any presentations yet.
|
||||
</h3>
|
||||
<p className="text-[#667085] text-base font-roboto">
|
||||
Start creating the first one.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
"use client";
|
||||
|
||||
import Wrapper from "@/components/Wrapper";
|
||||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
|
||||
const Header = () => {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<div className="w-full sticky top-0 z-50 py-7 ">
|
||||
<Wrapper className="px-5 sm:px-10 lg:px-20">
|
||||
<div className="flex items-center justify-between py-1">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* {(pathname !== "/upload" && pathname !== "/dashboard") && <BackBtn />} */}
|
||||
<Link href="/dashboard" onClick={() => trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/dashboard" })}>
|
||||
<img
|
||||
src="/logo-with-bg.png"
|
||||
alt="Presentation logo"
|
||||
className="h-[40px] w-[40px]"
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</Wrapper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
'use client'
|
||||
import React from "react";
|
||||
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { DashboardApi } from "@/app/(presentation-generator)/services/api/dashboard";
|
||||
import { EllipsisVertical, Star, Trash } from "lucide-react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
} from "@/components/ui/popover";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { useFontLoader } from "@/app/(presentation-generator)/hooks/useFontLoader";
|
||||
import SlideScale from "@/app/(presentation-generator)/components/PresentationRender";
|
||||
import MarkdownRenderer from "@/components/MarkDownRender";
|
||||
|
||||
export const PresentationCard = ({
|
||||
id,
|
||||
title,
|
||||
presentation,
|
||||
onDeleted
|
||||
}: {
|
||||
id: string;
|
||||
title: string;
|
||||
presentation: any;
|
||||
onDeleted?: (presentationId: string) => void;
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
useFontLoader(presentation.fonts || []);
|
||||
const handlePreview = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
router.push(`/presentation?id=${id}&type=standard`);
|
||||
};
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
|
||||
const response = await DashboardApi.deletePresentation(id);
|
||||
|
||||
if (response) {
|
||||
toast.success("Presentation deleted", {
|
||||
description: "The presentation has been deleted successfully",
|
||||
});
|
||||
if (onDeleted) {
|
||||
onDeleted(id);
|
||||
}
|
||||
} else {
|
||||
toast.error("Error deleting presentation");
|
||||
}
|
||||
};
|
||||
const firstSlide = presentation?.slides?.[0];
|
||||
return (
|
||||
<Card
|
||||
suppressHydrationWarning={true}
|
||||
onClick={handlePreview}
|
||||
className="bg-[#F8FBFB] font-syne shadow-none sm:shadow-none presentation-card rounded-[12px] p-0 group hover:shadow-md transition-all duration-500 slide-theme cursor-pointer overflow-hidden flex flex-col"
|
||||
>
|
||||
<div suppressHydrationWarning={true} className="flex flex-col flex-1 relative z-40">
|
||||
{/* <p className=" text-xs font-syne absolute top-2 flex gap-1 capitalize items-center left-2 rounded-[100px] px-2.5 py-1 bg-[#3A3A3AF5] text-white font-semibold z-40 ">
|
||||
|
||||
{presentation.type}
|
||||
</p> */}
|
||||
|
||||
<img src="/card_bg.svg" alt="" className="absolute top-0 left-0 w-full h-full object-cover" />
|
||||
<div className="scale-[0.75] mt-4 border border-gray-300 rounded-lg overflow-hidden">
|
||||
|
||||
<SlideScale slide={firstSlide} />
|
||||
</div>
|
||||
|
||||
<div className="w-full py-3 px-5 mt-auto z-40 relative bg-white border-t border-[#EDEEEF]">
|
||||
<div className="flex items-center justify-between gap-7 w-full">
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<div className="text-sm text-[#191919] font-semibold overflow-hidden line-clamp-1">
|
||||
<MarkdownRenderer content={title} className="text-sm mb-0 text-[#191919] font-semibold overflow-hidden line-clamp-1" />
|
||||
</div>
|
||||
<p className="text-[#808080] text-sm font-syne">
|
||||
{new Date(presentation?.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger className="w-6 h-6 hover:bg-gray-100 rounded-full flex items-center justify-center text-gray-500 hover:text-gray-700" onClick={(e) => e.stopPropagation()}>
|
||||
<EllipsisVertical className="w-6 h-6 text-gray-500" />
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="bg-white w-[200px]">
|
||||
<button
|
||||
className="flex items-center justify-between w-full px-2 py-1 hover:bg-gray-100"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
<p>Delete</p>
|
||||
<Trash className="w- h-4 text-red-500" />
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
import React from "react";
|
||||
import { PresentationCard } from "./PresentationCard";
|
||||
import { PlusIcon } from "@radix-ui/react-icons";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { PresentationResponse } from "@/app/(presentation-generator)/services/api/dashboard";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
|
||||
interface PresentationGridProps {
|
||||
presentations: PresentationResponse[];
|
||||
type: "slide" | "video";
|
||||
isLoading?: boolean;
|
||||
error?: string | null;
|
||||
onPresentationDeleted?: (presentationId: string) => void;
|
||||
}
|
||||
|
||||
export const PresentationGrid = ({
|
||||
presentations,
|
||||
type,
|
||||
isLoading = false,
|
||||
error = null,
|
||||
onPresentationDeleted,
|
||||
}: PresentationGridProps) => {
|
||||
const router = useRouter();
|
||||
const handleCreateNewPresentation = () => {
|
||||
if (type === "slide") {
|
||||
router.push("/upload");
|
||||
} else {
|
||||
router.push("/editor");
|
||||
}
|
||||
};
|
||||
|
||||
const ShimmerCard = () => (
|
||||
<div className="flex flex-col gap-4 min-h-[200px] bg-white/70 rounded-lg p-4 animate-pulse">
|
||||
<div className="w-full h-24 bg-gray-200 rounded-lg"></div>
|
||||
<div className="space-y-3">
|
||||
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
|
||||
<div className="h-3 bg-gray-200 rounded w-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const CreateNewCard = () => (
|
||||
<div
|
||||
onClick={handleCreateNewPresentation}
|
||||
className="flex flex-col cursor-pointer group ring-1 ring-inset ring-slate-200 hover:ring-[#8A7DFF]/40 bg-white/80 rounded-xl overflow-hidden transition-all duration-300 font-syne"
|
||||
>
|
||||
<img src="/create_presentation.png" alt="New Presentation" className="w-full aspect-[16/11] object-cover" />
|
||||
<div className="flex items-center gap-3 p-3 mt-auto border border-[#EDEEEF]">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="45" height="46" viewBox="0 0 45 46" fill="none" className="flex-shrink-0">
|
||||
<rect width="45" height="46" rx="8" fill="#FB6514" />
|
||||
<g clipPath="url(#clip0_1789_6104)">
|
||||
<path d="M16.0332 17.1807L28.9665 17.1807" stroke="white" strokeWidth="1.11" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M28.3197 17.1807L28.3197 24.294C28.3197 24.637 28.1834 24.966 27.9409 25.2085C27.6983 25.4511 27.3694 25.5873 27.0264 25.5873L17.973 25.5873C17.63 25.5873 17.301 25.4511 17.0585 25.2085C16.8159 24.966 16.6797 24.637 16.6797 24.294L16.6797 17.1807" stroke="white" strokeWidth="1.11" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M19.2676 28.8202L22.5009 25.5869L25.7342 28.8202" stroke="white" strokeWidth="1.11" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1789_6104">
|
||||
<rect width="15.52" height="15.52" fill="white" transform="translate(14.7402 15.2402)" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<div>
|
||||
<h4 className="text-sm text-[#191919] font-semibold tracking-[0.14px]">Create New Presentation</h4>
|
||||
<p className="text-sm text-[#808080] font-medium tracking-[0.14px] flex items-center gap-2">Get Started <ArrowRight className="w-4 h-4" /></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 px-6 mt-10 md:grid-cols-2 lg:grid-cols-4 gap-5 sm:gap-6 w-full">
|
||||
<div className="flex flex-col gap-4 min-h-[200px] cursor-pointer group ring-1 ring-inset ring-slate-200 bg-white/80 rounded-xl items-center justify-center animate-pulse">
|
||||
<div className="rounded-full bg-slate-200 p-4">
|
||||
<div className="w-8 h-8" />
|
||||
</div>
|
||||
<div className="text-center space-y-2">
|
||||
<div className="h-4 bg-slate-200 rounded w-32 mx-auto"></div>
|
||||
<div className="h-3 bg-slate-200 rounded w-48 mx-auto"></div>
|
||||
</div>
|
||||
</div>
|
||||
{[...Array(15)].map((_, i) => (
|
||||
<ShimmerCard key={i} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<CreateNewCard />
|
||||
<div className="col-span-3 flex items-center justify-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<p className="mb-2">{error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="text-primary hover:text-primary/80 underline"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<CreateNewCard />
|
||||
{presentations &&
|
||||
presentations.length > 0 &&
|
||||
presentations.map((presentation) => (
|
||||
<PresentationCard
|
||||
key={presentation.id}
|
||||
id={presentation.id}
|
||||
title={presentation.title}
|
||||
presentation={presentation}
|
||||
onDeleted={onPresentationDeleted}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import React from 'react';
|
||||
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Presentation } from '../types';
|
||||
|
||||
export const PresentationListItem: React.FC<Presentation> = ({
|
||||
title,
|
||||
date,
|
||||
thumbnail,
|
||||
type
|
||||
}) => {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="flex items-center gap-4 p-4">
|
||||
<div className="relative w-[120px] aspect-video rounded-md overflow-hidden">
|
||||
<img
|
||||
src={thumbnail}
|
||||
alt={title}
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<h3 className="font-medium">{title}</h3>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{/* {formatDistanceToNow(new Date(date), { addSuffix: true })} */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{type === 'video' ? (
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15 12L9 8V16L15 12Z" fill="currentColor" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M4 4H20V16H4V4Z" stroke="currentColor" strokeWidth="2" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
|
@ -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 @@
|
|||
export interface Presentation {
|
||||
id: string;
|
||||
title: string;
|
||||
date: string;
|
||||
thumbnail: string;
|
||||
type: 'video' | 'slide';
|
||||
}
|
||||
|
||||
export interface PresentationFilter {
|
||||
type?: 'video' | 'slide';
|
||||
search?: string;
|
||||
dateRange?: {
|
||||
start: Date;
|
||||
end: Date;
|
||||
};
|
||||
}
|
||||
|
|
@ -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,355 @@
|
|||
import ToolTip from '@/components/ToolTip'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Select, SelectItem, SelectContent, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { LLMConfig } from '@/types/llm_config'
|
||||
import { DALLE_3_QUALITY_OPTIONS, GPT_IMAGE_1_5_QUALITY_OPTIONS, IMAGE_PROVIDERS } from '@/utils/providerConstants'
|
||||
import { Check, ChevronUp, Eye, EyeOff } from 'lucide-react'
|
||||
import React, { useState } from 'react'
|
||||
|
||||
const ImageProvider = ({ llmConfig, setLlmConfig }: { llmConfig: LLMConfig, setLlmConfig: (config: any) => void }) => {
|
||||
const [openImageProviderSelect, setOpenImageProviderSelect] = useState(false);
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const isImageGenerationDisabled = llmConfig.DISABLE_IMAGE_GENERATION ?? false;
|
||||
const handleChangeImageGenerationDisabled = (value: boolean) => {
|
||||
setLlmConfig((prev: any) => ({
|
||||
...prev,
|
||||
DISABLE_IMAGE_GENERATION: value
|
||||
}));
|
||||
}
|
||||
const input_field_changed = (value: string, field: string) => {
|
||||
setLlmConfig((prev: any) => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}));
|
||||
setOpenImageProviderSelect(false);
|
||||
}
|
||||
|
||||
const getFieldValue = (field?: string) => {
|
||||
if (!field) return "";
|
||||
return (llmConfig as Record<string, string | undefined>)[field] || "";
|
||||
};
|
||||
|
||||
const updateFieldValue = (field: string | undefined, value: string) => {
|
||||
if (!field) return;
|
||||
setLlmConfig((prev: any) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const getTextProviderApiField = () => {
|
||||
if (llmConfig.LLM === "openai") return "OPENAI_API_KEY";
|
||||
if (llmConfig.LLM === "google") return "GOOGLE_API_KEY";
|
||||
if (llmConfig.LLM === "anthropic") return "ANTHROPIC_API_KEY";
|
||||
return "";
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const renderQualitySelector = (llmConfig: LLMConfig, input_field_changed: (value: string, field: string) => void) => {
|
||||
if (llmConfig.IMAGE_PROVIDER === "dall-e-3") {
|
||||
return (
|
||||
<div className="w-[205px] mr-0 ml-auto">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
DALL·E 3 Image Quality
|
||||
</label>
|
||||
<div className="">
|
||||
<Select value={llmConfig.DALL_E_3_QUALITY} onValueChange={(value) => input_field_changed(value, "DALL_E_3_QUALITY")}>
|
||||
<SelectTrigger className="w-full h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between">
|
||||
<SelectValue placeholder="Select a quality" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DALLE_3_QUALITY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (llmConfig.IMAGE_PROVIDER === "gpt-image-1.5") {
|
||||
return (
|
||||
<div className="w-[205px]">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
GPT Image 1.5 Quality
|
||||
</label>
|
||||
<div className="">
|
||||
<Select
|
||||
value={llmConfig.GPT_IMAGE_1_5_QUALITY}
|
||||
onValueChange={(value) => input_field_changed(value, "GPT_IMAGE_1_5_QUALITY")}
|
||||
>
|
||||
<SelectTrigger
|
||||
|
||||
className="w-full h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between">
|
||||
<SelectValue placeholder="Select a quality" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{GPT_IMAGE_1_5_QUALITY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-6 bg-[#F9F8F8] p-7 rounded-[12px] ">
|
||||
{/* API Key Input */}
|
||||
<div className="mb-4 bg-white p-10 pt-5 rounded-[12px]">
|
||||
<ToolTip content="Enable/Disable Image Generation" className='flex justify-end items-center'>
|
||||
<div className='flex justify-end items-center'>
|
||||
<Switch
|
||||
checked={!isImageGenerationDisabled}
|
||||
className='data-[state=checked]:bg-[#4791FF] data-[state=unchecked]:bg-gray-400'
|
||||
onCheckedChange={(checked) => handleChangeImageGenerationDisabled(!checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</ToolTip>
|
||||
<div className='flex items-center justify-between'>
|
||||
|
||||
|
||||
<div className=" max-w-[290px] pb-[50px]">
|
||||
<div className='w-[60px] h-[60px] px-[13.5px] py-[14.2px] rounded-[4px] flex items-center justify-center'
|
||||
style={{ backgroundColor: '#F4F3FF' }}
|
||||
>
|
||||
<img src="/image-markup.svg" className='w-full h-full object-cover' alt='image-markup' />
|
||||
</div>
|
||||
<h3 className="text-xl font-normal text-[#191919] py-2.5">Image Generation Settings</h3>
|
||||
<p className=" text-sm text-gray-500">
|
||||
Choosing where images come from
|
||||
</p>
|
||||
</div>
|
||||
<div className=' '>
|
||||
|
||||
<div className='flex items-center justify-end gap-4'>
|
||||
|
||||
{!isImageGenerationDisabled && (
|
||||
<>
|
||||
{/* Image Provider Selection */}
|
||||
<div className="">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Select Image Provider
|
||||
</label>
|
||||
<div className="w-full">
|
||||
<Popover
|
||||
open={openImageProviderSelect}
|
||||
onOpenChange={setOpenImageProviderSelect}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openImageProviderSelect}
|
||||
className="w-[205px] h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between"
|
||||
>
|
||||
<div className="flex gap-3 items-center">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{llmConfig.IMAGE_PROVIDER
|
||||
? IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER]
|
||||
?.label || llmConfig.IMAGE_PROVIDER
|
||||
: "Select image provider"}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronUp className="w-4 h-4 text-gray-500" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
align="start"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search provider..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No provider found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{Object.values(IMAGE_PROVIDERS).map(
|
||||
(provider, index) => (
|
||||
<CommandItem
|
||||
key={index}
|
||||
value={provider.value}
|
||||
onSelect={(value) => {
|
||||
input_field_changed(value, "IMAGE_PROVIDER");
|
||||
setOpenImageProviderSelect(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
llmConfig.IMAGE_PROVIDER === provider.value
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="flex flex-col space-y-1 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium text-gray-900 capitalize">
|
||||
{provider.label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-600 leading-relaxed">
|
||||
{provider.description}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
)
|
||||
)}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* Dynamic API Key Input for Image Provider */}
|
||||
{llmConfig.IMAGE_PROVIDER &&
|
||||
IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER] &&
|
||||
(() => {
|
||||
const provider = IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER];
|
||||
|
||||
|
||||
|
||||
// Show ComfyUI configuration
|
||||
if (provider.value === "comfyui") {
|
||||
return (
|
||||
<div className=" space-y-4">
|
||||
<div className='w-[205px]'>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
ComfyUI Server URL
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="http://192.168.1.7:8188"
|
||||
className="w-full px-4 py-2.5 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
|
||||
value={llmConfig.COMFYUI_URL || ""}
|
||||
onChange={(e) => {
|
||||
input_field_changed(
|
||||
e.target.value,
|
||||
"COMFYUI_URL"
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show API key input for other providers
|
||||
return (
|
||||
<div className=" w-[205px]">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
{provider.apiKeyFieldLabel}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
placeholder={`Enter your ${provider.apiKeyFieldLabel}`}
|
||||
className="w-full px-4 py-2.5 h-12 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
|
||||
value={getFieldValue(provider.apiKeyField)}
|
||||
onChange={(e) =>
|
||||
updateFieldValue(
|
||||
provider.apiKeyField,
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiKey((prev) => !prev)}
|
||||
className='absolute right-2 top-1/2 -translate-y-1/2 bg-white px-2 py-1 cursor-pointer'
|
||||
>
|
||||
{showApiKey ? <Eye className='w-4 h-4 text-gray-500' /> : <EyeOff className='w-4 h-4 text-gray-500' />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{!isImageGenerationDisabled && <div className='flex justify-end items-center mt-[18px]'>
|
||||
|
||||
{renderQualitySelector(llmConfig, input_field_changed)}
|
||||
{llmConfig.IMAGE_PROVIDER === "comfyui" && <div className='w-full'>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Workflow JSON
|
||||
</label>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
placeholder='Paste your ComfyUI workflow JSON here (export via "Export (API)" in ComfyUI)'
|
||||
className="w-full px-4 py-2.5 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors font-mono text-xs"
|
||||
rows={3}
|
||||
value={llmConfig.COMFYUI_WORKFLOW || ""}
|
||||
onChange={(e) => {
|
||||
input_field_changed(
|
||||
e.target.value,
|
||||
"COMFYUI_WORKFLOW"
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>}
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Web Grounding Toggle - show at the end, below models dropdown */}
|
||||
{/* <div className="bg-white flex justify-between items-center p-10 rounded-[12px]">
|
||||
<div className=' max-w-[290px]'>
|
||||
|
||||
<h4 className="text-xl font-normal text-[#191919]">Advanced</h4>
|
||||
<p className="mt-2.5 text-sm text-gray-500">
|
||||
Configure advanced AI features.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
|
||||
<div className="w-[275px]">
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
<div className="w-[295px]"></div>
|
||||
</div>
|
||||
|
||||
</div> */}
|
||||
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ImageProvider
|
||||
|
|
@ -0,0 +1,345 @@
|
|||
"use client";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Loader2, Download, CheckCircle, ChevronRight } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { RootState } from "@/store/store";
|
||||
import { useSelector } from "react-redux";
|
||||
import { handleSaveLLMConfig } from "@/utils/storeHelpers";
|
||||
import {
|
||||
checkIfSelectedOllamaModelIsPulled,
|
||||
pullOllamaModel,
|
||||
} from "@/utils/providerUtils";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { LLMConfig } from "@/types/llm_config";
|
||||
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
|
||||
import SettingSideBar from "./SettingSideBar";
|
||||
import TextProvider from "./TextProvider";
|
||||
import ImageProvider from "./ImageProvider";
|
||||
import { IMAGE_PROVIDERS, LLM_PROVIDERS } from "@/utils/providerConstants";
|
||||
|
||||
// Button state interface
|
||||
interface ButtonState {
|
||||
isLoading: boolean;
|
||||
isDisabled: boolean;
|
||||
text: string;
|
||||
showProgress: boolean;
|
||||
progressPercentage?: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
const SettingsPage = () => {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [mode, setMode] = useState<'nanobanana' | 'presenton'>('presenton')
|
||||
const [selectedProvider, setSelectedProvider] = useState<'text-provider' | 'image-provider'>('text-provider')
|
||||
const userConfigState = useSelector((state: RootState) => state.userConfig);
|
||||
const [llmConfig, setLlmConfig] = useState<LLMConfig>(
|
||||
userConfigState.llm_config
|
||||
);
|
||||
const canChangeKeys = userConfigState.can_change_keys;
|
||||
const [buttonState, setButtonState] = useState<ButtonState>({
|
||||
isLoading: false,
|
||||
isDisabled: false,
|
||||
text: "Save Configuration",
|
||||
showProgress: false,
|
||||
});
|
||||
|
||||
const [downloadingModel, setDownloadingModel] = useState<{
|
||||
name: string;
|
||||
size: number | null;
|
||||
downloaded: number | null;
|
||||
status: string;
|
||||
done: boolean;
|
||||
} | null>(null);
|
||||
const [showDownloadModal, setShowDownloadModal] = useState<boolean>(false);
|
||||
|
||||
const downloadProgress = React.useMemo(() => {
|
||||
if (
|
||||
downloadingModel &&
|
||||
downloadingModel.downloaded !== null &&
|
||||
downloadingModel.size !== null
|
||||
) {
|
||||
return Math.round(
|
||||
(downloadingModel.downloaded / downloadingModel.size) * 100
|
||||
);
|
||||
}
|
||||
return 0;
|
||||
}, [downloadingModel?.downloaded, downloadingModel?.size]);
|
||||
|
||||
const handleSaveConfig = async () => {
|
||||
trackEvent(MixpanelEvent.Settings_SaveConfiguration_Button_Clicked, { pathname });
|
||||
try {
|
||||
setButtonState(prev => ({
|
||||
...prev,
|
||||
isLoading: true,
|
||||
isDisabled: true,
|
||||
text: "Saving Configuration...",
|
||||
}));
|
||||
trackEvent(MixpanelEvent.Settings_SaveConfiguration_API_Call);
|
||||
await handleSaveLLMConfig(llmConfig);
|
||||
if (llmConfig.LLM === "ollama" && llmConfig.OLLAMA_MODEL) {
|
||||
trackEvent(MixpanelEvent.Settings_CheckOllamaModelPulled_API_Call);
|
||||
const isPulled = await checkIfSelectedOllamaModelIsPulled(
|
||||
llmConfig.OLLAMA_MODEL
|
||||
);
|
||||
if (!isPulled) {
|
||||
setShowDownloadModal(true);
|
||||
trackEvent(MixpanelEvent.Settings_DownloadOllamaModel_API_Call);
|
||||
await handleModelDownload();
|
||||
}
|
||||
}
|
||||
toast.info("Configuration saved successfully");
|
||||
setButtonState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
isDisabled: false,
|
||||
text: "Save Configuration",
|
||||
}));
|
||||
trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/upload" });
|
||||
router.push("/upload");
|
||||
} catch (error) {
|
||||
toast.info(error instanceof Error ? error.message : "Failed to save configuration");
|
||||
setButtonState(prev => ({
|
||||
...prev,
|
||||
isLoading: false,
|
||||
isDisabled: false,
|
||||
text: "Save Configuration",
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleModelDownload = async () => {
|
||||
try {
|
||||
await pullOllamaModel(llmConfig.OLLAMA_MODEL!, setDownloadingModel);
|
||||
}
|
||||
finally {
|
||||
setDownloadingModel(null);
|
||||
setShowDownloadModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
downloadingModel &&
|
||||
downloadingModel.downloaded !== null &&
|
||||
downloadingModel.size !== null
|
||||
) {
|
||||
const percentage = Math.round(
|
||||
(downloadingModel.downloaded / downloadingModel.size) * 100
|
||||
);
|
||||
setButtonState({
|
||||
isLoading: true,
|
||||
isDisabled: true,
|
||||
text: `Downloading Model (${percentage}%)`,
|
||||
showProgress: true,
|
||||
progressPercentage: percentage,
|
||||
status: downloadingModel.status,
|
||||
});
|
||||
}
|
||||
|
||||
if (downloadingModel && downloadingModel.done) {
|
||||
setTimeout(() => {
|
||||
setShowDownloadModal(false);
|
||||
setDownloadingModel(null);
|
||||
toast.info("Model downloaded successfully!");
|
||||
}, 2000);
|
||||
}
|
||||
}, [downloadingModel]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canChangeKeys) {
|
||||
router.push("/dashboard");
|
||||
}
|
||||
}, [canChangeKeys, router]);
|
||||
|
||||
if (!canChangeKeys) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
const textProviderKey = llmConfig.LLM || "openai";
|
||||
const textProviderLabel =
|
||||
LLM_PROVIDERS[textProviderKey]?.label || textProviderKey;
|
||||
const selectedTextModel =
|
||||
textProviderKey === "openai"
|
||||
? llmConfig.OPENAI_MODEL
|
||||
: textProviderKey === "google"
|
||||
? llmConfig.GOOGLE_MODEL
|
||||
: textProviderKey === "anthropic"
|
||||
? llmConfig.ANTHROPIC_MODEL
|
||||
: textProviderKey === "ollama"
|
||||
? llmConfig.OLLAMA_MODEL
|
||||
: textProviderKey === "custom"
|
||||
? llmConfig.CUSTOM_MODEL
|
||||
: "";
|
||||
const textSummary = selectedTextModel
|
||||
? `${textProviderLabel} (${selectedTextModel})`
|
||||
: textProviderLabel;
|
||||
|
||||
const imageSummary = llmConfig.DISABLE_IMAGE_GENERATION
|
||||
? "Image generation disabled"
|
||||
: llmConfig.IMAGE_PROVIDER
|
||||
? IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER]?.label || llmConfig.IMAGE_PROVIDER
|
||||
: "No image provider";
|
||||
|
||||
return (
|
||||
<div className="h-screen font-syne flex flex-col overflow-hidden relative">
|
||||
<div
|
||||
className='fixed z-0 bottom-[-14.5rem] left-0 w-full h-full'
|
||||
style={{
|
||||
height: "341px",
|
||||
borderRadius: '1440px',
|
||||
background: 'radial-gradient(5.92% 104.69% at 50% 100%, rgba(122, 90, 248, 0.00) 0%, rgba(255, 255, 255, 0.00) 100%), radial-gradient(50% 50% at 50% 50%, rgba(122, 90, 248, 0.80) 0%, rgba(122, 90, 248, 0.00) 100%)',
|
||||
}}
|
||||
/>
|
||||
|
||||
<main className="w-full mx-auto gap-6 overflow-hidden flex ">
|
||||
<SettingSideBar mode={mode} setMode={setMode} selectedProvider={selectedProvider} setSelectedProvider={setSelectedProvider} />
|
||||
<div className="w-full">
|
||||
<div className="sticky top-0 right-0 z-50 py-[28px] backdrop-blur mb-4 ">
|
||||
<div className="flex gap-3 items-center ">
|
||||
<h3 className=" text-[28px] tracking-[-0.84px] font-unbounded font-normal text-black flex items-center gap-2">
|
||||
Settings
|
||||
</h3>
|
||||
<p className="text-[10px] px-2.5 py-0.5 rounded-[50px] text-[#7A5AF8] border border-[#EDEEEF] font-medium ">
|
||||
{textSummary} · {imageSummary}
|
||||
</p>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === 'nanobanana' && <div className=" w-full bg-[#F9F8F8] p-7 rounded-[20px]">
|
||||
<h4>Nano Banana</h4>
|
||||
</div>}
|
||||
{mode === 'presenton' && selectedProvider === 'text-provider' && <TextProvider
|
||||
|
||||
|
||||
onInputChange={(value, field) => {
|
||||
setLlmConfig(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}));
|
||||
}}
|
||||
llmConfig={llmConfig}
|
||||
/>}
|
||||
{mode === 'presenton' && selectedProvider === 'image-provider' && <ImageProvider llmConfig={llmConfig} setLlmConfig={setLlmConfig} />}
|
||||
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Fixed Bottom Button */}
|
||||
<div className=" mx-auto fixed bottom-20 right-5 ">
|
||||
<button
|
||||
onClick={handleSaveConfig}
|
||||
disabled={buttonState.isDisabled}
|
||||
style={{
|
||||
background: "linear-gradient(270deg, #D5CAFC 2.4%, #E3D2EB 27.88%, #F4DCD3 69.23%, #FDE4C2 100%)",
|
||||
color: "#101323",
|
||||
}}
|
||||
className={`w-full font-syne font-semibold flex items-center justify-center gap-2 py-3 px-5 rounded-[58px] transition-all duration-500 ${buttonState.isDisabled
|
||||
? "bg-gray-400 cursor-not-allowed"
|
||||
: "bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 focus:ring-4 focus:ring-blue-200"
|
||||
} text-white`}
|
||||
>
|
||||
{buttonState.isLoading ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{buttonState.text}
|
||||
</div>
|
||||
) : (
|
||||
buttonState.text
|
||||
)}
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Download Progress Modal */}
|
||||
{showDownloadModal && downloadingModel && (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white/95 backdrop-blur-md rounded-xl shadow-2xl max-w-md w-full p-6 relative">
|
||||
{/* Modal Content */}
|
||||
<div className="text-center">
|
||||
{/* Icon */}
|
||||
<div className="mb-4">
|
||||
{downloadingModel.done ? (
|
||||
<CheckCircle className="w-12 h-12 text-green-600 mx-auto" />
|
||||
) : (
|
||||
<Download className="w-12 h-12 text-blue-600 mx-auto animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
{downloadingModel.done
|
||||
? "Download Complete!"
|
||||
: "Downloading Model"}
|
||||
</h3>
|
||||
|
||||
{/* Model Name */}
|
||||
<p className="text-sm text-gray-600 mb-6">
|
||||
{llmConfig.OLLAMA_MODEL}
|
||||
</p>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{downloadProgress > 0 && (
|
||||
<div className="mb-4">
|
||||
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
|
||||
<div
|
||||
className="bg-blue-600 h-3 rounded-full transition-all duration-300 ease-out"
|
||||
style={{ width: `${downloadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-2">
|
||||
{downloadProgress}% Complete
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status */}
|
||||
{downloadingModel.status && (
|
||||
<div className="flex items-center justify-center gap-2 mb-4">
|
||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||
<span className="text-sm font-medium text-green-700 capitalize">
|
||||
{downloadingModel.status}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Message */}
|
||||
{downloadingModel.status &&
|
||||
downloadingModel.status !== "pulled" && (
|
||||
<div className="text-xs text-gray-500">
|
||||
{downloadingModel.status === "downloading" &&
|
||||
"Downloading model files..."}
|
||||
{downloadingModel.status === "verifying" &&
|
||||
"Verifying model integrity..."}
|
||||
{downloadingModel.status === "pulling" &&
|
||||
"Pulling model from registry..."}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Download Info */}
|
||||
{downloadingModel.downloaded && downloadingModel.size && (
|
||||
<div className="mt-4 p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex justify-between text-xs text-gray-600">
|
||||
<span>
|
||||
Downloaded:{" "}
|
||||
{(downloadingModel.downloaded / 1024 / 1024).toFixed(1)}{" "}
|
||||
MB
|
||||
</span>
|
||||
<span>
|
||||
Total: {(downloadingModel.size / 1024 / 1024).toFixed(1)}{" "}
|
||||
MB
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsPage;
|
||||
|
|
@ -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,576 @@
|
|||
import ToolTip from '@/components/ToolTip';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { LLMConfig } from '@/types/llm_config';
|
||||
import { getApiUrl } from '@/utils/api';
|
||||
import { LLM_PROVIDERS } from '@/utils/providerConstants';
|
||||
import { Check, Loader2, Eye, EyeOff, ChevronUp, User, RefreshCw, LogOut } from 'lucide-react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { toast } from 'sonner';
|
||||
|
||||
|
||||
interface OpenAIConfigProps {
|
||||
|
||||
onInputChange: (value: string | boolean, field: string) => void;
|
||||
llmConfig: LLMConfig;
|
||||
}
|
||||
const TextProvider = ({
|
||||
|
||||
onInputChange,
|
||||
llmConfig
|
||||
}: OpenAIConfigProps
|
||||
|
||||
) => {
|
||||
const [openProviderSelect, setOpenProviderSelect] = useState(false);
|
||||
const [openModelSelect, setOpenModelSelect] = useState(false);
|
||||
const [availableModels, setAvailableModels] = useState<string[]>([]);
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
const [modelsChecked, setModelsChecked] = useState(false);
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const isFirstRender = useRef(true);
|
||||
|
||||
const selectedProvider = (llmConfig.LLM || 'openai') as keyof typeof LLM_PROVIDERS;
|
||||
const selectedProviderMeta = LLM_PROVIDERS[selectedProvider];
|
||||
const currentModelField = useMemo(() => {
|
||||
switch (selectedProvider) {
|
||||
case 'openai':
|
||||
return 'OPENAI_MODEL';
|
||||
case 'google':
|
||||
return 'GOOGLE_MODEL';
|
||||
case 'anthropic':
|
||||
return 'ANTHROPIC_MODEL';
|
||||
case 'ollama':
|
||||
return 'OLLAMA_MODEL';
|
||||
case 'custom':
|
||||
return 'CUSTOM_MODEL';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}, [selectedProvider]);
|
||||
|
||||
const currentApiKeyField = useMemo(() => {
|
||||
switch (selectedProvider) {
|
||||
case 'openai':
|
||||
return 'OPENAI_API_KEY';
|
||||
case 'google':
|
||||
return 'GOOGLE_API_KEY';
|
||||
case 'anthropic':
|
||||
return 'ANTHROPIC_API_KEY';
|
||||
case 'custom':
|
||||
return 'CUSTOM_LLM_API_KEY';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}, [selectedProvider]);
|
||||
|
||||
const currentModel = currentModelField ? ((llmConfig as Record<string, unknown>)[currentModelField] as string || '') : '';
|
||||
const currentApiKey = currentApiKeyField ? ((llmConfig as Record<string, unknown>)[currentApiKeyField] as string || '') : '';
|
||||
const currentCustomUrl = llmConfig.CUSTOM_LLM_URL || '';
|
||||
const currentOllamaUrl = llmConfig.OLLAMA_URL || '';
|
||||
const useCustomOllamaUrl = !!llmConfig.USE_CUSTOM_URL;
|
||||
const modelLabel = selectedProviderMeta?.label || selectedProvider;
|
||||
|
||||
useEffect(() => {
|
||||
if (isFirstRender.current) {
|
||||
isFirstRender.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
setAvailableModels([]);
|
||||
setModelsChecked(false);
|
||||
if (currentModelField) {
|
||||
onInputChange('', currentModelField);
|
||||
}
|
||||
}, [selectedProvider, currentApiKey, currentCustomUrl, currentModelField]);
|
||||
|
||||
|
||||
|
||||
const onApiKeyChange = (llm: keyof typeof LLM_PROVIDERS, value: string) => {
|
||||
if (llm === 'ollama') {
|
||||
onInputChange(value, 'OLLAMA_URL');
|
||||
return;
|
||||
}
|
||||
|
||||
const keyField =
|
||||
llm === 'openai'
|
||||
? 'OPENAI_API_KEY'
|
||||
: llm === 'google'
|
||||
? 'GOOGLE_API_KEY'
|
||||
: llm === 'anthropic'
|
||||
? 'ANTHROPIC_API_KEY'
|
||||
: llm === 'custom'
|
||||
? 'CUSTOM_LLM_API_KEY'
|
||||
: '';
|
||||
if (keyField) {
|
||||
onInputChange(value, keyField);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchAvailableModels = async () => {
|
||||
if (selectedProvider === 'openai' && !currentApiKey) return;
|
||||
if (selectedProvider === 'google' && !currentApiKey) return;
|
||||
if (selectedProvider === 'anthropic' && !currentApiKey) return;
|
||||
if (selectedProvider === 'custom' && !currentCustomUrl) return;
|
||||
|
||||
setModelsLoading(true);
|
||||
try {
|
||||
let response: Response;
|
||||
if (selectedProvider === 'google') {
|
||||
response = await fetch(getApiUrl('/api/v1/ppt/google/models/available'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
api_key: currentApiKey
|
||||
}),
|
||||
});
|
||||
} else if (selectedProvider === 'anthropic') {
|
||||
response = await fetch(getApiUrl('/api/v1/ppt/anthropic/models/available'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
api_key: currentApiKey
|
||||
}),
|
||||
});
|
||||
} else if (selectedProvider === 'ollama') {
|
||||
response = await fetch(getApiUrl('/api/v1/ppt/ollama/models/supported'));
|
||||
} else {
|
||||
response = await fetch(getApiUrl('/api/v1/ppt/openai/models/available'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: selectedProvider === 'custom' ? currentCustomUrl : selectedProviderMeta?.url || '',
|
||||
api_key: currentApiKey
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const normalizedModels: string[] = selectedProvider === 'ollama'
|
||||
? Array.isArray(data)
|
||||
? data.map((model: { value?: string; label?: string }) => model.value || model.label || '').filter(Boolean)
|
||||
: []
|
||||
: Array.isArray(data)
|
||||
? data
|
||||
: [];
|
||||
|
||||
setAvailableModels(normalizedModels);
|
||||
setModelsChecked(true);
|
||||
|
||||
if (normalizedModels.length > 0 && currentModelField) {
|
||||
if (currentModel && normalizedModels.includes(currentModel)) {
|
||||
onInputChange(currentModel, currentModelField);
|
||||
return;
|
||||
}
|
||||
|
||||
const preferredDefault =
|
||||
selectedProvider === 'openai'
|
||||
? 'gpt-4.1'
|
||||
: selectedProvider === 'google'
|
||||
? 'models/gemini-2.5-flash'
|
||||
: selectedProvider === 'anthropic'
|
||||
? 'claude-sonnet-4-20250514'
|
||||
: normalizedModels[0];
|
||||
|
||||
const nextModel = normalizedModels.includes(preferredDefault) ? preferredDefault : normalizedModels[0];
|
||||
onInputChange(nextModel, currentModelField);
|
||||
}
|
||||
} else {
|
||||
console.error('Failed to fetch models');
|
||||
setAvailableModels([]);
|
||||
setModelsChecked(true);
|
||||
toast.error(`Failed to fetch ${modelLabel} models`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching models:', error);
|
||||
toast.error('Error fetching models');
|
||||
setAvailableModels([]);
|
||||
setModelsChecked(true);
|
||||
} finally {
|
||||
setModelsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedProvider === 'ollama' && !modelsChecked && !modelsLoading) {
|
||||
fetchAvailableModels();
|
||||
}
|
||||
}, [selectedProvider, modelsChecked, modelsLoading]);
|
||||
return (
|
||||
<div className="space-y-6 bg-[#F9F8F8] p-7 rounded-[12px] ">
|
||||
{/* API Key Input */}
|
||||
<div className="mb-4 flex items-center justify-between rounded-[12px] bg-white pt-5 pb-10 px-10">
|
||||
<div className=" max-w-[290px] pb-[50px]">
|
||||
<div className='w-[60px] h-[60px] rounded-[4px] flex items-center justify-center'
|
||||
style={{ backgroundColor: '#4C55541A' }}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32" fill="none">
|
||||
<path d="M15.9459 5.31543V26.5767" stroke="#4C5554" strokeWidth="1.59459" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M5.31531 9.30192V6.64426C5.31531 6.29183 5.45531 5.95384 5.70451 5.70463C5.95372 5.45543 6.29171 5.31543 6.64414 5.31543H25.2477C25.6002 5.31543 25.9382 5.45543 26.1874 5.70463C26.4366 5.95384 26.5766 6.29183 26.5766 6.64426V9.30192" stroke="#4C5554" strokeWidth="1.59459" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M11.9594 26.5762H19.9324" stroke="#4C5554" strokeWidth="1.59459" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 className="text-xl font-normal text-[#191919] py-2.5">Text Generation Settings</h3>
|
||||
<p className=" text-sm text-gray-500">
|
||||
Choosing where text contets come from
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
{selectedProvider === 'codex' && false && <div className='border border-[#EDEEEF] mb-4 rounded-[8px] p-5 flex justify-between items-center'>
|
||||
<div className='flex items-center gap-2.5'>
|
||||
<User className='w-4 h-4 text-gray-500' />
|
||||
<div>
|
||||
<h4 className='text-[#19001F] text-sm font-medium'>Acc: 123-455-acghk</h4>
|
||||
<p className='text-xs text-[#B3B3B3]'>Signed in to ChatGPT</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div className='flex items-center gap-2.5'>
|
||||
<ToolTip content='Refresh ChatGPT account'>
|
||||
|
||||
|
||||
<button className='px-3.5 py-2.5 rounded-full bg-[#EDEEEF]'>
|
||||
|
||||
<RefreshCw className='w-4 h-4 text-black' />
|
||||
</button>
|
||||
</ToolTip>
|
||||
<ToolTip content='Logout from ChatGPT'>
|
||||
<button className='px-3.5 py-2.5 rounded-full bg-[#EDEEEF]'>
|
||||
|
||||
<LogOut className='w-4 h-4 text-black' />
|
||||
</button>
|
||||
</ToolTip>
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
<div className={`flex gap-4 justify-end ${selectedProvider === 'codex' ? 'items-end' : 'items-start'}`}>
|
||||
<div className="relative w-[205px] ">
|
||||
<div className="flex flex-col justify-start ">
|
||||
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Select Text Provider
|
||||
</label>
|
||||
<Popover
|
||||
open={openProviderSelect}
|
||||
onOpenChange={setOpenProviderSelect}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openProviderSelect}
|
||||
className="w-[205px] h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between"
|
||||
>
|
||||
<div className="flex gap-3 items-center">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{llmConfig.LLM
|
||||
? LLM_PROVIDERS[llmConfig.LLM]
|
||||
?.label || llmConfig.LLM
|
||||
: "Select text provider"}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronUp className="w-4 h-4 text-gray-500" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
align="start"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search provider..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No provider found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{Object.values(LLM_PROVIDERS).map(
|
||||
(provider, index) => (
|
||||
<CommandItem
|
||||
key={index}
|
||||
value={provider.value}
|
||||
onSelect={(value) => {
|
||||
onInputChange(value, "LLM");
|
||||
setOpenProviderSelect(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
llmConfig.LLM === provider.value
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="flex flex-col space-y-1 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium text-gray-900 capitalize">
|
||||
{provider.label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-600 leading-relaxed">
|
||||
{provider.description}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
)
|
||||
)}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<div className="relative flex flex-col justify-end items-end w-[205px] ">
|
||||
<div className="flex flex-col justify-start w-full ">
|
||||
{selectedProvider === 'ollama' ? (
|
||||
<>
|
||||
{!useCustomOllamaUrl ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onInputChange(true, 'USE_CUSTOM_URL');
|
||||
if (!currentOllamaUrl) {
|
||||
onInputChange('http://localhost:11434', 'OLLAMA_URL');
|
||||
}
|
||||
}}
|
||||
className="mt-8 py-2.5 bg-[#EDEEEF] px-3.5 w-fit rounded-[48px] text-xs font-semibold text-[#101323] transition-all duration-200 border border-[#EDEEEF] hover:bg-[#E8F0FF]/90 focus:ring-2 focus:ring-blue-500/20"
|
||||
>
|
||||
Use Ollama URL
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<label className="block text-sm font-medium capitalize text-gray-700 mb-2">
|
||||
Ollama URL
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={currentOllamaUrl}
|
||||
onChange={(e) => onApiKeyChange(selectedProvider, e.target.value)}
|
||||
className="w-full px-2 py-3 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
|
||||
placeholder="http://localhost:11434"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onInputChange(false, 'USE_CUSTOM_URL');
|
||||
onInputChange('http://localhost:11434', 'OLLAMA_URL');
|
||||
}}
|
||||
className="mt-2 text-xs font-medium text-[#4B5563] underline underline-offset-2"
|
||||
>
|
||||
Use default Ollama URL
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : selectedProvider === 'codex' ?
|
||||
<>
|
||||
<button className='px-3.5 py-2.5 bg-[#EDEEEF] mt-auto rounded-[58px] w-full text-xs font-medium text-[#101323]'>Sign in with ChatGPT</button>
|
||||
</>
|
||||
: (
|
||||
<>
|
||||
<label className="block text-sm font-medium capitalize text-gray-700 mb-2">
|
||||
{selectedProvider === 'custom' ? 'Custom LLM API Key' : `${llmConfig.LLM} API Key`}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
value={currentApiKey}
|
||||
onChange={(e) => onApiKeyChange(selectedProvider, e.target.value)}
|
||||
className="w-full px-2 py-3 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
|
||||
placeholder={`Enter your ${llmConfig.LLM} API key`}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiKey((prev) => !prev)}
|
||||
className='absolute right-2 top-1/2 -translate-y-1/2 bg-white px-2 py-1 cursor-pointer'
|
||||
>
|
||||
{showApiKey ? <Eye className='w-4 h-4 text-gray-500' /> : <EyeOff className='w-4 h-4 text-gray-500' />}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{selectedProvider === 'custom' && (
|
||||
<input
|
||||
type="text"
|
||||
value={currentCustomUrl}
|
||||
onChange={(e) => onInputChange(e.target.value, 'CUSTOM_LLM_URL')}
|
||||
className="w-full mt-2 px-2 py-3 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
|
||||
placeholder="OpenAI-compatible URL"
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
{selectedProvider !== 'ollama' && selectedProvider !== 'codex' && (!modelsChecked || (modelsChecked && availableModels.length === 0)) && (
|
||||
|
||||
<button
|
||||
onClick={fetchAvailableModels}
|
||||
disabled={
|
||||
modelsLoading ||
|
||||
(selectedProvider === 'openai' && !currentApiKey) ||
|
||||
(selectedProvider === 'google' && !currentApiKey) ||
|
||||
(selectedProvider === 'anthropic' && !currentApiKey) ||
|
||||
(selectedProvider === 'custom' && !currentCustomUrl)
|
||||
}
|
||||
className={`mt-4 py-2.5 bg-[#EDEEEF] px-3.5 w-fit rounded-[48px] text-xs font-semibold text-[#101323] transition-all duration-200 border ${modelsLoading
|
||||
? " border-gray-300 cursor-not-allowed text-gray-500"
|
||||
: " border-[#EDEEEF] text-[#101323] hover:bg-[#E8F0FF]/90 focus:ring-2 focus:ring-blue-500/20"
|
||||
}`}
|
||||
>
|
||||
{modelsLoading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Checking for models...
|
||||
</span>
|
||||
) : (
|
||||
"Check models"
|
||||
)}
|
||||
</button>
|
||||
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Model Selection - only show if models are available */}
|
||||
{modelsChecked && availableModels.length > 0 ? (
|
||||
<div className="w-[205px]">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
{selectedProvider === 'ollama' ? 'Choose a supported model' : `Select ${modelLabel} Model`}
|
||||
</label>
|
||||
<div className="w-full">
|
||||
<Popover
|
||||
open={openModelSelect}
|
||||
onOpenChange={setOpenModelSelect}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openModelSelect}
|
||||
className="w-full h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between"
|
||||
>
|
||||
<span className="text-sm truncate font-medium text-gray-900">
|
||||
{currentModel
|
||||
? availableModels.find(model => model === currentModel) || currentModel
|
||||
: "Select a model"}
|
||||
</span>
|
||||
|
||||
<ChevronUp className="w-4 h-4 text-gray-500" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
align="start"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search models..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No model found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableModels.map((model, index) => (
|
||||
<CommandItem
|
||||
key={index}
|
||||
value={model}
|
||||
onSelect={(value) => {
|
||||
if (currentModelField) {
|
||||
onInputChange(value, currentModelField);
|
||||
}
|
||||
setOpenModelSelect(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
currentModel === model
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="flex flex-col space-y-1 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{model}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Show message if no models found */}
|
||||
{modelsChecked && availableModels.length === 0 && (
|
||||
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<p className="text-sm text-yellow-800">
|
||||
No models found. Please make sure your provider credentials are valid and the selected provider is reachable.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Web Grounding Toggle - show at the end, below models dropdown */}
|
||||
<div className="bg-white flex justify-between items-center p-10 rounded-[12px]">
|
||||
<div className=' max-w-[290px]'>
|
||||
|
||||
<h4 className="text-xl font-normal text-[#191919]">Advanced</h4>
|
||||
<p className="mt-2.5 text-sm text-gray-500">
|
||||
Configure advanced AI features.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
|
||||
<div className="w-[205px]">
|
||||
<div className="flex items-center mb-4 gap-2.5 ">
|
||||
<Switch
|
||||
checked={!!llmConfig.WEB_GROUNDING}
|
||||
onCheckedChange={(checked) => onInputChange(checked, "WEB_GROUNDING")}
|
||||
/>
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
Enable Web Grounding
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
{/* <div className="w-[295px]"></div> */}
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TextProvider
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import { Card } from "@/components/ui/card";
|
||||
|
||||
export default function LoadingProfile() {
|
||||
return (
|
||||
<div className="h-screen bg-gradient-to-b font-instrument_sans from-gray-50 to-white flex flex-col overflow-hidden">
|
||||
{/* Header Skeleton */}
|
||||
<div className="flex-shrink-0 bg-white border-b border-gray-200 p-4">
|
||||
<div className="container mx-auto max-w-3xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="h-8 w-32 bg-gray-200 animate-pulse rounded-md" />
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-8 w-8 bg-gray-200 animate-pulse rounded-full" />
|
||||
<div className="h-8 w-24 bg-gray-200 animate-pulse rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content Skeleton */}
|
||||
<main className="flex-1 container mx-auto px-4 max-w-3xl overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{/* LLM Selection Content Skeleton */}
|
||||
<div className="space-y-6 p-6">
|
||||
{/* Page Title */}
|
||||
<div className="space-y-2">
|
||||
<div className="h-8 w-48 bg-gray-200 animate-pulse rounded-md" />
|
||||
<div className="h-5 w-72 bg-gray-200 animate-pulse rounded-md" />
|
||||
</div>
|
||||
|
||||
{/* LLM Provider Cards */}
|
||||
<div className="space-y-4">
|
||||
{[...Array(3)].map((_, index) => (
|
||||
<Card key={index} className="p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 bg-gray-200 animate-pulse rounded-md" />
|
||||
<div className="space-y-1">
|
||||
<div className="h-5 w-32 bg-gray-200 animate-pulse rounded-md" />
|
||||
<div className="h-4 w-48 bg-gray-200 animate-pulse rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-6 w-6 bg-gray-200 animate-pulse rounded-full" />
|
||||
</div>
|
||||
|
||||
{/* Configuration Fields */}
|
||||
<div className="space-y-4">
|
||||
{[...Array(2)].map((_, fieldIndex) => (
|
||||
<div key={fieldIndex} className="space-y-2">
|
||||
<div className="h-4 w-24 bg-gray-200 animate-pulse rounded-md" />
|
||||
<div className="h-10 w-full bg-gray-200 animate-pulse rounded-md" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Model Selection */}
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="h-5 w-32 bg-gray-200 animate-pulse rounded-md" />
|
||||
<div className="h-10 w-full bg-gray-200 animate-pulse rounded-md" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Fixed Bottom Button Skeleton */}
|
||||
<div className="flex-shrink-0 bg-white border-t border-gray-200 p-4">
|
||||
<div className="container mx-auto max-w-3xl">
|
||||
<div className="h-12 w-full bg-gray-200 animate-pulse rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import { Plus, Sparkles } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React from 'react'
|
||||
|
||||
const CreateCustomTemplate = () => {
|
||||
const router = useRouter();
|
||||
return (
|
||||
<div
|
||||
onClick={() => {
|
||||
router.push('/custom-template')
|
||||
}}
|
||||
className='w-full rounded-xl border border-[#EDEEEF] cursor-pointer font-syne'>
|
||||
<div className='relative h-[215px] flex justify-center items-center '>
|
||||
<img src="/card_bg.svg" alt="" className="absolute top-0 z-[1] left-0 w-full h-full object-cover" />
|
||||
<div className='w-[36px] h-[36px] relative z-[4] rounded-full bg-[#7A5AF8] flex items-center justify-center'
|
||||
style={{
|
||||
background: 'linear-gradient(0deg, rgba(0, 0, 0, 0.20) 0%, rgba(0, 0, 0, 0.20) 100%), #FFF'
|
||||
}}
|
||||
><div className='w-[26px] h-[26px] rounded-full bg-white flex items-center justify-center'>
|
||||
|
||||
<Plus className='w-4 h-4 text-[#A2A0A1]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='px-5 py-4 bg-white flex items-center gap-4 border-t border-[#EDEEEF]'>
|
||||
<div className='bg-[#7A5AF8] w-[45px] h-[45px] rounded-lg p-2 flex items-center justify-center'>
|
||||
|
||||
<Sparkles className='w-6 h-6 text-white' />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className='text-[#191919] text-sm font-semibold '>Build Template</h4>
|
||||
<p className='flex text-[#808080] text-sm font-medium items-center gap-2'>Build Your Own Template</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreateCustomTemplate
|
||||
|
|
@ -0,0 +1,278 @@
|
|||
"use client";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { ArrowUpRight, ChevronRight, ExternalLink, Loader2, Plus } from "lucide-react";
|
||||
import { templates } from "@/app/presentation-templates";
|
||||
import { TemplateWithData, TemplateLayoutsWithSettings } from "@/app/presentation-templates/utils";
|
||||
import {
|
||||
useCustomTemplateSummaries,
|
||||
useCustomTemplatePreview,
|
||||
CustomTemplates,
|
||||
} from "@/app/hooks/useCustomTemplates";
|
||||
import { CompiledLayout } from "@/app/hooks/compileLayout";
|
||||
import CreateCustomTemplate from "./CreateCustomTemplate";
|
||||
import Link from "next/link";
|
||||
|
||||
// Component for rendering custom template card with lazy-loaded previews
|
||||
export const CustomTemplateCard = React.memo(function CustomTemplateCard({ template }: { template: CustomTemplates }) {
|
||||
const router = useRouter();
|
||||
const { previewLayouts, loading, totalLayouts } = useCustomTemplatePreview(`${template.id}`);
|
||||
const handleOpen = useCallback(() => {
|
||||
if (template.id.startsWith('custom-')) {
|
||||
router.push(`/template-preview/${template.id}`)
|
||||
} else {
|
||||
router.push(`/template-preview/custom-${template.id}`)
|
||||
}
|
||||
}
|
||||
, [router, template.id]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="cursor-pointer flex flex-col justify-between shadow-none sm:shadow-none relative hover:shadow-lg transition-all duration-200 group overflow-hidden"
|
||||
onClick={handleOpen}
|
||||
>
|
||||
|
||||
<img src="/card_bg.svg" alt="" className="absolute top-0 left-0 w-full h-full object-cover" />
|
||||
<span className="text-xs font-syne absolute top-2 flex gap-1 capitalize items-center left-2 rounded-[100px] px-2.5 py-1 bg-[#3A3A3AF5] text-white font-semibold z-40">
|
||||
Layouts- {totalLayouts}
|
||||
</span>
|
||||
<div className="p-5">
|
||||
|
||||
{/* Layout previews */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{loading ? (
|
||||
// Loading placeholders
|
||||
[...Array(Math.min(4, template.layoutCount))].map((_, index) => (
|
||||
<div
|
||||
key={`${template.id}-loading-${index}`}
|
||||
className="relative bg-gradient-to-br from-purple-50 to-blue-50 border border-gray-200 overflow-hidden aspect-video rounded flex items-center justify-center"
|
||||
>
|
||||
<Loader2 className="w-4 h-4 text-purple-300 animate-spin" />
|
||||
</div>
|
||||
))
|
||||
) : previewLayouts.length > 0 && (
|
||||
// Actual layout previews
|
||||
previewLayouts.slice(0, 4).map((layout: CompiledLayout, index: number) => {
|
||||
const LayoutComponent = layout.component;
|
||||
return (
|
||||
<div
|
||||
key={`${template.id}-preview-${index}`}
|
||||
className="relative bg-gray-100 border border-gray-200 overflow-hidden aspect-video rounded"
|
||||
>
|
||||
<div className="absolute inset-0 bg-transparent z-10" />
|
||||
<div
|
||||
className="transform scale-[0.12] origin-top-left"
|
||||
style={{ width: "833.33%", height: "833.33%" }}
|
||||
>
|
||||
<LayoutComponent data={layout.sampleData} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-5 bg-white border-t border-[#EDEEEF] relative z-40 ">
|
||||
<h3 className="text-sm font-bold w-[191px] text-gray-900">
|
||||
{template.name}
|
||||
</h3>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
<ArrowUpRight className="w-4 h-4 text-gray-400 group-hover:text-purple-600 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}, (prev, next) => {
|
||||
// Custom templates may be refetched, producing new object references; compare on fields we render/use.
|
||||
return (
|
||||
prev.template.id === next.template.id &&
|
||||
prev.template.id === next.template.id &&
|
||||
prev.template.name === next.template.name &&
|
||||
prev.template.layoutCount === next.template.layoutCount
|
||||
);
|
||||
});
|
||||
|
||||
const InbuiltTemplateCard = React.memo(function InbuiltTemplateCard({
|
||||
template,
|
||||
onOpen,
|
||||
}: {
|
||||
template: TemplateLayoutsWithSettings;
|
||||
onOpen: (id: string) => void;
|
||||
}) {
|
||||
const previewLayouts = useMemo(() => template.layouts.slice(0, 4), [template.layouts]);
|
||||
const handleOpen = useCallback(() => onOpen(template.id), [onOpen, template.id]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={template.id}
|
||||
className="cursor-pointer relative sm:shadow-none shadow-none hover:shadow-lg transition-all duration-200 group overflow-hidden"
|
||||
onClick={handleOpen}
|
||||
>
|
||||
<span className="text-xs font-syne absolute top-2 flex gap-1 capitalize items-center left-2 rounded-[100px] px-2.5 py-1 bg-[#3A3A3AF5] text-white font-semibold z-40">
|
||||
Layouts- {template.layouts.length}
|
||||
</span>
|
||||
<img src="/card_bg.svg" alt="" className="absolute top-0 left-0 w-full h-full object-cover" />
|
||||
<div className="p-5">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{previewLayouts.map((layout: TemplateWithData, index: number) => {
|
||||
const LayoutComponent = layout.component;
|
||||
return (
|
||||
<div
|
||||
key={`${template.id}-preview-${index}`}
|
||||
className="relative bg-gray-100 border border-gray-200 overflow-hidden aspect-video rounded"
|
||||
>
|
||||
<div className="absolute inset-0 bg-transparent z-10" />
|
||||
<div
|
||||
className="transform scale-[0.12] origin-top-left"
|
||||
style={{ width: "833.33%", height: "833.33%" }}
|
||||
>
|
||||
<LayoutComponent data={layout.sampleData} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-5 bg-white border-t border-[#EDEEEF] relative z-40 ">
|
||||
<div className="w-[191px]">
|
||||
|
||||
<h3 className="text-sm font-bold text-gray-900 capitalize">
|
||||
{template.name}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-600 mb-4 line-clamp-2">
|
||||
{template.description}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
<ArrowUpRight className="w-4 h-4 text-gray-400 group-hover:text-blue-600 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
const LayoutPreview = () => {
|
||||
const [tab, setTab] = useState<'custom' | 'default'>('default');
|
||||
const router = useRouter();
|
||||
const { templates: customTemplates, loading: customLoading } = useCustomTemplateSummaries();
|
||||
|
||||
useEffect(() => {
|
||||
const existingScript = document.querySelector('script[src*="tailwindcss.com"]');
|
||||
if (!existingScript) {
|
||||
const script = document.createElement("script");
|
||||
script.src = "https://cdn.tailwindcss.com";
|
||||
script.async = true;
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleOpenPreview = useCallback((id: string) => router.push(`/template-preview/${id}`), [router]);
|
||||
|
||||
|
||||
|
||||
|
||||
const inbuiltTemplateCards = useMemo(
|
||||
() =>
|
||||
templates.map((template: TemplateLayoutsWithSettings) => (
|
||||
<InbuiltTemplateCard key={template.id} template={template} onOpen={handleOpenPreview} />
|
||||
)),
|
||||
[handleOpenPreview],
|
||||
);
|
||||
|
||||
const customTemplateCards = useMemo(
|
||||
() => customTemplates.map((template: CustomTemplates) => <CustomTemplateCard key={template.id} template={template} />),
|
||||
[customTemplates],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen relative font-syne">
|
||||
<div
|
||||
className='fixed z-0 bottom-[-16.5rem] left-0 w-full h-full'
|
||||
style={{
|
||||
height: "341px",
|
||||
borderRadius: '1440px',
|
||||
background: 'radial-gradient(5.92% 104.69% at 50% 100%, rgba(122, 90, 248, 0.00) 0%, rgba(255, 255, 255, 0.00) 100%), radial-gradient(50% 50% at 50% 50%, rgba(122, 90, 248, 0.80) 0%, rgba(122, 90, 248, 0.00) 100%)',
|
||||
}}
|
||||
/>
|
||||
<div className="sticky top-0 right-0 z-50 py-[28px] px-6 backdrop-blur ">
|
||||
<div className="flex xl:flex-row flex-col gap-6 xl:gap-0 items-center justify-between">
|
||||
<h3 className=" text-[28px] tracking-[-0.84px] font-unbounded font-normal text-[#101828] flex items-center gap-2">
|
||||
Templates
|
||||
</h3>
|
||||
<div className="flex gap-2.5 max-sm:w-full max-md:justify-center max-sm:flex-wrap">
|
||||
|
||||
|
||||
|
||||
|
||||
<Link
|
||||
href="/custom-template"
|
||||
className="inline-flex items-center font-syne font-semibold gap-2 rounded-xl px-4 py-2.5 text-black text-sm shadow-sm hover:shadow-md"
|
||||
aria-label="Create new template"
|
||||
style={{
|
||||
borderRadius: "48px",
|
||||
background: "linear-gradient(270deg, #D5CAFC 2.4%, #E3D2EB 27.88%, #F4DCD3 69.23%, #FDE4C2 100%)",
|
||||
}}
|
||||
>
|
||||
<span className="hidden md:inline">New Template</span>
|
||||
<span className="md:hidden">New</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Link>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="l mx-auto px-6 py-8">
|
||||
<div className='p-1 rounded-[40px] bg-[#ffffff] w-fit border border-[#EDEEEF] flex items-center justify-center '>
|
||||
<button className='px-5 py-2 text-xs font-medium text-[#3A3A3A] rounded-[70px]'
|
||||
onClick={() => setTab('custom')}
|
||||
style={{
|
||||
background: tab === 'custom' ? '#F4F3FF' : 'transparent',
|
||||
color: tab === 'custom' ? '#5146E5' : '#3A3A3A'
|
||||
}}
|
||||
>Custom</button>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className='mx-1' width="2" height="17" viewBox="0 0 2 17" fill="none">
|
||||
<path d="M1 0V16.5" stroke="#EDECEC" strokeWidth="2" />
|
||||
</svg>
|
||||
<button className='px-5 py-2 text-xs font-medium text-[#3A3A3A] rounded-[70px]'
|
||||
onClick={() => setTab('default')}
|
||||
style={{
|
||||
background: tab === 'default' ? '#F4F3FF' : 'transparent',
|
||||
color: tab === 'default' ? '#5146E5' : '#3A3A3A'
|
||||
}}
|
||||
>Built-in</button>
|
||||
</div>
|
||||
|
||||
{/* Inbuilt Templates Section */}
|
||||
{tab === 'default' && <section className="my-12">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{inbuiltTemplateCards}
|
||||
</div>
|
||||
</section>}
|
||||
|
||||
|
||||
{tab === 'custom' && <section className="my-12">
|
||||
{customLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
|
||||
<span className="ml-3 text-gray-600">Loading custom templates...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<CreateCustomTemplate />
|
||||
{customTemplateCards}
|
||||
</div>
|
||||
)}
|
||||
</section>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LayoutPreview;
|
||||
|
|
@ -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
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
"use client";
|
||||
import React from 'react'
|
||||
|
||||
import { HexColorPicker, HexColorInput } from 'react-colorful'
|
||||
import { ThemeColors } from './types'
|
||||
|
||||
interface ColorPickerComponentProps {
|
||||
colorKey: keyof ThemeColors
|
||||
currentColor: string
|
||||
onColorChange: (colorKey: keyof ThemeColors, value: string) => void
|
||||
showColorPicker: string | null
|
||||
onShowColorPicker: (colorKey: string | null) => void,
|
||||
label: string
|
||||
}
|
||||
|
||||
export const ColorPickerComponent: React.FC<ColorPickerComponentProps> = ({
|
||||
colorKey,
|
||||
currentColor,
|
||||
onColorChange,
|
||||
showColorPicker,
|
||||
onShowColorPicker,
|
||||
label
|
||||
}) => (
|
||||
<div className="">
|
||||
{label && <p className="text-xs text-[#38393D] font-medium pb-1.5">
|
||||
{label}
|
||||
</p>}
|
||||
<div className="flex gap-2 border border-[#EDEEEF] rounded-md p-1">
|
||||
<div
|
||||
className="w-8 h-8 rounded border border-gray-300 cursor-pointer relative"
|
||||
style={{ backgroundColor: currentColor }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onShowColorPicker(showColorPicker === colorKey ? null : colorKey)
|
||||
}}
|
||||
>
|
||||
{showColorPicker === colorKey && (
|
||||
<div
|
||||
className="absolute top-full left-0 z-[9999] mt-2 bg-white border border-gray-300 rounded-lg shadow-lg p-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<HexColorPicker
|
||||
color={currentColor}
|
||||
onChange={(color) => onColorChange(colorKey, color)}
|
||||
/>
|
||||
<div className="mt-2">
|
||||
<HexColorInput
|
||||
color={currentColor}
|
||||
onChange={(color) => onColorChange(colorKey, color)}
|
||||
className="w-full px-2 py-1 text-sm border border-gray-300 rounded outline-none "
|
||||
prefixed
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
className='w-full outline-none text-sm font-medium text-[#38393D]'
|
||||
value={currentColor}
|
||||
onChange={(e) => onColorChange(colorKey, e.target.value)}
|
||||
placeholder="#000000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
"use client";
|
||||
import { ArrowRight, Plus, Sparkle, Sparkles } from 'lucide-react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import React from 'react'
|
||||
|
||||
const CustomTabEmpty = () => {
|
||||
const router = useRouter()
|
||||
return (
|
||||
<div
|
||||
onClick={() => {
|
||||
router.push('/theme?tab=new-theme')
|
||||
}}
|
||||
className='w-[305px] rounded-xl border border-[#EDEEEF] cursor-pointer'>
|
||||
<div className='relative h-[250px] flex justify-center items-center '>
|
||||
<img src="/card_bg.svg" alt="" className="absolute top-0 z-[1] left-0 w-full h-full object-cover" />
|
||||
<div className='w-[36px] h-[36px] relative z-[4] rounded-full bg-[#7A5AF8] flex items-center justify-center'
|
||||
style={{
|
||||
background: 'linear-gradient(90deg, #F00 5.21%, #FF8A00 16.48%, #FFE600 27.74%, #14FF00 39.35%, #00A3FF 49.37%, #0500FF 61.18%, #AD00FF 72.26%, #FF00C7 83.53%, #F00 94.61%), #FFF'
|
||||
}}
|
||||
><div className='w-[26px] h-[26px] rounded-full bg-white flex items-center justify-center'>
|
||||
|
||||
<Plus className='w-4 h-4 text-[#A2A0A1]' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='px-5 py-4 bg-white flex items-center gap-4 border-t border-[#EDEEEF]'>
|
||||
<div className='bg-[#7A5AF8] w-[45px] h-[45px] rounded-lg p-2 flex items-center justify-center'>
|
||||
|
||||
<Sparkles className='w-6 h-6 text-white' />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className='text-[#191919] text-sm font-semibold '>Build Theme</h4>
|
||||
<p className='flex text-[#808080] text-sm font-medium items-center gap-2'>From colors <ArrowRight className='w-3 h-3' /> fonts </p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomTabEmpty
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
"use client";
|
||||
import React from 'react'
|
||||
import { Check } from 'lucide-react'
|
||||
|
||||
interface FontCardProps {
|
||||
font: any
|
||||
isSelected: boolean
|
||||
onSelect: (fontName: string) => void
|
||||
}
|
||||
|
||||
export const FontCard: React.FC<FontCardProps> = ({ font, isSelected, onSelect }) => (
|
||||
<div
|
||||
className={`relative p-3 rounded-xl cursor-pointer transition-all duration-200 group
|
||||
${isSelected
|
||||
? 'bg-gradient-to-br from-[#F8F7FF] to-[#F0EFFF] border border-[#7A5AF8] shadow-sm'
|
||||
: 'bg-white border border-[#EDEEEF] hover:border-[#C4B5FD] hover:bg-[#FAFAFF]'
|
||||
}`}
|
||||
onClick={() => onSelect(font.name)}
|
||||
>
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className={`text-sm font-medium truncate ${isSelected ? 'text-[#7A5AF8]' : 'text-[#151515]'}`}
|
||||
style={{ fontFamily: `"${font.name}"` }}
|
||||
>
|
||||
{font.displayName}
|
||||
</p>
|
||||
<p
|
||||
className="text-[11px] text-[#A6A4A2] mt-0.5"
|
||||
style={{ fontFamily: `"${font.name}"` }}
|
||||
>
|
||||
ABC abc 123
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={`text-xl font-semibold ${isSelected ? 'text-[#7A5AF8]' : 'text-[#333] group-hover:text-[#7A5AF8]'} transition-colors`}
|
||||
style={{ fontFamily: `"${font.name}"` }}
|
||||
>
|
||||
Aa
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import React from 'react'
|
||||
|
||||
interface StepIndicatorProps {
|
||||
currentStep: number
|
||||
}
|
||||
|
||||
const steps = [
|
||||
{ step: 1, label: 'Brand' },
|
||||
{ step: 2, label: 'Palette' },
|
||||
{ step: 3, label: 'Fonts' },
|
||||
{ step: 4, label: 'Logo' },
|
||||
]
|
||||
|
||||
export const StepIndicator: React.FC<StepIndicatorProps> = ({ currentStep }) => (
|
||||
<div className="flex flex-col items-center gap-7 px-4 min-w-[104px] pt-8 border-r border-[#EDEEEF]">
|
||||
{steps.map(({ step, label }) => {
|
||||
const isActive = currentStep === step
|
||||
return (
|
||||
<div key={step} className="flex flex-col items-center gap-1.5 px-3 ">
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded-full text-[9px] font-medium ${isActive
|
||||
? 'bg-[#7A5AF8] text-white'
|
||||
: 'bg-white text-[#404348] border border-[#EDEEEF]'
|
||||
}`}
|
||||
>
|
||||
Step-{step}
|
||||
</span>
|
||||
<span className="text-[11px] font-normal text-black">{label}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
"use client";
|
||||
import React, { useState } from 'react'
|
||||
import { AlertTriangle, Check, Copy, Trash } from 'lucide-react'
|
||||
import { Theme } from '@/app/(presentation-generator)/services/api/types'
|
||||
import ToolTip from '@/components/ToolTip'
|
||||
|
||||
interface ThemeCardProps {
|
||||
theme: Theme
|
||||
onSelect: (theme: Theme) => void
|
||||
onDelete: (themeId: string) => void
|
||||
showDeleteButton?: boolean
|
||||
}
|
||||
|
||||
export const ThemeCard: React.FC<ThemeCardProps> = ({ theme, onSelect, onDelete, showDeleteButton = true }) => {
|
||||
if (!theme.data.colors['graph_0']) { return null }
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
|
||||
|
||||
|
||||
return (<div
|
||||
className={` group rounded-xl border w-[305px] cursor-pointer transition-all relative bg-white border-[#EDEEEF] hover:shadow-md`}
|
||||
onClick={() => onSelect(theme)}
|
||||
|
||||
>
|
||||
{showDeleteButton && <button
|
||||
className="absolute hidden group-hover:block duration-300 transition-all -top-3 -right-3 z-10 bg-white rounded-full p-2 border border-[#EDEEEF] hover:bg-gray-100 hover:text-gray-700"
|
||||
style={{ boxShadow: '0 6.6px 13.2px 0 rgba(0, 0, 0, 0.10)' }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setShowDeleteDialog(true)
|
||||
}}
|
||||
>
|
||||
<Trash className="h-3 w-3" />
|
||||
</button>}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
{showDeleteDialog && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center animate-[fadeIn_150ms_ease-out]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setShowDeleteDialog(false)
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/40 backdrop-blur-[2px]" />
|
||||
<div
|
||||
className="relative bg-white rounded-2xl w-[340px] shadow-2xl animate-[scaleIn_200ms_ease-out] "
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-6 pb-4 flex flex-col items-center text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-red-50 flex items-center justify-center mb-4">
|
||||
<AlertTriangle className="h-6 w-6 text-red-500" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-[#191919] mb-2">Delete Theme?</h3>
|
||||
<p className="text-sm text-gray-500 leading-relaxed">
|
||||
You're about to delete <span className="font-medium text-gray-700">"{theme.name}"</span>. This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex border-t border-gray-100">
|
||||
<button
|
||||
onClick={() => setShowDeleteDialog(false)}
|
||||
className="flex-1 px-4 py-3.5 text-sm font-medium text-gray-600 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onDelete(theme.id)
|
||||
setShowDeleteDialog(false)
|
||||
}}
|
||||
className="flex-1 px-4 py-3.5 text-sm font-medium text-red-500 hover:bg-red-50 transition-colors border-l border-gray-100"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
<div className='relative h-[250px] flex justify-center items-center '>
|
||||
|
||||
<img src="/card_bg.svg" alt="" className="absolute top-0 z-[1] left-0 w-[99%] h-full object-cover" />
|
||||
<div className=" absolute top-0 left-0 flex items-center justify-between gap-2 z-[2] p-2">
|
||||
<ToolTip content='Font' >
|
||||
|
||||
<p className=" text-xs font-syne flex gap-1 capitalize items-center rounded-[100px] px-2.5 py-1 bg-[#3A3A3AF5] text-white font-semibold z-40 ">
|
||||
|
||||
{theme.data.fonts.textFont.name}
|
||||
</p>
|
||||
</ToolTip>
|
||||
{theme.company_name && <ToolTip content='COMPANY'>
|
||||
|
||||
<p className=" text-xs font-syne flex gap-1 capitalize items-center rounded-[100px] px-2.5 py-1 bg-[#3A3A3AF5] text-white font-semibold text-ellipsis overflow-hidden whitespace-nowrap z-40 ">
|
||||
|
||||
{theme.company_name}
|
||||
</p>
|
||||
</ToolTip>}
|
||||
{theme.logo_url && <ToolTip content='LOGO'>
|
||||
|
||||
<p className=" text-xs font-syne flex gap-1 capitalize items-center rounded-[100px] px-2.5 py-1 bg-[#3A3A3AF5] text-white font-semibold z-40 ">
|
||||
|
||||
<img src={theme.logo_url} alt={theme.name} className="w-full max-w-6 h-4 rounded-full object-cover" />
|
||||
</p>
|
||||
</ToolTip>}
|
||||
|
||||
|
||||
|
||||
</div>
|
||||
<div className=" relative z-[3] px-6">
|
||||
|
||||
<div className="w-full h-[135px]">
|
||||
<div
|
||||
className=" w-full h-full rounded-xl p-3 border border-black/10 "
|
||||
style={{ backgroundColor: theme.data.colors['background'] }}
|
||||
>
|
||||
<div
|
||||
className="h-[calc(100%-2px)] w-[calc(100%-2px)] mx-auto my-auto rounded-xl p-4 border border-black/10 shadow-[0_2px_6px_rgba(0,0,0,0.10)]"
|
||||
style={{ backgroundColor: theme.data.colors['card'] }}
|
||||
>
|
||||
<div className="h-full w-full flex flex-col justify-center">
|
||||
<div
|
||||
className="text-[22px] font-semibold leading-[1.05] text-left truncate"
|
||||
style={{ color: theme.data.colors['background_text'], fontFamily: `"${theme.data.fonts.textFont.name}", ui-serif, Georgia, serif` }}
|
||||
>
|
||||
{theme.name}
|
||||
</div>
|
||||
<div
|
||||
className="mt-1 text-base font-medium leading-[1.1] text-left truncate"
|
||||
style={{ color: theme.data.colors['background_text'], fontFamily: `"${theme.data.fonts.textFont.name}", ui-serif, Georgia, serif` }}
|
||||
>
|
||||
Choose your preferences.
|
||||
</div>
|
||||
<div
|
||||
className="mt-2 h-2.5 w-16 rounded-full"
|
||||
style={{ backgroundColor: theme.data.colors['primary'] }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className='px-5 border-t rounded-b-xl border-[#EDEEEF] w-full py-2.5 h-[80px] bg-white flex items-center justify-between'>
|
||||
<div>
|
||||
|
||||
<h4 className='text-sm font-semibold text-[#191919] pb-1'>{theme.name}</h4>
|
||||
<div className='flex items-center gap-1'>
|
||||
|
||||
<div className='w-4 h-4 rounded-full border border-[#EDEEEF] '
|
||||
style={{ backgroundColor: theme.data.colors['primary'] }}
|
||||
/>
|
||||
<div
|
||||
className='w-4 h-4 rounded-full border border-[#EDEEEF] '
|
||||
style={{ backgroundColor: theme.data.colors['background'] }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard.writeText(theme.id)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}}
|
||||
className={copied ? "text-green-500" : "text-gray-500 hover:text-gray-700"}
|
||||
title={copied ? "Copied!" : "Copy ID"}
|
||||
>
|
||||
{copied ? <Check className="h-5 w-5" /> : <Copy className="h-5 w-5" />}
|
||||
</button>
|
||||
|
||||
</div>
|
||||
</div>)
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,205 @@
|
|||
|
||||
|
||||
export const FONT_OPTIONS: any[] = [
|
||||
{ name: 'Inter', displayName: 'Inter', cssUrl: 'https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap' },
|
||||
{ name: 'DM Sans', displayName: 'DM Sans', cssUrl: 'https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;700&display=swap' },
|
||||
{ name: 'Overpass', displayName: 'Overpass', cssUrl: 'https://fonts.googleapis.com/css2?family=Overpass:wght@100..900&display=swap' },
|
||||
{ name: 'Barlow', displayName: 'Barlow', cssUrl: 'https://fonts.googleapis.com/css2?family=Barlow:wght@100..900&display=swap' },
|
||||
{ name: 'Nunito', displayName: 'Nunito', cssUrl: 'https://fonts.googleapis.com/css2?family=Nunito:wght@200..1000&display=swap' },
|
||||
{ name: 'Lora', displayName: 'Lora', cssUrl: 'https://fonts.googleapis.com/css2?family=Lora:wght@400;500;600;700&display=swap' },
|
||||
{ name: 'Instrument Sans', displayName: 'Instrument Sans', cssUrl: 'https://fonts.googleapis.com/css2?family=Instrument+Sans:ital,wght@0,400..700;1,400..700&display=swap' },
|
||||
{ name: 'Roboto Slab', displayName: 'Roboto Slab', cssUrl: 'https://fonts.googleapis.com/css2?family=Roboto+Slab:wght@100..900&display=swap' },
|
||||
{ name: 'Montserrat', displayName: 'Montserrat', cssUrl: 'https://fonts.googleapis.com/css2?family=Montserrat:wght@100..900&display=swap' },
|
||||
{ name: 'Libre Baskerville', displayName: 'Libre Baskerville', cssUrl: 'https://fonts.googleapis.com/css2?family=Libre+Baskerville:wght@400;700&display=swap' },
|
||||
{ name: 'Prompt', displayName: 'Prompt', cssUrl: 'https://fonts.googleapis.com/css2?family=Prompt:wght@100..900&display=swap' },
|
||||
{ name: 'Inconsolata', displayName: 'Inconsolata', cssUrl: 'https://fonts.googleapis.com/css2?family=Inconsolata:wght@200..900&display=swap' },
|
||||
{ name: 'Fraunces', displayName: 'Fraunces', cssUrl: 'https://fonts.googleapis.com/css2?family=Fraunces:wght@300..900&display=swap' },
|
||||
{ name: 'Gelasio', displayName: 'Gelasio', cssUrl: 'https://fonts.googleapis.com/css2?family=Gelasio:wght@300..700&display=swap' },
|
||||
{ name: 'Raleway', displayName: 'Raleway', cssUrl: 'https://fonts.googleapis.com/css2?family=Raleway:wght@100..900&display=swap' },
|
||||
{ name: 'Kanit', displayName: 'Kanit', cssUrl: 'https://fonts.googleapis.com/css2?family=Kanit:wght@100..900&display=swap' },
|
||||
{ name: 'Corben', displayName: 'Corben', cssUrl: 'https://fonts.googleapis.com/css2?family=Corben:wght@400;700&display=swap' },
|
||||
{ name: 'Poppins', displayName: 'Poppins', cssUrl: 'https://fonts.googleapis.com/css2?family=Poppins:wght@100..900&display=swap' },
|
||||
{ name: 'Open Sans', displayName: 'Open Sans', cssUrl: 'https://fonts.googleapis.com/css2?family=Open+Sans:wght@300..800&display=swap' },
|
||||
{ name: 'Lato', displayName: 'Lato', cssUrl: 'https://fonts.googleapis.com/css2?family=Lato:wght@100..900&display=swap' },
|
||||
{ name: 'Source Sans Pro', displayName: 'Source Sans Pro', cssUrl: 'https://fonts.googleapis.com/css2?family=Source+Sans+Pro:wght@200..900&display=swap' },
|
||||
{ name: 'Playfair Display', displayName: 'Playfair Display', cssUrl: 'https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400..900&display=swap' },
|
||||
{ name: 'Roboto', displayName: 'Roboto', cssUrl: 'https://fonts.googleapis.com/css2?family=Roboto:wght@100..900&display=swap' }
|
||||
]
|
||||
|
||||
export const DEFAULT_THEMES: any[] = [
|
||||
{
|
||||
id: "edge-yellow",
|
||||
name: "Edge Yellow",
|
||||
description: "Yellow and dark theme for professionalish and edge.",
|
||||
logo: null,
|
||||
logo_url: null,
|
||||
company_name: null,
|
||||
|
||||
data: {
|
||||
colors: {
|
||||
primary: "#f5f547",
|
||||
background: "#1f1f1f",
|
||||
card: "#424242",
|
||||
stroke: "#585858",
|
||||
primary_text: "#161616",
|
||||
background_text: "#f5f547",
|
||||
graph_0: "#ffff54",
|
||||
graph_1: "#f1f142",
|
||||
graph_2: "#dada15",
|
||||
graph_3: "#c1bf00",
|
||||
graph_4: "#a8a600",
|
||||
graph_5: "#908c00",
|
||||
graph_6: "#797400",
|
||||
graph_7: "#625c00",
|
||||
graph_8: "#4d4500",
|
||||
graph_9: "#382f00"
|
||||
},
|
||||
fonts: {
|
||||
textFont: {
|
||||
name: "Playfair Display",
|
||||
url: "https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400..900&display=swap"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "light-rose",
|
||||
name: "Light Rose",
|
||||
description: "Rose background with punchy font",
|
||||
logo: null,
|
||||
logo_url: null,
|
||||
company_name: null,
|
||||
|
||||
data: {
|
||||
colors: {
|
||||
"primary": "#030204",
|
||||
background: "#f69c9c",
|
||||
card: "#ffaeb4",
|
||||
stroke: "#bf6a6b",
|
||||
primary_text: "#bebebe",
|
||||
background_text: "#030202",
|
||||
graph_0: "#2f2c32",
|
||||
graph_1: "#444147",
|
||||
graph_2: "#5a565d",
|
||||
graph_3: "#706d73",
|
||||
graph_4: "#88848b",
|
||||
graph_5: "#a09da4",
|
||||
graph_6: "#b9b6bd",
|
||||
graph_7: "#d3cfd6",
|
||||
graph_8: "#eae6ed",
|
||||
graph_9: "#f7f3fb"
|
||||
},
|
||||
fonts: {
|
||||
textFont: {
|
||||
name: "Overpass",
|
||||
url: "https://fonts.googleapis.com/css2?family=Overpass:wght@100..900&display=swap"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "mint-blue",
|
||||
name: "Mint Blue",
|
||||
description: "Mint Greent with blue heading.",
|
||||
logo: null,
|
||||
logo_url: null,
|
||||
company_name: null,
|
||||
|
||||
data: {
|
||||
colors: {
|
||||
primary: "#3b3172",
|
||||
background: "#ffffff",
|
||||
card: "#80e7cf",
|
||||
stroke: "#d1d1d1",
|
||||
primary_text: "#ffffff",
|
||||
background_text: "#3b3172",
|
||||
graph_0: "#003d2d",
|
||||
graph_1: "#005341",
|
||||
graph_2: "#006a57",
|
||||
graph_3: "#00826d",
|
||||
graph_4: "#2b9a85",
|
||||
graph_5: "#4ab39d",
|
||||
graph_6: "#65cdb6",
|
||||
graph_7: "#80e7cf",
|
||||
graph_8: "#98ffe6",
|
||||
graph_9: "#a5fff4"
|
||||
},
|
||||
fonts: {
|
||||
textFont: {
|
||||
name: "Prompt",
|
||||
url: "https://fonts.googleapis.com/css2?family=Prompt:wght@100..900&display=swap"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "professional-blue",
|
||||
name: "Professional Blue",
|
||||
description: "Clean and professional blue theme",
|
||||
logo: null,
|
||||
logo_url: null,
|
||||
company_name: null,
|
||||
|
||||
data: {
|
||||
colors: {
|
||||
primary: "#161616",
|
||||
background: "#ffffff",
|
||||
card: "#dae6ff",
|
||||
stroke: "#d1d1d1",
|
||||
primary_text: "#eeeaea",
|
||||
background_text: "#000000",
|
||||
graph_0: "#2e2e2e",
|
||||
graph_1: "#424242",
|
||||
graph_2: "#585858",
|
||||
graph_3: "#6f6f6f",
|
||||
graph_4: "#868686",
|
||||
graph_5: "#9e9e9e",
|
||||
graph_6: "#b7b7b7",
|
||||
graph_7: "#d1d1d1",
|
||||
graph_8: "#e8e8e8",
|
||||
graph_9: "#f5f5f5"
|
||||
},
|
||||
fonts: {
|
||||
textFont: {
|
||||
name: "Inter",
|
||||
url: "https://fonts.googleapis.com/css2?family=Inter:wght@100..900&display=swap"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "professional-dark",
|
||||
name: "Professional Dark",
|
||||
description: "Clean and professional for dark corporate usage.",
|
||||
logo: null,
|
||||
logo_url: null,
|
||||
company_name: null,
|
||||
|
||||
data: {
|
||||
colors: {
|
||||
primary: "#eff5f1",
|
||||
background: "#050505",
|
||||
card: "#424242",
|
||||
stroke: "#585858",
|
||||
primary_text: "#050505",
|
||||
background_text: "#eff5f1",
|
||||
graph_0: "#ebf6ff",
|
||||
graph_1: "#dee8fa",
|
||||
graph_2: "#c7d2e3",
|
||||
graph_3: "#aeb8c9",
|
||||
graph_4: "#959fb0",
|
||||
graph_5: "#7d8797",
|
||||
graph_6: "#666f7f",
|
||||
graph_7: "#505867",
|
||||
graph_8: "#3a4351",
|
||||
graph_9: "#262e3c"
|
||||
},
|
||||
fonts: {
|
||||
textFont: {
|
||||
name: "Instrument Sans",
|
||||
url: "https://fonts.googleapis.com/css2?family=Instrument+Sans:ital,wght@0,400..700;1,400..700&display=swap"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,20 @@
|
|||
export interface ThemeColors {
|
||||
'primary': string
|
||||
'background': string
|
||||
'card': string
|
||||
'stroke': string
|
||||
'primary_text': string
|
||||
'background_text': string
|
||||
'graph_0': string
|
||||
'graph_1': string
|
||||
'graph_2': string
|
||||
'graph_3': string
|
||||
'graph_4': string
|
||||
'graph_5': string
|
||||
'graph_6': string
|
||||
'graph_7': string
|
||||
'graph_8': string
|
||||
'graph_9': string
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
export const loadGoogleFont = (fontFamily: string) => {
|
||||
// Check if font is already loaded
|
||||
const existingLink = document.querySelector(`link[href*="${fontFamily.replace(' ', '+')}"]`)
|
||||
if (existingLink) return
|
||||
|
||||
const link = document.createElement('link')
|
||||
link.href = `https://fonts.googleapis.com/css2?family=${fontFamily.replace(' ', '+')}:wght@300;400;500;600;700&display=swap`
|
||||
link.rel = 'stylesheet'
|
||||
document.head.appendChild(link)
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
const ThemeCardSkeleton = () => (
|
||||
<div className="rounded-xl px-6 border border-[#EDEEEF] w-[305px] bg-white overflow-hidden">
|
||||
{/* Preview area */}
|
||||
<div className="relative h-[250px] p-6">
|
||||
{/* Top badges */}
|
||||
<div className="absolute top-2 left-2 flex items-center gap-2 z-10">
|
||||
<Skeleton className="h-6 w-16 rounded-full" />
|
||||
<Skeleton className="h-6 w-20 rounded-full" />
|
||||
</div>
|
||||
{/* Card preview */}
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="w-full h-[135px] rounded-xl overflow-hidden">
|
||||
<Skeleton className="w-full h-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Bottom info */}
|
||||
<div className="px-5 border-t border-[#EDEEEF] py-2.5 h-[80px] flex items-center justify-between">
|
||||
<div>
|
||||
<Skeleton className="h-4 w-24 mb-2" />
|
||||
<div className="flex items-center gap-1">
|
||||
<Skeleton className="w-4 h-4 rounded-full" />
|
||||
<Skeleton className="w-4 h-4 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const Loading = () => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Tabs skeleton */}
|
||||
<div className="p-1 rounded-[40px] bg-[#F7F6F9] w-fit border border-[#F4F4F4] flex items-center justify-center">
|
||||
<Skeleton className="h-8 w-20 rounded-[70px]" />
|
||||
<div className="mx-1 w-[2px] h-[17px] bg-[#EDECEC]" />
|
||||
<Skeleton className="h-8 w-20 rounded-[70px]" />
|
||||
</div>
|
||||
|
||||
{/* Theme cards grid */}
|
||||
<div className="flex flex-wrap gap-6">
|
||||
{Array.from({ length: 4 }).map((_, idx) => (
|
||||
<ThemeCardSkeleton key={idx} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Loading
|
||||
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import React from 'react'
|
||||
import ThemePanel from './components/ThemePanel'
|
||||
const page = () => {
|
||||
return (
|
||||
<ThemePanel />
|
||||
)
|
||||
}
|
||||
|
||||
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" })}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -357,7 +357,9 @@ const ImageEditor = ({
|
|||
<div className="grid grid-cols-2 gap-4 ">
|
||||
{previousGeneratedImages.map((image) => (
|
||||
<div
|
||||
onClick={() => handleImageChange(image.file_url || image.path)}
|
||||
onClick={() =>
|
||||
handleImageChange(image.file_url || image.path)
|
||||
}
|
||||
key={image.id}
|
||||
className="aspect-[4/3] w-full overflow-hidden rounded-lg border cursor-pointer hover:border-blue-500 transition-colors"
|
||||
>
|
||||
|
|
@ -483,7 +485,7 @@ const ImageEditor = ({
|
|||
handleDeleteImage(image.id)
|
||||
}}/>
|
||||
<img
|
||||
src={image.file_url || image.path}
|
||||
src={image.file_url || image.path}
|
||||
alt="Uploaded preview"
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ export const V1ContentRender = ({ slide, isEditMode, theme }: { slide: any, isEd
|
|||
if (isEditMode) {
|
||||
return (
|
||||
<SlideErrorBoundary label={`Slide ${slide.index + 1}`}>
|
||||
<div ref={containerRef} className={`w-full h-full `}>
|
||||
<div ref={containerRef} className={`w-full h-full border border-[#EDEEEF] `}>
|
||||
|
||||
<EditableLayoutWrapper
|
||||
slideIndex={slide.index}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ export const useFontManagement = () => {
|
|||
const formData = new FormData();
|
||||
formData.append("font_file", file);
|
||||
|
||||
const response = await fetch(getApiUrl("api/v1/ppt/fonts/upload"), {
|
||||
const response = await fetch(getApiUrl("/api/v1/ppt/fonts/upload"), {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ export const useLayoutSaving = (
|
|||
|
||||
while (retryCount < maxRetries) {
|
||||
try {
|
||||
const response = await fetch(getApiUrl("api/v1/ppt/html-to-react/"), {
|
||||
const response = await fetch(getApiUrl("/api/v1/ppt/html-to-react/"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
|
@ -137,7 +137,7 @@ export const useLayoutSaving = (
|
|||
}
|
||||
|
||||
// First create/update the template metadata
|
||||
await fetch(getApiUrl("api/v1/ppt/template-management/templates"), {
|
||||
await fetch(getApiUrl("/api/v1/ppt/template-management/templates"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id: presentationId, name: layoutName, description }),
|
||||
|
|
|
|||
|
|
@ -143,7 +143,7 @@ export const useSlideEdit = (
|
|||
formData.append("html", currentHtml);
|
||||
formData.append("prompt", prompt);
|
||||
|
||||
const response = await fetch(getApiUrl("api/v1/ppt/html-edit/"), {
|
||||
const response = await fetch(getApiUrl("/api/v1/ppt/html-edit/"), {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export const useSlideProcessing = (
|
|||
);
|
||||
|
||||
try {
|
||||
const htmlResponse = await fetch(getApiUrl("api/v1/ppt/slide-to-html/"), {
|
||||
const htmlResponse = await fetch(getApiUrl("/api/v1/ppt/slide-to-html/"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
|
@ -134,7 +134,7 @@ export const useSlideProcessing = (
|
|||
let slidesResponseData: any = null;
|
||||
if (isPdf) {
|
||||
formData.append("pdf_file", selectedFile);
|
||||
const pdfResponse = await fetch(getApiUrl("api/v1/ppt/pdf-slides/process"), {
|
||||
const pdfResponse = await fetch(getApiUrl("/api/v1/ppt/pdf-slides/process"), {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
|
@ -144,7 +144,7 @@ export const useSlideProcessing = (
|
|||
);
|
||||
} else if (isPptx) {
|
||||
formData.append("pptx_file", selectedFile);
|
||||
const pptxResponse = await fetch(getApiUrl("api/v1/ppt/pptx-slides/process"), {
|
||||
const pptxResponse = await fetch(getApiUrl("/api/v1/ppt/pptx-slides/process"), {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -21,8 +21,6 @@ export interface ProcessedSlide extends SlideData {
|
|||
error?: string;
|
||||
modified?: boolean;
|
||||
convertingToReact?: boolean; // indicates HTML-to-React conversion in progress
|
||||
react?: string; // React component code
|
||||
layout_name?: string; // Layout name
|
||||
}
|
||||
|
||||
export interface FontData {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,36 @@
|
|||
|
||||
export const useFontLoader = (fonts: Record<string, string>) => {
|
||||
const injectFonts = () => {
|
||||
if (typeof document === 'undefined' || !fonts || typeof fonts !== 'object') return;
|
||||
|
||||
const ensureStylesheetLink = (href: string) => {
|
||||
const existing = document.querySelector(`link[rel="stylesheet"][data-font-url="${href}"]`);
|
||||
if (existing) return;
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.setAttribute('data-font-url', href);
|
||||
link.href = href;
|
||||
document.head.appendChild(link);
|
||||
};
|
||||
|
||||
const ensureFontFaceStyle = (name: string, srcUrl: string) => {
|
||||
const existing = document.querySelector(`style[data-font-url="${srcUrl}"]`);
|
||||
if (existing) return;
|
||||
const styleEl = document.createElement('style');
|
||||
styleEl.setAttribute('data-font-url', srcUrl);
|
||||
styleEl.textContent = `@font-face {\n font-family: '${name}';\n src: url('${srcUrl}');\n font-style: normal;\n font-display: swap;\n}`;
|
||||
document.head.appendChild(styleEl);
|
||||
};
|
||||
|
||||
Object.entries(fonts).forEach(([name, url]) => {
|
||||
if (!name || !url) return;
|
||||
const isCss = /\.css(\?|$)/i.test(url) || /fonts\.googleapis\.com/.test(url);
|
||||
if (isCss) {
|
||||
ensureStylesheetLink(url);
|
||||
} else {
|
||||
ensureFontFaceStyle(name, url);
|
||||
}
|
||||
});
|
||||
};
|
||||
injectFonts();
|
||||
};
|
||||
|
|
@ -28,26 +28,21 @@ LayoutPreview.displayName = 'LayoutPreview';
|
|||
|
||||
export const CustomTemplateCard = memo(({ template, onSelectTemplate, selectedTemplate }: { template: CustomTemplates, onSelectTemplate: (template: string) => void, selectedTemplate: string | null }) => {
|
||||
|
||||
const { previewLayouts, loading: customLoading } = useCustomTemplatePreview(template.id);
|
||||
const { previewLayouts, loading: customLoading, totalLayouts } = useCustomTemplatePreview(template.id);
|
||||
const isSelected = selectedTemplate === template.id;
|
||||
|
||||
return (
|
||||
|
||||
<Card
|
||||
className={`${isSelected ? 'border-2 border-blue-500' : ''} cursor-pointer hover:shadow-lg transition-all duration-200 group overflow-hidden relative`}
|
||||
style={{ contain: 'layout style paint' }}
|
||||
onClick={() => {
|
||||
onSelectTemplate(template.id);
|
||||
}}
|
||||
className={`${isSelected ? 'border-2 border-blue-500' : ''} font-syne cursor-pointer flex flex-col justify-between relative hover:shadow-lg transition-all duration-200 group overflow-hidden`}
|
||||
onClick={() => onSelectTemplate(template.id)}
|
||||
>
|
||||
|
||||
<img src="/card_bg.svg" alt="" className="absolute top-0 left-0 w-full h-full object-cover" />
|
||||
<span className="text-xs font-syne absolute top-2 flex gap-1 capitalize items-center left-2 rounded-[100px] px-2.5 py-1 bg-[#3A3A3AF5] text-white font-semibold z-40">
|
||||
Layouts- {totalLayouts}
|
||||
</span>
|
||||
<div className="p-5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-xl font-bold text-gray-900">
|
||||
{template.name}
|
||||
</h3>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* Layout previews */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
|
|
@ -61,36 +56,37 @@ export const CustomTemplateCard = memo(({ template, onSelectTemplate, selectedTe
|
|||
<Loader2 className="w-4 h-4 text-purple-300 animate-spin" />
|
||||
</div>
|
||||
))
|
||||
) : previewLayouts && previewLayouts?.length > 0 ? (
|
||||
// Actual layout previews - using memoized component
|
||||
previewLayouts?.slice(0, 4).map((layout: CompiledLayout, index: number) => (
|
||||
<LayoutPreview
|
||||
key={`${template.id}-preview-${index}`}
|
||||
layout={layout}
|
||||
templateId={template.id}
|
||||
index={index}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
// Empty state placeholders
|
||||
[...Array(Math.min(4, template.layoutCount))].map((_, index) => (
|
||||
<div
|
||||
key={`${template.id}-empty-${index}`}
|
||||
className="relative bg-gray-100 border border-gray-200 overflow-hidden aspect-video rounded flex items-center justify-center"
|
||||
>
|
||||
<span className="text-xs text-gray-400">No preview</span>
|
||||
</div>
|
||||
))
|
||||
) : previewLayouts.length > 0 && (
|
||||
// Actual layout previews
|
||||
previewLayouts.slice(0, 4).map((layout: CompiledLayout, index: number) => {
|
||||
const LayoutComponent = layout.component;
|
||||
return (
|
||||
<div
|
||||
key={`${template.id}-preview-${index}`}
|
||||
className="relative bg-gray-100 border border-gray-200 overflow-hidden aspect-video rounded"
|
||||
>
|
||||
<div className="absolute inset-0 bg-transparent z-10" />
|
||||
<div
|
||||
className="transform scale-[0.12] origin-top-left"
|
||||
style={{ width: "833.33%", height: "833.33%" }}
|
||||
>
|
||||
<LayoutComponent data={layout.sampleData} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="absolute top-0 right-0 bg-blue-500 text-white px-2 py-1 rounded-bl-lg">
|
||||
Selected
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between p-5 bg-white border-t border-[#EDEEEF] relative z-40 ">
|
||||
<h3 className="text-sm font-bold text-gray-900">
|
||||
{template.name}
|
||||
</h3>
|
||||
|
||||
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ const EmptyStateView: React.FC = () => {
|
|||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Wrapper>
|
||||
<div className="max-w-[800px] h-[calc(100vh-72px)] flex justify-center items-center mx-auto px-4 sm:px-6 pb-6">
|
||||
<Wrapper className="bg-white">
|
||||
<div className="max-w-[800px] h-[calc(100vh-72px)] font-syne flex justify-center items-center mx-auto px-4 sm:px-6 pb-6">
|
||||
<div className="text-center space-y-8">
|
||||
{/* Icon */}
|
||||
<div className="flex justify-center">
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingState, Template } from "../types/index";
|
||||
import { TemplateLayoutsWithSettings } from "@/app/presentation-templates/utils";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
|
||||
interface GenerateButtonProps {
|
||||
loadingState: LoadingState;
|
||||
|
|
@ -50,34 +51,14 @@ const GenerateButton: React.FC<GenerateButtonProps> = ({
|
|||
}
|
||||
onSubmit();
|
||||
}}
|
||||
className="bg-[#5146E5] w-full rounded-lg text-base sm:text-lg py-4 sm:py-6 font-instrument_sans font-semibold hover:bg-[#5146E5]/80 text-white disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className=" w-full flex items-center gap-0.5 rounded-[58px] text-sm py-3 px-5 font-instrument_sans font-semibold text-[#101323] disabled:opacity-50 disabled:cursor-not-allowed font-syne"
|
||||
style={{
|
||||
background: "linear-gradient(270deg, #D5CAFC 2.4%, #E3D2EB 27.88%, #F4DCD3 69.23%, #FDE4C2 100%)",
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
className="mr-2"
|
||||
width="24"
|
||||
height="24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 25 25"
|
||||
fill="none"
|
||||
>
|
||||
<g clipPath="url(#clip0_1960_939)">
|
||||
<path
|
||||
d="M21.217 9.57008L21.463 9.00408C21.8955 8.0028 22.6876 7.2 23.683 6.75408L24.442 6.41508C24.5341 6.37272 24.6121 6.30485 24.6668 6.21951C24.7214 6.13417 24.7505 6.03494 24.7505 5.93358C24.7505 5.83222 24.7214 5.73299 24.6668 5.64765C24.6121 5.56231 24.5341 5.49444 24.442 5.45208L23.725 5.13308C22.7046 4.67446 21.8989 3.84196 21.474 2.80708L21.221 2.19608C21.1838 2.10144 21.119 2.02018 21.035 1.96291C20.951 1.90563 20.8517 1.875 20.75 1.875C20.6483 1.875 20.549 1.90563 20.465 1.96291C20.381 2.02018 20.3162 2.10144 20.279 2.19608L20.026 2.80608C19.6015 3.84116 18.7962 4.67401 17.776 5.13308L17.058 5.45308C16.9662 5.49556 16.8885 5.56342 16.834 5.64865C16.7795 5.73389 16.7506 5.83293 16.7506 5.93408C16.7506 6.03523 16.7795 6.13428 16.834 6.21951C16.8885 6.30474 16.9662 6.3726 17.058 6.41508L17.818 6.75308C18.8132 7.19945 19.6049 8.00261 20.037 9.00408L20.283 9.57008C20.463 9.98408 21.036 9.98408 21.217 9.57008ZM6.55 16.8761H8.704L9.304 15.3761H12.196L12.796 16.8761H14.95L11.75 8.87608H9.75L6.55 16.8761ZM10.75 11.7611L11.396 13.3761H10.104L10.75 11.7611ZM15.75 16.8761V8.87608H17.75V16.8761H15.75ZM3.75 3.87608C3.48478 3.87608 3.23043 3.98144 3.04289 4.16897C2.85536 4.35651 2.75 4.61086 2.75 4.87608V20.8761C2.75 21.1413 2.85536 21.3957 3.04289 21.5832C3.23043 21.7707 3.48478 21.8761 3.75 21.8761H21.75C22.0152 21.8761 22.2696 21.7707 22.4571 21.5832C22.6446 21.3957 22.75 21.1413 22.75 20.8761V11.8761H20.75V19.8761H4.75V5.87608H14.75V3.87608H3.75Z"
|
||||
fill="white"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1960_939">
|
||||
<rect
|
||||
width="24"
|
||||
height="24"
|
||||
fill="white"
|
||||
transform="translate(0.75 0.876953)"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
{getButtonText()}
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ const OutlineContent: React.FC<OutlineContentProps> = ({
|
|||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<div className="space-y-6 font-instrument_sans">
|
||||
<div className="space-y-6 font-syne ">
|
||||
{isLoading && (!outlines || outlines.length === 0) && (
|
||||
<div className="flex items-center justify-center">
|
||||
<span className="inline-flex items-center gap-1 rounded-full border border-blue-200 bg-blue-50 text-blue-600 px-2 py-0.5 text-xs">
|
||||
|
|
@ -70,7 +70,7 @@ const OutlineContent: React.FC<OutlineContentProps> = ({
|
|||
</div> */}
|
||||
{/* Skeleton loading state */}
|
||||
{isLoading && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4 bg-white">
|
||||
{[...Array(6)].map((_, index) => (
|
||||
<div key={index} className="animate-pulse">
|
||||
<div className="flex items-start space-x-3 p-4 border rounded-lg bg-white">
|
||||
|
|
@ -91,41 +91,30 @@ const OutlineContent: React.FC<OutlineContentProps> = ({
|
|||
)}
|
||||
|
||||
{/* Outlines content */}
|
||||
|
||||
{outlines && outlines.length > 0 && (
|
||||
<div>
|
||||
<div className="bg-[#F9F8F8] min-h-[calc(100vh-16rem)] p-7 relative z-20 rounded-[20px] overflow-y-auto custom_scrollbar">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={onDragEnd}
|
||||
>
|
||||
{isStreaming ? (
|
||||
|
||||
outlines.map((item, index) => (
|
||||
<OutlineItem
|
||||
key={`slide-${index}`}
|
||||
index={index + 1}
|
||||
slideOutline={item}
|
||||
isStreaming={isStreaming}
|
||||
isActiveStreaming={activeSlideIndex === index}
|
||||
isStableStreaming={highestActiveIndex >= 0 && index < highestActiveIndex}
|
||||
/>
|
||||
))
|
||||
) :
|
||||
<SortableContext
|
||||
items={outlines?.map((item, index) => ({ id: `slide-${index}` })) || []}
|
||||
<SortableContext
|
||||
items={outlines.map((_, index) => `slide-${index}`)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{outlines?.map((item, index) => (
|
||||
{outlines.map((item, index) => (
|
||||
<OutlineItem
|
||||
key={`slide-${index}`}
|
||||
sortableId={`slide-${index}`}
|
||||
index={index + 1}
|
||||
slideOutline={item}
|
||||
isStreaming={isStreaming}
|
||||
isActiveStreaming={false}
|
||||
isStableStreaming={false}
|
||||
isActiveStreaming={activeSlideIndex === index}
|
||||
isStableStreaming={highestActiveIndex >= 0 && index < highestActiveIndex}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useSortable } from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import { Trash2 } from "lucide-react"
|
||||
import { GripHorizontal, Trash, Trash2 } from "lucide-react"
|
||||
import { RootState } from "@/store/store"
|
||||
import { useDispatch, useSelector } from "react-redux"
|
||||
import { deleteSlideOutline, setOutlines } from "@/store/slices/presentationGeneration"
|
||||
|
|
@ -11,6 +11,7 @@ import { marked } from "marked"
|
|||
|
||||
|
||||
interface OutlineItemProps {
|
||||
sortableId: string
|
||||
slideOutline: {
|
||||
content: string,
|
||||
},
|
||||
|
|
@ -21,6 +22,7 @@ interface OutlineItemProps {
|
|||
}
|
||||
|
||||
export function OutlineItem({
|
||||
sortableId,
|
||||
index,
|
||||
slideOutline,
|
||||
isStreaming,
|
||||
|
|
@ -45,7 +47,7 @@ export function OutlineItem({
|
|||
}
|
||||
}, [outlines.length]);
|
||||
|
||||
const handleSlideChange = (newOutline:any) => {
|
||||
const handleSlideChange = (newOutline: any) => {
|
||||
if (isStreaming) return;
|
||||
const newData = outlines?.map((each, idx) => {
|
||||
if (idx === index - 1) {
|
||||
|
|
@ -69,7 +71,7 @@ export function OutlineItem({
|
|||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: index })
|
||||
} = useSortable({ id: sortableId, disabled: isStreaming })
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
|
|
@ -117,30 +119,34 @@ export function OutlineItem({
|
|||
}, [isStreaming, isActiveStreaming, isStableStreaming, slideOutline.content])
|
||||
|
||||
return (
|
||||
<div className="mb-2">
|
||||
{/* Main Title Row */}
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`mb-4 bg-white rounded-[12px] group shadow-sm p-10 relative font-syne transition-all duration-500 hover:shadow-[0_6.6px_13.2px_0_rgba(0,0,0,0.10)] ${isDragging ? "opacity-50" : ""}`}
|
||||
>
|
||||
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`flex items-start gap-2 md:gap-4 p-2 sm:pr-4 border border-black/10 bg-purple-100/10 rounded-[8px] ${isDragging ? "opacity-50" : ""}`}
|
||||
className="flex items-start gap-3 md:gap-4 rounded-[8px]"
|
||||
>
|
||||
{/* Drag Handle with Number - Make it smaller on mobile */}
|
||||
|
||||
<div
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
className="min-w-8 sm:min-w-10 w-10 sm:w-14 h-10 sm:h-14 bg-blue-400/10 rounded-[8px] flex items-center justify-center relative cursor-grab"
|
||||
className=" flex items-center justify-center relative cursor-grab"
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-[2px]">
|
||||
<div className="w-[3px] h-[3px] bg-black/80 rounded-full" />
|
||||
<div className="w-[3px] h-[3px] bg-black/80 rounded-full" />
|
||||
<div className="w-[3px] h-[3px] bg-black/80 rounded-full" />
|
||||
<div className="w-[3px] h-[3px] bg-black/80 rounded-full" />
|
||||
</div>
|
||||
<span className="text-black/80 text-md sm:text-lg font-medium ml-1">{index}</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 10C12.5523 10 13 9.55228 13 9C13 8.44772 12.5523 8 12 8C11.4477 8 11 8.44772 11 9C11 9.55228 11.4477 10 12 10Z" fill="#191919" stroke="#191919" strokeWidth="0.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M19 10C19.5523 10 20 9.55228 20 9C20 8.44772 19.5523 8 19 8C18.4477 8 18 8.44772 18 9C18 9.55228 18.4477 10 19 10Z" fill="#191919" stroke="#191919" strokeWidth="0.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M5 10C5.55228 10 6 9.55228 6 9C6 8.44772 5.55228 8 5 8C4.44772 8 4 8.44772 4 9C4 9.55228 4.44772 10 5 10Z" fill="#191919" stroke="#191919" strokeWidth="0.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M12 16C12.5523 16 13 15.5523 13 15C13 14.4477 12.5523 14 12 14C11.4477 14 11 14.4477 11 15C11 15.5523 11.4477 16 12 16Z" fill="#191919" stroke="#191919" strokeWidth="0.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M19 16C19.5523 16 20 15.5523 20 15C20 14.4477 19.5523 14 19 14C18.4477 14 18 14.4477 18 15C18 15.5523 18.4477 16 19 16Z" fill="#191919" stroke="#191919" strokeWidth="0.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M5 16C5.55228 16 6 15.5523 6 15C6 14.4477 5.55228 14 5 14C4.44772 14 4 14.4477 4 15C4 15.5523 4.44772 16 5 16Z" fill="#191919" stroke="#191919" strokeWidth="0.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Main Title Input - Add onFocus handler */}
|
||||
|
||||
<div id={`outline-item-${index}`} className="flex flex-col basis-full gap-2">
|
||||
<p className="text-black w-fit text-[10px] font-medium bg-white border border-[#EDEEEF] rounded-[80px] px-2.5">slide {index}</p>
|
||||
{/* Editable Markdown Content */}
|
||||
{isStreaming ? (
|
||||
isActiveStreaming ? (
|
||||
|
|
@ -166,15 +172,15 @@ export function OutlineItem({
|
|||
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-1 sm:gap-2 items-center">
|
||||
|
||||
<div className="hidden group-hover:flex absolute -top-3 -right-3 gap-1 sm:gap-2 items-center">
|
||||
|
||||
<ToolTip content="Delete Slide">
|
||||
<button
|
||||
onClick={handleSlideDelete}
|
||||
className="p-1.5 sm:p-2 bg-gray-200/50 hover:bg-gray-200 rounded-lg transition-colors"
|
||||
className="p-1.5 sm:p-2 bg-white shadow-md rounded-full transition-colors"
|
||||
>
|
||||
<Trash2 className="w-4 h-4 sm:w-5 sm:h-5 text-black/70" />
|
||||
<Trash className="w-4 h-4 text-black/70" />
|
||||
</button>
|
||||
</ToolTip>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { useOutlineManagement } from "../hooks/useOutlineManagement";
|
|||
import { usePresentationGeneration } from "../hooks/usePresentationGeneration";
|
||||
import TemplateSelection from "./TemplateSelection";
|
||||
import { TemplateLayoutsWithSettings } from "@/app/presentation-templates/utils";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
const OutlinePage: React.FC = () => {
|
||||
const { presentation_id, outlines } = useSelector(
|
||||
|
|
@ -39,7 +40,8 @@ const OutlinePage: React.FC = () => {
|
|||
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-72px)]">
|
||||
<div className=" font-syne pb-9">
|
||||
|
||||
<OverlayLoader
|
||||
show={loadingState.isLoading}
|
||||
text={loadingState.message}
|
||||
|
|
@ -47,16 +49,27 @@ const OutlinePage: React.FC = () => {
|
|||
duration={loadingState.duration}
|
||||
/>
|
||||
|
||||
<Wrapper className="h-full flex flex-col w-full">
|
||||
<div className="flex-grow overflow-y-hidden w-[1200px] mx-auto">
|
||||
<Wrapper className="h-full flex flex-col w-full relative px-5 sm:px-10 lg:px-20 ">
|
||||
<div className="flex-grow w-full hidden-scrollbar mx-auto ">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="h-full flex flex-col">
|
||||
<TabsList className="grid w-[50%] mx-auto my-4 grid-cols-2">
|
||||
<TabsTrigger value={TABS.OUTLINE}>Outline & Content</TabsTrigger>
|
||||
<TabsTrigger value={TABS.LAYOUTS}>Select Template</TabsTrigger>
|
||||
<TabsList className="my-4 h-auto w-fit rounded-full border border-[#EDEEEF] bg-white p-1.5">
|
||||
<TabsTrigger
|
||||
value={TABS.OUTLINE}
|
||||
className="rounded-full px-5 py-2 text-xs font-medium text-[#2D2D2D] shadow-none data-[state=active]:bg-[#F4F3FF] data-[state=active]:text-[#7E3AF2] data-[state=active]:shadow-none"
|
||||
>
|
||||
Outline & Content
|
||||
</TabsTrigger>
|
||||
<Separator orientation="vertical" className="h-6 mx-1" />
|
||||
<TabsTrigger
|
||||
value={TABS.LAYOUTS}
|
||||
className="relative rounded-full px-5 py-2 text-xs font-medium text-[#2D2D2D] shadow-none data-[state=active]:bg-[#F4F3FF] data-[state=active]:text-[#7E3AF2] data-[state=active]:shadow-none"
|
||||
>
|
||||
Select Template
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="flex-grow w-full mx-auto">
|
||||
<TabsContent value={TABS.OUTLINE} className="h-[calc(100vh-16rem)] overflow-y-auto custom_scrollbar"
|
||||
<TabsContent value={TABS.OUTLINE} className="h-[calc(100vh-15rem)] overflow-y-auto hide-scrollbar"
|
||||
>
|
||||
<div>
|
||||
<OutlineContent
|
||||
|
|
@ -71,7 +84,7 @@ const OutlinePage: React.FC = () => {
|
|||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={TABS.LAYOUTS} className="h-[calc(100vh-16rem)] overflow-y-auto custom_scrollbar">
|
||||
<TabsContent value={TABS.LAYOUTS} className="h-[calc(100vh-16rem)] bg-white overflow-y-auto hide-scrollbar">
|
||||
<div>
|
||||
<TemplateSelection
|
||||
selectedTemplate={selectedTemplate}
|
||||
|
|
@ -81,11 +94,9 @@ const OutlinePage: React.FC = () => {
|
|||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
{/* Fixed Button */}
|
||||
|
||||
{/* Fixed Button */}
|
||||
<div className="py-4 border-t border-gray-200">
|
||||
<div className="max-w-[1200px] mx-auto">
|
||||
<div className="absolute bottom-[26px] right-[26px] z-50">
|
||||
<GenerateButton
|
||||
outlineCount={outlines.length}
|
||||
loadingState={loadingState}
|
||||
|
|
@ -95,6 +106,9 @@ const OutlinePage: React.FC = () => {
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
</Wrapper>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
"use client";
|
||||
import React, { useEffect } from "react";
|
||||
import React, { useEffect, useMemo, useCallback, memo } from "react";
|
||||
|
||||
import { TemplateLayoutsWithSettings } from "@/app/presentation-templates/utils";
|
||||
import { templates } from "@/app/presentation-templates";
|
||||
|
|
@ -8,19 +8,87 @@ import { TemplateWithData } from "@/app/presentation-templates/utils";
|
|||
import { CustomTemplates, useCustomTemplateSummaries } from "@/app/hooks/useCustomTemplates";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { CustomTemplateCard } from "./CustomTemplateCard";
|
||||
import CreateCustomTemplate from "../../(dashboard)/templates/components/CreateCustomTemplate";
|
||||
|
||||
// Memoized layout preview for built-in templates
|
||||
const BuiltInLayoutPreview = memo(({ layout, templateId, index }: {
|
||||
layout: TemplateWithData;
|
||||
templateId: string;
|
||||
index: number;
|
||||
}) => {
|
||||
const LayoutComponent = layout.component;
|
||||
return (
|
||||
<div
|
||||
className="relative bg-gray-100 font-syne border border-gray-200 overflow-hidden aspect-video rounded"
|
||||
style={{ contain: 'layout style paint' }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-transparent z-10" />
|
||||
<div
|
||||
className="transform scale-[0.12] origin-top-left"
|
||||
style={{ width: "833.33%", height: "833.33%" }}
|
||||
>
|
||||
<LayoutComponent data={layout.sampleData} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
BuiltInLayoutPreview.displayName = 'BuiltInLayoutPreview';
|
||||
|
||||
// Memoized built-in template card
|
||||
const BuiltInTemplateCard = memo(({ template, isSelected, onSelect }: {
|
||||
template: TemplateLayoutsWithSettings;
|
||||
isSelected: boolean;
|
||||
onSelect: (template: TemplateLayoutsWithSettings) => void;
|
||||
}) => {
|
||||
const previewLayouts = useMemo(() => template.layouts.slice(0, 4), [template.layouts]);
|
||||
const handleClick = useCallback(() => onSelect(template), [onSelect, template]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`${isSelected ? 'border-2 border-blue-500' : ''} cursor-pointer relative hover:shadow-lg transition-all duration-200 group overflow-hidden`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<span className="text-xs font-syne absolute top-2 flex gap-1 capitalize items-center left-2 rounded-[100px] px-2.5 py-1 bg-[#3A3A3AF5] text-white font-semibold z-40">
|
||||
Layouts- {template.layouts.length}
|
||||
</span>
|
||||
<img src="/card_bg.svg" alt="" className="absolute top-0 left-0 w-full h-full object-cover" />
|
||||
<div className="p-5">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{previewLayouts.map((layout: TemplateWithData, index: number) => (
|
||||
<BuiltInLayoutPreview
|
||||
key={`${template.id}-preview-${index}`}
|
||||
layout={layout}
|
||||
templateId={template.id}
|
||||
index={index}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-5 bg-white border-t border-[#EDEEEF] relative z-40">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-gray-900 capitalize font-syne">
|
||||
{template.name}
|
||||
</h3>
|
||||
<p className="text-xs text-gray-600 line-clamp-2 font-syne">
|
||||
{template.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
BuiltInTemplateCard.displayName = 'BuiltInTemplateCard';
|
||||
|
||||
interface TemplateSelectionProps {
|
||||
selectedTemplate: (TemplateLayoutsWithSettings | string) | null;
|
||||
onSelectTemplate: (template: TemplateLayoutsWithSettings | string) => void;
|
||||
}
|
||||
|
||||
const TemplateSelection: React.FC<TemplateSelectionProps> = ({
|
||||
const TemplateSelection: React.FC<TemplateSelectionProps> = memo(({
|
||||
selectedTemplate,
|
||||
onSelectTemplate
|
||||
}) => {
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
const existingScript = document.querySelector(
|
||||
'script[src*="tailwindcss.com"]'
|
||||
);
|
||||
|
|
@ -30,107 +98,99 @@ const TemplateSelection: React.FC<TemplateSelectionProps> = ({
|
|||
script.async = true;
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
|
||||
}, []);
|
||||
|
||||
const { templates: customTemplates, loading: customLoading } = useCustomTemplateSummaries();
|
||||
|
||||
// Stable callback for custom template selection
|
||||
const handleCustomSelect = useCallback(
|
||||
(template: TemplateLayoutsWithSettings | string) => onSelectTemplate(template),
|
||||
[onSelectTemplate]
|
||||
);
|
||||
|
||||
// Stable callback for built-in template selection
|
||||
const handleBuiltInSelect = useCallback(
|
||||
(template: TemplateLayoutsWithSettings) => onSelectTemplate(template),
|
||||
[onSelectTemplate]
|
||||
);
|
||||
|
||||
// Derive the selected custom template id only when selectedTemplate changes
|
||||
const selectedCustomId = useMemo(
|
||||
() => (typeof selectedTemplate === 'string' ? selectedTemplate : null),
|
||||
[selectedTemplate]
|
||||
);
|
||||
|
||||
// Derive the selected built-in template id only when selectedTemplate changes
|
||||
const selectedBuiltInId = useMemo(
|
||||
() => (typeof selectedTemplate !== 'string' ? selectedTemplate?.id ?? null : null),
|
||||
[selectedTemplate]
|
||||
);
|
||||
|
||||
// Memoize the custom templates section
|
||||
const customTemplateCards = useMemo(() => {
|
||||
if (customLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12 font-syne">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-600 font-syne" />
|
||||
<span className="ml-3 text-gray-600">Loading custom templates...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (customTemplates.length === 0) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
|
||||
<CreateCustomTemplate />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
{customTemplates.map((template: CustomTemplates) => (
|
||||
<CustomTemplateCard
|
||||
key={template.id}
|
||||
template={template}
|
||||
onSelectTemplate={handleCustomSelect}
|
||||
selectedTemplate={selectedCustomId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}, [customLoading, customTemplates, handleCustomSelect, selectedCustomId]);
|
||||
|
||||
// Memoize the built-in templates list
|
||||
const builtInTemplateCards = useMemo(
|
||||
() =>
|
||||
templates.map((template: TemplateLayoutsWithSettings) => (
|
||||
<BuiltInTemplateCard
|
||||
key={template.id}
|
||||
template={template}
|
||||
isSelected={selectedBuiltInId === template.id}
|
||||
onSelect={handleBuiltInSelect}
|
||||
/>
|
||||
)),
|
||||
[selectedBuiltInId, handleBuiltInSelect]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-8 mb-4">
|
||||
{/* In Built Templates */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-3">In Built Templates</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{templates.map((template: TemplateLayoutsWithSettings) => {
|
||||
const previewLayouts = template.layouts.slice(0, 4);
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={template.id}
|
||||
className={`${typeof selectedTemplate !== 'string' && selectedTemplate?.id === template.id ? 'border-2 border-blue-500' : ''} cursor-pointer hover:shadow-lg transition-all duration-200 group overflow-hidden relative`}
|
||||
onClick={() => onSelectTemplate(template)}
|
||||
>
|
||||
<div className="p-5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="text-xl font-bold text-gray-900 capitalize">
|
||||
{template.name}
|
||||
</h3>
|
||||
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 mb-4 line-clamp-2">
|
||||
{template.description}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{previewLayouts.map((layout: TemplateWithData, index: number) => {
|
||||
const LayoutComponent = layout.component;
|
||||
return (
|
||||
<div
|
||||
key={`${template.id}-preview-${index}`}
|
||||
className="relative bg-gray-100 border border-gray-200 overflow-hidden aspect-video rounded"
|
||||
style={{ contain: 'layout style paint' }}
|
||||
>
|
||||
<div className="absolute inset-0 bg-transparent z-10" />
|
||||
<div
|
||||
className="transform scale-[0.2] flex justify-center items-center origin-top-left w-[500%] h-[500%]"
|
||||
style={{ transform: 'scale(0.2) translateZ(0)', backfaceVisibility: 'hidden' }}
|
||||
>
|
||||
<LayoutComponent data={layout.sampleData} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{typeof selectedTemplate !== 'string' && selectedTemplate?.id === template.id && (
|
||||
<div className="absolute top-0 right-0 bg-blue-500 text-white px-2 py-1 rounded-bl-lg">
|
||||
Selected
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-[30px] mb-4">
|
||||
{/* Custom AI Templates */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Custom AI Templates</h3>
|
||||
<h3 className="text-base font-semibold text-gray-900 font-syne">Custom</h3>
|
||||
</div>
|
||||
{customTemplateCards}
|
||||
</div>
|
||||
{/* In Built Templates */}
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-3 font-syne">In Built</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
{builtInTemplateCards}
|
||||
</div>
|
||||
{customLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
|
||||
<span className="ml-3 text-gray-600">Loading custom templates...</span>
|
||||
</div>
|
||||
) : customTemplates.length === 0 ? (
|
||||
<Card className="p-8 text-center">
|
||||
<p className="text-gray-500">No custom templates yet.</p>
|
||||
<p className="text-sm text-gray-400 mt-2">
|
||||
Custom templates you create will appear here.
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{customTemplates.map((template: CustomTemplates) => (
|
||||
|
||||
<CustomTemplateCard
|
||||
key={template.id}
|
||||
template={template}
|
||||
onSelectTemplate={onSelectTemplate}
|
||||
selectedTemplate={typeof selectedTemplate === 'string' ? selectedTemplate : null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
TemplateSelection.displayName = 'TemplateSelection';
|
||||
|
||||
export default TemplateSelection;
|
||||
export default TemplateSelection;
|
||||
|
|
|
|||
|
|
@ -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,7 +4,6 @@ import { toast } from "sonner";
|
|||
import { setOutlines } from "@/store/slices/presentationGeneration";
|
||||
import { jsonrepair } from "jsonrepair";
|
||||
import { RootState } from "@/store/store";
|
||||
import { getApiUrl } from "@/utils/api";
|
||||
|
||||
|
||||
|
||||
|
|
@ -30,7 +29,7 @@ export const useOutlineStreaming = (presentationId: string | null) => {
|
|||
setIsLoading(true)
|
||||
try {
|
||||
eventSource = new EventSource(
|
||||
getApiUrl(`api/v1/ppt/outlines/stream/${presentationId}`)
|
||||
`/api/v1/ppt/outlines/stream/${presentationId}`
|
||||
);
|
||||
|
||||
eventSource.addEventListener("response", (event) => {
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ export const usePresentationGeneration = (
|
|||
layout = {
|
||||
name: selectedTemplate.id,
|
||||
ordered: false,
|
||||
slides: selectedTemplate.layouts.map((layoutItem) => ({
|
||||
slides: selectedTemplate.layouts.map((layoutItem: any) => ({
|
||||
id: layoutItem.layoutId,
|
||||
name: layoutItem.layoutName,
|
||||
description: layoutItem.layoutDescription,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react'
|
||||
import Header from '@/app/(presentation-generator)/dashboard/components/Header'
|
||||
import Header from '@/app/(presentation-generator)/(dashboard)/dashboard/components/Header'
|
||||
import { Metadata } from 'next'
|
||||
import OutlinePage from './components/OutlinePage'
|
||||
export const metadata: Metadata = {
|
||||
|
|
@ -25,6 +25,15 @@ const page = () => {
|
|||
return (
|
||||
<div className='relative min-h-screen'>
|
||||
<Header />
|
||||
<div
|
||||
className='fixed z-[-10] bottom-5 left-1/2 -translate-x-1/2 w-full h-full'
|
||||
style={{
|
||||
height: "341px",
|
||||
width: "86%",
|
||||
borderRadius: '1440px',
|
||||
background: 'radial-gradient(5.92% 104.69% at 50% 100%, rgba(122, 90, 248, 0.00) 0%, rgba(255, 255, 255, 0.00) 100%), radial-gradient(50% 50% at 50% 50%, rgba(122, 90, 248, 0.80) 0%, rgba(122, 90, 248, 0.00) 100%)',
|
||||
}}
|
||||
/>
|
||||
<OutlinePage />
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,10 +10,11 @@ import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
|
|||
import { AlertCircle } from "lucide-react";
|
||||
import { setPresentationData } from "@/store/slices/presentationGeneration";
|
||||
import { DashboardApi } from "../services/api/dashboard";
|
||||
import { setupImageUrlConverter } from "@/utils/image-url-converter";
|
||||
|
||||
|
||||
import { V1ContentRender } from "../components/V1ContentRender";
|
||||
import { useFontLoader } from "../hooks/useFontLoad";
|
||||
import { Theme } from "../services/api/types";
|
||||
|
||||
|
||||
|
||||
|
|
@ -42,13 +43,6 @@ const PresentationPage = ({ presentation_id }: { presentation_id: string }) => {
|
|||
}
|
||||
}
|
||||
}, [presentationData]);
|
||||
|
||||
// Setup image URL converter for Docker/browser compatibility
|
||||
useEffect(() => {
|
||||
const observer = setupImageUrlConverter();
|
||||
return () => observer?.disconnect();
|
||||
}, []);
|
||||
|
||||
// Function to fetch the slides
|
||||
useEffect(() => {
|
||||
fetchUserSlides();
|
||||
|
|
@ -60,6 +54,9 @@ const PresentationPage = ({ presentation_id }: { presentation_id: string }) => {
|
|||
const data = await DashboardApi.getPresentation(presentation_id);
|
||||
dispatch(setPresentationData(data));
|
||||
setContentLoading(false);
|
||||
if (data?.theme) {
|
||||
applyTheme(data.theme);
|
||||
}
|
||||
} catch (error) {
|
||||
setError(true);
|
||||
toast.error("Failed to load presentation");
|
||||
|
|
@ -68,6 +65,43 @@ const PresentationPage = ({ presentation_id }: { presentation_id: string }) => {
|
|||
}
|
||||
};
|
||||
|
||||
const applyTheme = async (theme: Theme) => {
|
||||
const element = document.getElementById('presentation-slides-wrapper')
|
||||
if (!element) return;
|
||||
if (!theme || !theme.data) { return; }
|
||||
if (!theme.data.colors['graph_0']) { return; }
|
||||
const cssVariables = {
|
||||
'--primary-color': theme.data.colors['primary'],
|
||||
'--background-color': theme.data.colors['background'],
|
||||
'--card-color': theme.data.colors['card'],
|
||||
'--stroke': theme.data.colors['stroke'],
|
||||
'--primary-text': theme.data.colors['primary_text'],
|
||||
'--background-text': theme.data.colors['background_text'],
|
||||
'--graph-0': theme.data.colors['graph_0'],
|
||||
'--graph-1': theme.data.colors['graph_1'],
|
||||
'--graph-2': theme.data.colors['graph_2'],
|
||||
'--graph-3': theme.data.colors['graph_3'],
|
||||
'--graph-4': theme.data.colors['graph_4'],
|
||||
'--graph-5': theme.data.colors['graph_5'],
|
||||
'--graph-6': theme.data.colors['graph_6'],
|
||||
'--graph-7': theme.data.colors['graph_7'],
|
||||
'--graph-8': theme.data.colors['graph_8'],
|
||||
'--graph-9': theme.data.colors['graph_9'],
|
||||
}
|
||||
|
||||
Object.entries(cssVariables).forEach(([key, value]) => {
|
||||
element.style.setProperty(key, value)
|
||||
})
|
||||
useFontLoader({ [theme.data.fonts.textFont.name]: theme.data.fonts.textFont.url })
|
||||
|
||||
// Apply fonts to preview container
|
||||
element.style.setProperty('font-family', `"${theme.data.fonts.textFont.name}"`)
|
||||
element.style.setProperty('--heading-font-family', `"${theme.data.fonts.textFont.name}"`)
|
||||
element.style.setProperty('--body-font-family', `"${theme.data.fonts.textFont.name}"`)
|
||||
// Update the Presentation content with theme
|
||||
}
|
||||
|
||||
|
||||
// Regular view
|
||||
return (
|
||||
<div className="flex overflow-hidden flex-col">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,135 @@
|
|||
'use client'
|
||||
import React, { useEffect, useState, memo, useCallback } from "react";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { addNewSlide } from "@/store/slices/presentationGeneration";
|
||||
import { Loader2, X } from "lucide-react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { toast } from 'sonner';
|
||||
import { getCustomTemplateDetails } from "@/app/hooks/useCustomTemplates";
|
||||
import { getTemplatesByTemplateName } from "@/app/presentation-templates";
|
||||
|
||||
interface LayoutItemProps {
|
||||
layout: any;
|
||||
onSelect: (sampleData: any, layoutId: string) => void;
|
||||
}
|
||||
|
||||
const LayoutItem = memo(({ layout, onSelect }: LayoutItemProps) => {
|
||||
const { component: LayoutComponent, sampleData, layoutId } = layout;
|
||||
return (
|
||||
<div
|
||||
onClick={() => onSelect(sampleData, layoutId)}
|
||||
className="relative cursor-pointer overflow-hidden aspect-video"
|
||||
>
|
||||
<div className="absolute cursor-pointer bg-transparent z-40 top-0 left-0 w-full h-full" />
|
||||
<div className="transform scale-[0.2] flex justify-center items-center origin-top-left w-[500%] h-[500%]">
|
||||
<LayoutComponent data={sampleData} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
LayoutItem.displayName = 'LayoutItem';
|
||||
interface NewSlideV1Props {
|
||||
setShowNewSlideSelection: (show: boolean) => void;
|
||||
templateID: string;
|
||||
index: number;
|
||||
presentationId: string;
|
||||
}
|
||||
const NewSlideV1 = ({
|
||||
setShowNewSlideSelection,
|
||||
templateID,
|
||||
index,
|
||||
presentationId,
|
||||
}: NewSlideV1Props) => {
|
||||
const dispatch = useDispatch();
|
||||
const [layouts, setLayouts] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const isCustomTemplate = templateID.startsWith("custom-");
|
||||
const handleNewSlide = useCallback((sampleData: any, id: string) => {
|
||||
try {
|
||||
const newSlide = {
|
||||
id: uuidv4(),
|
||||
index: index,
|
||||
content: sampleData,
|
||||
layout_group: templateID,
|
||||
layout: isCustomTemplate ? `${templateID}:${id}` : id,
|
||||
presentation: presentationId,
|
||||
};
|
||||
dispatch(addNewSlide({ slideData: newSlide, index }));
|
||||
setShowNewSlideSelection(false);
|
||||
} catch (error: any) {
|
||||
console.error(error);
|
||||
toast.error("Error adding new slide");
|
||||
}
|
||||
}, [index, templateID, presentationId, dispatch, setShowNewSlideSelection]);
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (layouts.length > 0 || loading) return;
|
||||
|
||||
const fetchLayouts = async () => {
|
||||
|
||||
if (isCustomTemplate) {
|
||||
setLoading(true);
|
||||
const customTemplateId = templateID.split("custom-")[1];
|
||||
const templateDetails = await getCustomTemplateDetails(customTemplateId, "Custom Template", "User-created template");
|
||||
setLayouts(templateDetails?.layouts || []);
|
||||
setLoading(false);
|
||||
} else {
|
||||
setLoading(true);
|
||||
const templateDetails = getTemplatesByTemplateName(templateID);
|
||||
setLayouts(templateDetails || []);
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchLayouts();
|
||||
|
||||
}, []);
|
||||
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="my-6 w-full bg-gray-50 p-8 max-w-[1280px]">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h2 className="text-2xl font-semibold">Select a Slide Layout</h2>
|
||||
<X
|
||||
onClick={() => setShowNewSlideSelection(false)}
|
||||
className="text-gray-500 text-2xl cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-gray-500" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="my-6 w-full bg-gray-50 p-8 max-w-[1280px]">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h2 className="text-2xl font-semibold">Select a Slide Layout</h2>
|
||||
<X
|
||||
onClick={() => setShowNewSlideSelection(false)}
|
||||
className="text-gray-500 text-2xl cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
{layouts.map((layout: any) => (
|
||||
<LayoutItem
|
||||
key={layout.layoutId}
|
||||
layout={layout}
|
||||
onSelect={handleNewSlide}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewSlideV1;
|
||||
|
||||
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,318 @@
|
|||
"use client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Play,
|
||||
Loader2,
|
||||
Redo2,
|
||||
Undo2,
|
||||
RotateCcw,
|
||||
ArrowRightFromLine,
|
||||
|
||||
ArrowUpRight,
|
||||
|
||||
} from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useRouter, usePathname } from "next/navigation";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { PresentationGenerationApi } from "../../services/api/presentation-generation";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
|
||||
|
||||
import { RootState } from "@/store/store";
|
||||
import { toast } from "sonner";
|
||||
|
||||
|
||||
import { PptxPresentationModel } from "@/types/pptx_models";
|
||||
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
|
||||
import { usePresentationUndoRedo } from "../hooks/PresentationUndoRedo";
|
||||
import ToolTip from "@/components/ToolTip";
|
||||
import { clearPresentationData } from "@/store/slices/presentationGeneration";
|
||||
import { clearHistory } from "@/store/slices/undoRedoSlice";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import ThemeSelector from "./ThemeSelector";
|
||||
import { DEFAULT_THEMES } from "../../(dashboard)/theme/components/ThemePanel/constants";
|
||||
import ThemeApi from "../../services/api/theme";
|
||||
import { Theme } from "../../services/api/types";
|
||||
import MarkdownRenderer from "@/components/MarkDownRender";
|
||||
|
||||
const PresentationHeader = ({
|
||||
presentation_id,
|
||||
isPresentationSaving,
|
||||
currentSlide,
|
||||
}: {
|
||||
presentation_id: string;
|
||||
isPresentationSaving: boolean;
|
||||
currentSlide?: number;
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [themes, setThemes] = useState<Theme[]>([]);
|
||||
|
||||
const pathname = usePathname();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
|
||||
const { presentationData, isStreaming } = useSelector(
|
||||
(state: RootState) => state.presentationGeneration
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
const [customThemes] = await Promise.all([
|
||||
ThemeApi.getThemes(),
|
||||
]);
|
||||
setThemes([...customThemes, ...DEFAULT_THEMES]);
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || "Failed to load themes");
|
||||
}
|
||||
};
|
||||
if (themes.length === 0) {
|
||||
load();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const { onUndo, onRedo, canUndo, canRedo } = usePresentationUndoRedo();
|
||||
|
||||
const get_presentation_pptx_model = async (id: string): Promise<PptxPresentationModel> => {
|
||||
const response = await fetch(`/api/presentation_to_pptx_model?id=${id}`);
|
||||
const pptx_model = await response.json();
|
||||
return pptx_model;
|
||||
};
|
||||
|
||||
const exportViaIpc = async (format: "pptx" | "pdf"): Promise<boolean> => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
if (!(window as any).electron?.exportPresentation) return false;
|
||||
trackEvent(
|
||||
format === "pptx"
|
||||
? MixpanelEvent.Header_ExportAsPPTX_API_Call
|
||||
: MixpanelEvent.Header_ExportAsPDF_API_Call
|
||||
);
|
||||
const result = await (window as any).electron.exportPresentation(
|
||||
presentation_id,
|
||||
presentationData?.title || 'presentation',
|
||||
format
|
||||
);
|
||||
if (!result?.success) {
|
||||
throw new Error(result?.message || 'Export failed');
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleExportPptx = async () => {
|
||||
if (isStreaming) return;
|
||||
|
||||
try {
|
||||
setIsExporting(true);
|
||||
// Save the presentation data before exporting
|
||||
trackEvent(MixpanelEvent.Header_UpdatePresentationContent_API_Call);
|
||||
await PresentationGenerationApi.updatePresentationContent(presentationData);
|
||||
|
||||
if (await exportViaIpc("pptx")) {
|
||||
toast.success("PPTX exported successfully!");
|
||||
return;
|
||||
}
|
||||
|
||||
trackEvent(MixpanelEvent.Header_GetPptxModel_API_Call);
|
||||
const pptx_model = await get_presentation_pptx_model(presentation_id);
|
||||
if (!pptx_model) {
|
||||
throw new Error("Failed to get presentation PPTX model");
|
||||
}
|
||||
trackEvent(MixpanelEvent.Header_ExportAsPPTX_API_Call);
|
||||
const pptx_path = await PresentationGenerationApi.exportAsPPTX(pptx_model);
|
||||
if (pptx_path) {
|
||||
// window.open(pptx_path, '_self');
|
||||
downloadLink(pptx_path);
|
||||
} else {
|
||||
throw new Error("No path returned from export");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Export failed:", error);
|
||||
toast.error("Having trouble exporting!", {
|
||||
description:
|
||||
"We are having trouble exporting your presentation. Please try again.",
|
||||
});
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleExportPdf = async () => {
|
||||
if (isStreaming) return;
|
||||
|
||||
try {
|
||||
setIsExporting(true);
|
||||
// Save the presentation data before exporting
|
||||
trackEvent(MixpanelEvent.Header_UpdatePresentationContent_API_Call);
|
||||
await PresentationGenerationApi.updatePresentationContent(presentationData);
|
||||
|
||||
trackEvent(MixpanelEvent.Header_ExportAsPDF_API_Call);
|
||||
if (await exportViaIpc("pdf")) {
|
||||
toast.success("PDF exported successfully!");
|
||||
return;
|
||||
}
|
||||
const response = await fetch('/api/export-as-pdf', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
id: presentation_id,
|
||||
title: presentationData?.title,
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const { path: pdfPath } = await response.json();
|
||||
// window.open(pdfPath, '_blank');
|
||||
downloadLink(pdfPath);
|
||||
} else {
|
||||
throw new Error("Failed to export PDF");
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
toast.error("Having trouble exporting!", {
|
||||
description:
|
||||
"We are having trouble exporting your presentation. Please try again.",
|
||||
});
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
const handleReGenerate = () => {
|
||||
dispatch(clearPresentationData());
|
||||
dispatch(clearHistory())
|
||||
trackEvent(MixpanelEvent.Header_ReGenerate_Button_Clicked, { pathname });
|
||||
router.push(`/presentation?id=${presentation_id}&stream=true`);
|
||||
};
|
||||
const downloadLink = (path: string) => {
|
||||
// if we have popup access give direct download if not redirect to the path
|
||||
if (window.opener) {
|
||||
window.open(path, '_blank');
|
||||
} else {
|
||||
const link = document.createElement('a');
|
||||
link.href = path;
|
||||
link.download = path.split('/').pop() || 'download';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
}
|
||||
};
|
||||
|
||||
const ExportOptions = ({ mobile }: { mobile: boolean }) => (
|
||||
<div className={` rounded-[18px] max-md:mt-4 ${mobile ? "" : "bg-white"} p-5`}>
|
||||
<p className="text-sm font-medium text-[#19001F]">Export as</p>
|
||||
<div className="my-[18px] h-[1px] bg-[#E8E8E8]" />
|
||||
<div className="space-y-3">
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
trackEvent(MixpanelEvent.Header_Export_PDF_Button_Clicked, { pathname });
|
||||
handleExportPdf();
|
||||
setOpen(false);
|
||||
}}
|
||||
variant="ghost"
|
||||
className={` rounded-none px-0 w-full text-xs flex justify-start text-black hover:bg-transparent ${mobile ? "bg-white py-6 border-none rounded-lg" : ""}`} >
|
||||
|
||||
PDF
|
||||
<ArrowUpRight className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
trackEvent(MixpanelEvent.Header_Export_PPTX_Button_Clicked, { pathname });
|
||||
handleExportPptx();
|
||||
setOpen(false);
|
||||
}}
|
||||
variant="ghost"
|
||||
className={`w-full flex px-0 justify-start text-xs text-black hover:bg-transparent ${mobile ? "bg-white py-6" : ""}`}
|
||||
>
|
||||
|
||||
PPTX
|
||||
<ArrowUpRight className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="py-7 sticky top-0 bg-white z-50 mb-[17px] font-syne flex justify-between items-center">
|
||||
<h2 className="text-lg text-[#101323] font-unbounded "><MarkdownRenderer content={presentationData?.title || "Presentation"} className="mb-0 w-[600px] truncate text-sm text-[#101323] " /></h2>
|
||||
<div className="flex items-center gap-2.5">
|
||||
|
||||
{isPresentationSaving && <div className="flex items-center gap-2">
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
</div>}
|
||||
<ThemeSelector presentation_id={presentation_id} current_theme={presentationData?.theme || {}} themes={themes} />
|
||||
|
||||
<div className="flex items-center gap-2 bg-[#F6F6F9] px-3.5 h-[38px] border border-[#EDECEC] rounded-[80px]">
|
||||
|
||||
<ToolTip content="Regenerate Presentation">
|
||||
<button onClick={handleReGenerate} className="group">
|
||||
<RotateCcw className="w-3.5 h-3.5 text-[#101323] group-hover:text-[#5141e5] duration-300" />
|
||||
</button>
|
||||
</ToolTip>
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<ToolTip content="Undo">
|
||||
<button disabled={!canUndo} className=" disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer group" onClick={() => {
|
||||
onUndo();
|
||||
}}>
|
||||
|
||||
<Undo2 className="w-3.5 h-3.5 text-[#101323] group-hover:text-[#5141e5] duration-300" />
|
||||
|
||||
</button>
|
||||
</ToolTip>
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<ToolTip content="Redo">
|
||||
|
||||
<button disabled={!canRedo} className=" disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer group" onClick={() => {
|
||||
|
||||
onRedo();
|
||||
}}>
|
||||
<Redo2 className="w-3.5 h-3.5 text-[#101323] group-hover:text-[#5141e5] duration-300" />
|
||||
|
||||
</button>
|
||||
</ToolTip>
|
||||
<Separator orientation="vertical" className="h-4 w-[2px]" />
|
||||
<ToolTip content="Present">
|
||||
<button
|
||||
onClick={() => {
|
||||
const to = `?id=${presentation_id}&mode=present&slide=${currentSlide || 0}`;
|
||||
trackEvent(MixpanelEvent.Navigation, { from: pathname, to });
|
||||
router.push(to);
|
||||
}}
|
||||
disabled={!presentationData?.slides || presentationData?.slides.length === 0} className="cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed group">
|
||||
<Play className="w-3.5 h-3.5 text-[#101323] group-hover:text-[#5141e5] duration-300" />
|
||||
</button>
|
||||
</ToolTip>
|
||||
</div>
|
||||
|
||||
<Popover open={open} onOpenChange={setOpen} >
|
||||
<PopoverTrigger asChild>
|
||||
<button className="flex items-center gap-[7px] px-[18px] py-[11px] rounded-[53px] text-sm font-semibold text-[#101323]"
|
||||
style={{
|
||||
background: "linear-gradient(270deg, #D5CAFC 2.4%, #E3D2EB 27.88%, #F4DCD3 69.23%, #FDE4C2 100%)",
|
||||
}}
|
||||
disabled={isExporting}
|
||||
>
|
||||
{isExporting ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : "Export"} <ArrowRightFromLine className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className="w-[200px] rounded-[18px] space-y-2 p-0 ">
|
||||
<ExportOptions mobile={false} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PresentationHeader;
|
||||
|
|
@ -0,0 +1,306 @@
|
|||
"use client";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
X,
|
||||
Minimize2,
|
||||
Maximize2,
|
||||
StickyNote,
|
||||
EyeOff,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Slide } from "../../types/slide";
|
||||
import { V1ContentRender } from "../../components/V1ContentRender";
|
||||
|
||||
|
||||
|
||||
interface PresentationModeProps {
|
||||
slides: Slide[];
|
||||
currentSlide: number;
|
||||
|
||||
isFullscreen: boolean;
|
||||
onFullscreenToggle: () => void;
|
||||
onExit: () => void;
|
||||
onSlideChange: (slideNumber: number) => void;
|
||||
}
|
||||
|
||||
const PresentationMode: React.FC<PresentationModeProps> = ({
|
||||
|
||||
slides,
|
||||
currentSlide,
|
||||
|
||||
isFullscreen,
|
||||
onFullscreenToggle,
|
||||
onExit,
|
||||
onSlideChange,
|
||||
|
||||
|
||||
}) => {
|
||||
if (slides === undefined || slides === null || slides.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [showSpeakerNotes, setShowSpeakerNotes] = useState(true);
|
||||
const currentSpeakerNote = useMemo(
|
||||
() => slides[currentSlide]?.speaker_note?.trim() || "",
|
||||
[slides, currentSlide]
|
||||
);
|
||||
|
||||
|
||||
const recomputeScale = useCallback(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const padding = isFullscreen ? 0 : 64; // match p-8 when not fullscreen
|
||||
const fullscreenMargin = isFullscreen ? 16 : 0; // small safety margin to prevent clipping
|
||||
const availableWidth = Math.max(window.innerWidth - padding - fullscreenMargin, 0);
|
||||
const availableHeight = Math.max(window.innerHeight - padding - fullscreenMargin, 0);
|
||||
const baseW = 1280;
|
||||
const baseH = 720;
|
||||
const s = Math.min(availableWidth / baseW, availableHeight / baseH);
|
||||
|
||||
}, [isFullscreen]);
|
||||
|
||||
useEffect(() => {
|
||||
recomputeScale();
|
||||
window.addEventListener("resize", recomputeScale);
|
||||
return () => window.removeEventListener("resize", recomputeScale);
|
||||
}, [recomputeScale]);
|
||||
|
||||
|
||||
// Modify the handleKeyPress to prevent default behavior
|
||||
const handleKeyPress = useCallback(
|
||||
(event: KeyboardEvent) => {
|
||||
event.preventDefault(); // Prevent default scroll behavior
|
||||
|
||||
switch (event.key) {
|
||||
case "ArrowRight":
|
||||
case "ArrowDown":
|
||||
case " ": // Space key
|
||||
if (currentSlide < slides.length - 1) {
|
||||
onSlideChange(currentSlide + 1);
|
||||
}
|
||||
break;
|
||||
case "ArrowLeft":
|
||||
case "ArrowUp":
|
||||
if (currentSlide > 0) {
|
||||
onSlideChange(currentSlide - 1);
|
||||
}
|
||||
break;
|
||||
case "Escape":
|
||||
// If fullscreen is active, only exit fullscreen on first ESC. Second ESC exits present mode.
|
||||
if (document.fullscreenElement) {
|
||||
try { document.exitFullscreen(); } catch (_) { }
|
||||
return;
|
||||
}
|
||||
onExit();
|
||||
break;
|
||||
case "f":
|
||||
case "F":
|
||||
onFullscreenToggle();
|
||||
break;
|
||||
case "n":
|
||||
case "N":
|
||||
setShowSpeakerNotes((prev) => !prev);
|
||||
break;
|
||||
}
|
||||
},
|
||||
[currentSlide, slides.length, onSlideChange, onExit, onFullscreenToggle, isFullscreen]
|
||||
);
|
||||
|
||||
// Add both keydown and keyup listeners
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Prevent default behavior for arrow keys and space
|
||||
if (
|
||||
["ArrowRight", "ArrowLeft", "ArrowUp", "ArrowDown", " "].includes(e.key)
|
||||
) {
|
||||
e.preventDefault();
|
||||
}
|
||||
handleKeyPress(e);
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [handleKeyPress]);
|
||||
|
||||
// Add click handlers for the slide area
|
||||
const handleSlideClick = (e: React.MouseEvent) => {
|
||||
// Don't trigger navigation if clicking on controls
|
||||
if ((e.target as HTMLElement).closest(".presentation-controls")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clickX = e.clientX;
|
||||
const windowWidth = window.innerWidth;
|
||||
|
||||
if (clickX < windowWidth / 3) {
|
||||
if (currentSlide > 0) {
|
||||
onSlideChange(currentSlide - 1);
|
||||
}
|
||||
} else if (clickX > (windowWidth * 2) / 3) {
|
||||
if (currentSlide < slides.length - 1) {
|
||||
onSlideChange(currentSlide + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle Escape key separately
|
||||
useEffect(() => {
|
||||
const handleEscKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && isFullscreen) {
|
||||
onFullscreenToggle(); // Just toggle fullscreen, don't exit presentation
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleEscKey);
|
||||
return () => document.removeEventListener("keydown", handleEscKey);
|
||||
}, [isFullscreen, onFullscreenToggle]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex flex-col"
|
||||
style={{ backgroundColor: "var(--page-background-color,#c8c7c9)" }}
|
||||
tabIndex={0}
|
||||
onClick={handleSlideClick}
|
||||
>
|
||||
{/* Controls - Only show when not in fullscreen */}
|
||||
{!isFullscreen && (
|
||||
<>
|
||||
<div className="presentation-controls absolute top-4 right-4 flex items-center gap-2 z-50">
|
||||
<Button
|
||||
variant="ghost"
|
||||
style={{ color: "var(--text-body-color,#000000)" }}
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onFullscreenToggle();
|
||||
}}
|
||||
className="text-white hover:bg-white/20"
|
||||
>
|
||||
{isFullscreen ? (
|
||||
<Minimize2 className="h-5 w-5" />
|
||||
) : (
|
||||
<Maximize2 className="h-5 w-5" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
style={{ color: "var(--text-body-color,#000000)" }}
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onExit();
|
||||
}}
|
||||
className="text-white hover:bg-white/20"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="presentation-controls absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-4 z-50">
|
||||
<Button
|
||||
variant="ghost"
|
||||
style={{ color: "var(--text-body-color,#000000)" }}
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSlideChange(currentSlide - 1);
|
||||
}}
|
||||
disabled={currentSlide === 0}
|
||||
className="text-white hover:bg-white/20"
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" style={{ color: "var(--text-body-color,#000000)" }} />
|
||||
</Button>
|
||||
<span className="text-white"
|
||||
style={{ color: "var(--text-body-color,#000000)" }}
|
||||
>
|
||||
{currentSlide + 1} / {slides.length}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
style={{ color: "var(--text-body-color,#000000)" }}
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSlideChange(currentSlide + 1);
|
||||
}}
|
||||
disabled={currentSlide === slides.length - 1}
|
||||
className="text-white hover:bg-white/20"
|
||||
>
|
||||
<ChevronRight className="h-5 w-5" style={{ color: "var(--text-body-color,#000000)" }} />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Centered 16:9 stage for consistent alignment in normal + fullscreen modes */}
|
||||
<div className={`flex-1 min-h-0 flex items-center justify-center ${isFullscreen ? "px-6 py-8 md:px-10 md:py-12" : "p-8"}`}>
|
||||
<div
|
||||
className="relative rounded-sm font-inter"
|
||||
style={{
|
||||
aspectRatio: "16 / 9",
|
||||
width: isFullscreen
|
||||
? "min(90vw, calc(88vh * 16 / 9))"
|
||||
: "min(calc(100vw - 4rem), calc((100vh - 4rem) * 16 / 9))",
|
||||
maxHeight: isFullscreen ? "88vh" : "calc(100vh - 4rem)",
|
||||
}}
|
||||
>
|
||||
{slides.length > 0 && slides.map((slide, index) => (
|
||||
<div
|
||||
key={slide.id}
|
||||
className={index === currentSlide ? "h-full w-full" : "hidden h-full w-full"}
|
||||
>
|
||||
<V1ContentRender slide={slide} isEditMode={true} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentSpeakerNote && (
|
||||
<div className="presentation-controls absolute bottom-4 right-4 z-50">
|
||||
{showSpeakerNotes ? (
|
||||
<div className="w-[360px] max-w-[50vw] rounded-xl border border-black/10 bg-white/95 shadow-xl backdrop-blur-sm">
|
||||
<div className="flex items-center justify-between border-b border-black/10 px-3 py-2">
|
||||
<div className="flex items-center gap-2 text-sm font-medium text-gray-800">
|
||||
<StickyNote className="h-4 w-4" />
|
||||
Speaker notes
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowSpeakerNotes(false);
|
||||
}}
|
||||
className="h-8 px-2 text-gray-600 hover:bg-black/5 hover:text-gray-800"
|
||||
>
|
||||
<EyeOff className="mr-1 h-4 w-4" />
|
||||
Hide
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-[28vh] overflow-auto whitespace-pre-wrap px-3 py-2 text-sm text-gray-700">
|
||||
{currentSpeakerNote}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowSpeakerNotes(true);
|
||||
}}
|
||||
className="h-9 rounded-full border border-black/10 bg-white/95 px-3 text-gray-800 shadow-md hover:bg-white"
|
||||
>
|
||||
<StickyNote className="mr-2 h-4 w-4" />
|
||||
Show notes
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PresentationMode;
|
||||
|
|
@ -1,17 +1,15 @@
|
|||
"use client";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useState } from "react";
|
||||
import { useSelector } from "react-redux";
|
||||
import { RootState } from "@/store/store";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import PresentationMode from "../../components/PresentationMode";
|
||||
import PresentationMode from "./PresentationMode";
|
||||
import SidePanel from "./SidePanel";
|
||||
import SlideContent from "./SlideContent";
|
||||
import Header from "./Header";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
|
||||
import { AlertCircle, Loader2 } from "lucide-react";
|
||||
import Help from "./Help";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import {
|
||||
usePresentationStreaming,
|
||||
usePresentationData,
|
||||
|
|
@ -20,10 +18,10 @@ import {
|
|||
} from "../hooks";
|
||||
import { PresentationPageProps } from "../types";
|
||||
import LoadingState from "./LoadingState";
|
||||
import { setupImageUrlConverter } from "@/utils/image-url-converter";
|
||||
|
||||
import { useFontLoader } from "../../hooks/useFontLoader";
|
||||
import { usePresentationUndoRedo } from "../hooks/PresentationUndoRedo";
|
||||
import PresentationHeader from "./PresentationHeader";
|
||||
|
||||
const PresentationPage: React.FC<PresentationPageProps> = ({
|
||||
presentation_id,
|
||||
}) => {
|
||||
|
|
@ -33,13 +31,6 @@ const PresentationPage: React.FC<PresentationPageProps> = ({
|
|||
const [selectedSlide, setSelectedSlide] = useState(0);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
const [error, setError] = useState(false);
|
||||
const [isMobilePanelOpen, setIsMobilePanelOpen] = useState(false);
|
||||
|
||||
// Setup image URL converter for Docker/browser compatibility
|
||||
useEffect(() => {
|
||||
const observer = setupImageUrlConverter();
|
||||
return () => observer?.disconnect();
|
||||
}, []);
|
||||
|
||||
|
||||
const { presentationData, isStreaming } = useSelector(
|
||||
|
|
@ -88,7 +79,6 @@ const PresentationPage: React.FC<PresentationPageProps> = ({
|
|||
handleSlideChange(newSlide, presentationData);
|
||||
};
|
||||
|
||||
|
||||
// useEffect(() => {
|
||||
// if(!loading && !isStreaming && presentationData?.slides && presentationData?.slides.length > 0){
|
||||
// const presentation_id = presentationData?.slides[0].layout.split(":")[0].split("custom-")[1];
|
||||
|
|
@ -113,7 +103,7 @@ const PresentationPage: React.FC<PresentationPageProps> = ({
|
|||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-screen bg-gray-100">
|
||||
<div className="flex flex-col items-center justify-center h-screen bg-gray-100 font-syne">
|
||||
<div
|
||||
className="bg-white border border-red-300 text-red-700 px-6 py-8 rounded-lg shadow-lg flex flex-col items-center"
|
||||
role="alert"
|
||||
|
|
@ -130,63 +120,65 @@ const PresentationPage: React.FC<PresentationPageProps> = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen flex overflow-hidden flex-col">
|
||||
<div className="fixed right-6 top-[5.2rem] z-50">
|
||||
{isSaving && <Loader2 className="w-6 h-6 animate-spin text-blue-500" />}
|
||||
</div>
|
||||
|
||||
<Header presentation_id={presentation_id} currentSlide={selectedSlide} />
|
||||
<Help />
|
||||
|
||||
<div className="h-screen overflow-hidden font-syne ">
|
||||
<div
|
||||
style={{
|
||||
background: "#c8c7c9",
|
||||
background: "#ffffff",
|
||||
}}
|
||||
className="flex flex-1 relative pt-6"
|
||||
className="flex gap-6 relative "
|
||||
>
|
||||
<SidePanel
|
||||
selectedSlide={selectedSlide}
|
||||
onSlideClick={handleSlideClick}
|
||||
loading={loading}
|
||||
isMobilePanelOpen={isMobilePanelOpen}
|
||||
setIsMobilePanelOpen={setIsMobilePanelOpen}
|
||||
/>
|
||||
<div className="w-[200px]">
|
||||
<SidePanel
|
||||
selectedSlide={selectedSlide}
|
||||
onSlideClick={handleSlideClick}
|
||||
presentationId={presentation_id}
|
||||
loading={loading}
|
||||
|
||||
<div className="flex-1 h-[calc(100vh-100px)] overflow-y-auto">
|
||||
/>
|
||||
</div>
|
||||
<div className=" w-full h-[calc(100vh-20px)] hide-scrollbar pr-[25px] overflow-y-auto">
|
||||
<PresentationHeader presentation_id={presentation_id} isPresentationSaving={isSaving} currentSlide={selectedSlide} />
|
||||
<div
|
||||
id="presentation-slides-wrapper"
|
||||
className="mx-auto flex flex-col items-center overflow-hidden justify-center p-2 sm:p-6 pt-0"
|
||||
style={{
|
||||
background: "rgba(255, 255, 255, 0.10)",
|
||||
boxShadow: "0 0 20.01px 0 rgba(122, 90, 248, 0.16) inset",
|
||||
}}
|
||||
className="p-6 rounded-[20px] flex flex-col items-center overflow-hidden justify-center border border-[#EDECEC] "
|
||||
>
|
||||
{!presentationData ||
|
||||
loading ||
|
||||
!presentationData?.slides ||
|
||||
presentationData?.slides.length === 0 ? (
|
||||
<div className="relative w-full h-[calc(100vh-120px)] mx-auto">
|
||||
<div className="">
|
||||
{Array.from({ length: 2 }).map((_, index) => (
|
||||
<Skeleton
|
||||
key={index}
|
||||
className="aspect-video bg-gray-400 my-4 w-full mx-auto max-w-[1280px]"
|
||||
/>
|
||||
))}
|
||||
<div className="w-full max-w-[1280px] h-full">
|
||||
|
||||
{!presentationData ||
|
||||
loading ||
|
||||
!presentationData?.slides ||
|
||||
presentationData?.slides.length === 0 ? (
|
||||
<div className="relative w-full h-[calc(100vh-120px)] mx-auto">
|
||||
<div className="">
|
||||
{Array.from({ length: 2 }).map((_, index) => (
|
||||
<Skeleton
|
||||
key={index}
|
||||
className="aspect-video bg-gray-400 my-4 w-full mx-auto "
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{stream && <LoadingState />}
|
||||
</div>
|
||||
{stream && <LoadingState />}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{presentationData &&
|
||||
presentationData.slides &&
|
||||
presentationData.slides.length > 0 &&
|
||||
presentationData.slides.map((slide: any, index: number) => (
|
||||
<SlideContent
|
||||
key={`${slide.type}-${index}-${slide.index}`}
|
||||
slide={slide}
|
||||
index={index}
|
||||
presentationId={presentation_id}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
) : (
|
||||
<>
|
||||
{presentationData &&
|
||||
presentationData.slides &&
|
||||
presentationData.slides.length > 0 &&
|
||||
presentationData.slides.map((slide: any, index: number) => (
|
||||
<SlideContent
|
||||
key={`${slide.type}-${index}-${slide.index}`}
|
||||
slide={slide}
|
||||
index={index}
|
||||
presentationId={presentation_id}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
"use client";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { LayoutList, ListTree, PanelRightOpen, X } from "lucide-react";
|
||||
import ToolTip from "@/components/ToolTip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import React, { useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
import { RootState } from "@/store/store";
|
||||
import {
|
||||
|
|
@ -21,26 +19,29 @@ import {
|
|||
} from "@dnd-kit/sortable";
|
||||
import { setPresentationData } from "@/store/slices/presentationGeneration";
|
||||
import { SortableSlide } from "./SortableSlide";
|
||||
import { SortableListItem } from "./SortableListItem";
|
||||
import SlideScale from "../../components/PresentationRender";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { useRouter } from "next/navigation";
|
||||
import NewSlide from "./NewSlide";
|
||||
|
||||
interface SidePanelProps {
|
||||
selectedSlide: number;
|
||||
onSlideClick: (index: number) => void;
|
||||
isMobilePanelOpen: boolean;
|
||||
setIsMobilePanelOpen: (value: boolean) => void;
|
||||
presentationId: string;
|
||||
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const SidePanel = ({
|
||||
selectedSlide,
|
||||
onSlideClick,
|
||||
isMobilePanelOpen,
|
||||
setIsMobilePanelOpen,
|
||||
presentationId,
|
||||
|
||||
loading,
|
||||
}: SidePanelProps) => {
|
||||
const [isOpen, setIsOpen] = useState(true);
|
||||
const [active, setActive] = useState<"list" | "grid">("grid");
|
||||
|
||||
const router = useRouter();
|
||||
const [showNewSlideSelection, setShowNewSlideSelection] = useState(false);
|
||||
|
||||
const { presentationData, isStreaming } = useSelector(
|
||||
(state: RootState) => state.presentationGeneration
|
||||
|
|
@ -48,13 +49,21 @@ const SidePanel = ({
|
|||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const lastSlideIndex = presentationData?.slides?.length
|
||||
? presentationData.slides.length - 1
|
||||
: 0;
|
||||
const lastSlideTemplateId = presentationData?.slides?.[lastSlideIndex]?.layout
|
||||
? presentationData.slides[lastSlideIndex].layout.split(":")[0]
|
||||
: "";
|
||||
|
||||
const handleAddSlideClick = () => {
|
||||
if (!presentationData?.slides?.length || isStreaming) return;
|
||||
setShowNewSlideSelection(true);
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (window.innerWidth < 768) {
|
||||
setIsOpen(isMobilePanelOpen);
|
||||
}
|
||||
}, [isMobilePanelOpen]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
|
|
@ -67,12 +76,7 @@ const SidePanel = ({
|
|||
})
|
||||
);
|
||||
|
||||
const handleClose = () => {
|
||||
setIsOpen(false);
|
||||
if (window.innerWidth < 768) {
|
||||
setIsMobilePanelOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleDragEnd = (event: any) => {
|
||||
const { active, over } = event;
|
||||
|
|
@ -119,196 +123,97 @@ const SidePanel = ({
|
|||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop Toggle Button - Always visible when panel is closed */}
|
||||
{!isOpen && (
|
||||
<div className="hidden xl:block fixed left-4 top-1/2 -translate-y-1/2 z-50">
|
||||
<ToolTip content="Open Panel">
|
||||
<Button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="bg-white hover:bg-gray-50 shadow-lg"
|
||||
>
|
||||
<PanelRightOpen className="text-black" size={20} />
|
||||
</Button>
|
||||
</ToolTip>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-[#F6F6F9] pt-8 px-4 w-[200px]">
|
||||
|
||||
{/* Mobile Toggle Button */}
|
||||
{!isMobilePanelOpen && (
|
||||
<div className="xl:hidden fixed left-4 bottom-4 z-50">
|
||||
<ToolTip content="Show Panel">
|
||||
<Button
|
||||
onClick={() => setIsMobilePanelOpen(true)}
|
||||
className="bg-[#5146E5] text-white p-3 rounded-full shadow-lg"
|
||||
>
|
||||
<PanelRightOpen className="text-white" size={20} />
|
||||
</Button>
|
||||
</ToolTip>
|
||||
</div>
|
||||
)}
|
||||
<img onClick={() => {
|
||||
router.push("/dashboard");
|
||||
}} src="/logo-with-bg.png" alt="" className="w-10 h-10 cursor-pointer object-contain" />
|
||||
|
||||
<Separator orientation="horizontal" className="my-6 " />
|
||||
<div
|
||||
className={`
|
||||
fixed xl:relative h-full z-50 xl:z-auto
|
||||
fixed xl:relative h-full z-50 xl:z-auto
|
||||
transition-all duration-300 ease-in-out
|
||||
${isOpen ? "ml-0" : "-ml-[300px]"}
|
||||
${isMobilePanelOpen
|
||||
? "translate-x-0"
|
||||
: "-translate-x-full xl:translate-x-0"
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div
|
||||
|
||||
className="min-w-[300px] bg-white max-w-[300px] h-[calc(100vh-120px)] rounded-[20px] hide-scrollbar overflow-hidden slide-theme shadow-xl"
|
||||
className="w-full h-[calc(100vh-120px)] hide-scrollbar overflow-hidden slide-theme "
|
||||
>
|
||||
<div
|
||||
className="sticky top-0 z-40 px-6 py-4"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center justify-start gap-4">
|
||||
<ToolTip content="Image Preview">
|
||||
<Button
|
||||
className={`${active === "grid"
|
||||
? "bg-[#5141e5] hover:bg-[#4638c7]"
|
||||
: "bg-white hover:bg-white"
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (!isStreaming) {
|
||||
setActive("grid")
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LayoutList
|
||||
className={`${active === "grid" ? "text-white" : "text-black"
|
||||
}`}
|
||||
size={20}
|
||||
/>
|
||||
</Button>
|
||||
</ToolTip>
|
||||
<ToolTip content="List Preview">
|
||||
<Button
|
||||
className={`${active === "list"
|
||||
? "bg-[#5141e5] hover:bg-[#4638c7]"
|
||||
: "bg-white hover:bg-white"
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (!isStreaming) {
|
||||
setActive("list")
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ListTree
|
||||
className={`${active === "list" ? "text-white" : "text-black"
|
||||
}`}
|
||||
size={20}
|
||||
/>
|
||||
</Button>
|
||||
</ToolTip>
|
||||
</div>
|
||||
<X
|
||||
onClick={handleClose}
|
||||
className="text-[#6c7081] cursor-pointer hover:text-gray-600"
|
||||
size={20}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xl font-normal pb-3.5 text-[#000000]">Slides</p>
|
||||
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{/* List Preview */}
|
||||
{active === "list" && (
|
||||
<div className="p-4 overflow-y-auto hide-scrollbar h-[calc(100%-100px)]">
|
||||
{isStreaming ? (
|
||||
presentationData &&
|
||||
presentationData?.slides.map((slide: any, index: number) => (
|
||||
<div
|
||||
key={`${index}-${slide.type}-${slide.id}`}
|
||||
className={`p-3 cursor-pointer rounded-lg slide-box`}
|
||||
>
|
||||
<span className="font-medium slide-title">
|
||||
Slide {index + 1}
|
||||
</span>
|
||||
<p className="text-sm slide-description">
|
||||
{slide.content.title}
|
||||
</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<SortableContext
|
||||
items={
|
||||
presentationData?.slides.map((slide: any) => slide.id!) || []
|
||||
}
|
||||
strategy={verticalListSortingStrategy}
|
||||
<div className=" overflow-y-auto hide-scrollbar h-[calc(100%-140px)] space-y-3.5">
|
||||
{isStreaming ? (
|
||||
presentationData &&
|
||||
presentationData?.slides.map((slide: any, index: number) => (
|
||||
<div
|
||||
key={`${slide.id}-${index}`}
|
||||
onClick={() => onSlideClick(index)}
|
||||
className={` cursor-pointer ring-2 rounded-[12px] transition-all duration-200 ${selectedSlide === index ? ' ring-[#5141e5]' : 'ring-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-2" id={`slide-${selectedSlide}`}>
|
||||
{presentationData &&
|
||||
presentationData?.slides.map((slide: any, index: number) => (
|
||||
<SortableListItem
|
||||
key={`${slide.id}-${index}`}
|
||||
slide={slide}
|
||||
index={index}
|
||||
selectedSlide={selectedSlide}
|
||||
onSlideClick={onSlideClick}
|
||||
/>
|
||||
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Grid Preview */}
|
||||
{active === "grid" && (
|
||||
<div className="p-4 overflow-y-auto hide-scrollbar h-[calc(100%-100px)] space-y-4">
|
||||
{isStreaming ? (
|
||||
presentationData &&
|
||||
presentationData?.slides.map((slide: any, index: number) => (
|
||||
<div
|
||||
key={`${slide.id}-${index}`}
|
||||
onClick={() => onSlideClick(index)}
|
||||
className={` cursor-pointer ring-2 p-1 rounded-md transition-all duration-200 ${selectedSlide === index ? ' ring-[#5141e5]' : 'ring-gray-200'
|
||||
}`}
|
||||
>
|
||||
<div className=" bg-white pointer-events-none relative overflow-hidden aspect-video">
|
||||
<div className="absolute bg-gray-100/5 z-50 top-0 left-0 w-full h-full" />
|
||||
<div className="transform scale-[0.2] flex justify-center items-center origin-top-left w-[500%] h-[500%]">
|
||||
<SlideScale slide={slide} />
|
||||
</div>
|
||||
<div className=" bg-white pointer-events-none relative overflow-hidden aspect-video">
|
||||
<div className="absolute bg-gray-100/5 z-50 top-0 left-0 w-full h-full" />
|
||||
<div className="transform scale-[0.2] flex justify-center items-center origin-top-left w-[500%] h-[500%]">
|
||||
<SlideScale slide={slide} />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<SortableContext
|
||||
items={
|
||||
presentationData?.slides.map((slide: any) => slide.id || `${slide.index}`) || []
|
||||
}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{presentationData &&
|
||||
presentationData?.slides.map((slide: any, index: number) => (
|
||||
<SortableSlide
|
||||
key={`${slide.id}-${index}`}
|
||||
slide={slide}
|
||||
index={index}
|
||||
selectedSlide={selectedSlide}
|
||||
onSlideClick={onSlideClick}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<SortableContext
|
||||
items={
|
||||
presentationData?.slides.map((slide: any) => slide.id || `${slide.index}`) || []
|
||||
}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{presentationData &&
|
||||
presentationData?.slides.map((slide: any, index: number) => (
|
||||
<SortableSlide
|
||||
key={`${slide.id}-${index}`}
|
||||
slide={slide}
|
||||
index={index}
|
||||
selectedSlide={selectedSlide}
|
||||
onSlideClick={onSlideClick}
|
||||
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
)}
|
||||
</div>
|
||||
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</DndContext>
|
||||
<Separator orientation="horizontal" className=" " />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddSlideClick}
|
||||
className="pt-6 gap-2 flex flex-col py-2 duration-300 items-center justify-center rounded-lg cursor-pointer mx-auto"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span className="text-[11px] font-normal text-[#000000]">Add Slide</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
{showNewSlideSelection && lastSlideTemplateId && (
|
||||
<div className="fixed inset-0 z-[60] bg-black/50 overflow-y-auto p-4">
|
||||
<div className="min-h-full flex items-start justify-center py-8">
|
||||
<NewSlide
|
||||
index={lastSlideIndex}
|
||||
templateID={lastSlideTemplateId}
|
||||
setShowNewSlideSelection={setShowNewSlideSelection}
|
||||
presentationId={presentationId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useEffect, useState, useMemo } from "react";
|
||||
import { Loader2, PlusIcon, Trash2, WandSparkles, StickyNote } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Loader2, PlusIcon, Trash2, Pencil, Trash } from "lucide-react";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
|
|
@ -18,9 +18,9 @@ import {
|
|||
} from "@/store/slices/presentationGeneration";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
|
||||
import NewSlide from "../../components/NewSlide";
|
||||
import { addToHistory } from "@/store/slices/undoRedoSlice";
|
||||
import { V1ContentRender } from "../../components/V1ContentRender";
|
||||
import NewSlide from "./NewSlide";
|
||||
|
||||
interface SlideContentProps {
|
||||
slide: any;
|
||||
|
|
@ -32,6 +32,9 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => {
|
|||
const dispatch = useDispatch();
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [showNewSlideSelection, setShowNewSlideSelection] = useState(false);
|
||||
const [isEditPopoverOpen, setIsEditPopoverOpen] = useState(false);
|
||||
const [isSpeakerPopoverOpen, setIsSpeakerPopoverOpen] = useState(false);
|
||||
const [editPrompt, setEditPrompt] = useState("");
|
||||
const { presentationData, isStreaming } = useSelector(
|
||||
(state: RootState) => state.presentationGeneration
|
||||
);
|
||||
|
|
@ -41,26 +44,24 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => {
|
|||
const pathname = usePathname();
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const element = document.getElementById(
|
||||
`slide-${slide.index}-prompt`
|
||||
) as HTMLInputElement;
|
||||
const value = element?.value;
|
||||
if (!value?.trim()) {
|
||||
if (!editPrompt.trim()) {
|
||||
toast.error("Please enter a prompt before submitting");
|
||||
return;
|
||||
}
|
||||
setIsUpdating(true);
|
||||
|
||||
try {
|
||||
trackEvent(MixpanelEvent.Slide_Update_From_Prompt_Button_Clicked, { pathname });
|
||||
trackEvent(MixpanelEvent.Slide_Edit_API_Call);
|
||||
const response = await PresentationGenerationApi.editSlide(
|
||||
slide.id,
|
||||
value
|
||||
editPrompt
|
||||
);
|
||||
|
||||
if (response) {
|
||||
dispatch(updateSlide({ index: slide.index, slide: response }));
|
||||
toast.success("Slide updated successfully");
|
||||
setEditPrompt("");
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Error in slide editing:", error);
|
||||
|
|
@ -71,8 +72,10 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => {
|
|||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onDeleteSlide = async () => {
|
||||
try {
|
||||
trackEvent(MixpanelEvent.Slide_Delete_Slide_Button_Clicked, { pathname });
|
||||
trackEvent(MixpanelEvent.Slide_Delete_API_Call);
|
||||
// Add current state to past
|
||||
dispatch(addToHistory({
|
||||
|
|
@ -132,7 +135,7 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => {
|
|||
<>
|
||||
<div
|
||||
id={`slide-${slide.index}`}
|
||||
className=" w-full max-w-[1280px] main-slide flex items-center max-md:mb-4 justify-center relative"
|
||||
className=" w-full main-slide flex items-center max-md:mb-4 justify-center relative"
|
||||
>
|
||||
{isStreaming && (
|
||||
<Loader2 className="w-8 h-8 absolute right-2 top-2 z-30 text-blue-800 animate-spin" />
|
||||
|
|
@ -140,7 +143,7 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => {
|
|||
<div
|
||||
data-layout={slide.layout}
|
||||
data-group={slide.layout_group}
|
||||
className={` w-full group `}
|
||||
className={` w-full group font-syne `}
|
||||
>
|
||||
<V1ContentRender slide={slide} isEditMode={true} theme={null} />
|
||||
{!showNewSlideSelection && (
|
||||
|
|
@ -170,96 +173,116 @@ const SlideContent = ({ slide, index, presentationId }: SlideContentProps) => {
|
|||
)}
|
||||
|
||||
{!isStreaming && (
|
||||
<ToolTip content="Delete slide">
|
||||
<div
|
||||
onClick={() => {
|
||||
trackEvent(MixpanelEvent.Slide_Delete_Slide_Button_Clicked, { pathname });
|
||||
onDeleteSlide();
|
||||
}}
|
||||
className="absolute top-2 z-20 sm:top-4 right-2 sm:right-4 hidden md:block transition-transform"
|
||||
>
|
||||
<Trash2 className="text-gray-500 text-xl cursor-pointer" />
|
||||
</div>
|
||||
</ToolTip>
|
||||
)}
|
||||
{!isStreaming && (
|
||||
<div className="absolute top-2 z-20 sm:top-4 hidden md:block left-2 sm:left-4 transition-transform">
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<ToolTip content="Update slide using prompt">
|
||||
<div
|
||||
className={`p-2 group-hover:scale-105 rounded-lg bg-[#5141e5] hover:shadow-md transition-all duration-300 cursor-pointer shadow-md `}
|
||||
>
|
||||
<WandSparkles className="w-4 sm:w-5 h-4 sm:h-5 text-white" />
|
||||
</div>
|
||||
</ToolTip>
|
||||
<div
|
||||
className={`absolute right-3 top-3 z-30 hidden md:flex flex-row items-center gap-2 rounded-[28px] border border-gray-200/80 bg-white/95 px-2.5 py-2 ${isEditPopoverOpen || isSpeakerPopoverOpen
|
||||
? "opacity-100 pointer-events-auto"
|
||||
: "opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto"
|
||||
}`}
|
||||
style={{
|
||||
boxShadow: "0 2px 13.2px 0 rgba(0, 0, 0, 0.10)"
|
||||
}}
|
||||
>
|
||||
<Popover open={isEditPopoverOpen} onOpenChange={setIsEditPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="flex px-3.5 py-2.5 items-center justify-center rounded-full bg-[#F7F6F9] font-syne"
|
||||
>
|
||||
<ToolTip content="Update slide using prompt">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</ToolTip>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="right"
|
||||
align="start"
|
||||
sideOffset={10}
|
||||
className="w-[280px] sm:w-[400px] z-20"
|
||||
side="bottom"
|
||||
align="center"
|
||||
sideOffset={12}
|
||||
className="z-30 w-[340px] rounded-2xl border border-gray-200 bg-white p-0 shadow-2xl font-syne"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<form
|
||||
className="flex flex-col gap-3"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}}
|
||||
>
|
||||
<Textarea
|
||||
id={`slide-${slide.index}-prompt`}
|
||||
placeholder="Enter your prompt here..."
|
||||
className="w-full min-h-[100px] max-h-[100px] p-2 text-sm border rounded-lg focus:outline-none focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
disabled={isUpdating}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
rows={4}
|
||||
wrap="soft"
|
||||
/>
|
||||
<button
|
||||
disabled={isUpdating}
|
||||
type="submit"
|
||||
className={`bg-gradient-to-r from-[#9034EA] to-[#5146E5] rounded-[32px] px-4 py-2 text-white flex items-center justify-end gap-2 ml-auto ${isUpdating ? "opacity-70 cursor-not-allowed" : ""
|
||||
}`}
|
||||
onClick={() => {
|
||||
trackEvent(MixpanelEvent.Slide_Update_From_Prompt_Button_Clicked, { pathname });
|
||||
}}
|
||||
>
|
||||
{isUpdating ? "Updating..." : "Update"}
|
||||
<SendHorizontal className="w-4 sm:w-5 h-4 sm:h-5" />
|
||||
</button>
|
||||
</form>
|
||||
<div className="border-b border-gray-100 px-4 py-3">
|
||||
<p className="text-sm font-semibold text-gray-900">Update slide</p>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
Describe how this slide should be improved.
|
||||
</p>
|
||||
</div>
|
||||
<form
|
||||
className="flex flex-col gap-3 p-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}}
|
||||
>
|
||||
<Textarea
|
||||
id={`slide-${slide.index}-prompt`}
|
||||
value={editPrompt}
|
||||
placeholder="Enter your prompt here..."
|
||||
className="min-h-[110px] max-h-[180px] w-full resize-none rounded-xl border border-gray-200 p-3 text-sm focus-visible:ring-1 focus-visible:ring-[#5141e5]"
|
||||
disabled={isUpdating}
|
||||
onChange={(e) => setEditPrompt(e.target.value)}
|
||||
rows={5}
|
||||
wrap="soft"
|
||||
/>
|
||||
<button
|
||||
disabled={isUpdating}
|
||||
type="submit"
|
||||
className={`ml-auto flex items-center justify-center gap-2 rounded-full bg-gradient-to-r from-[#9034EA] to-[#5146E5] px-4 py-2 text-sm font-medium text-white transition-opacity ${isUpdating ? "cursor-not-allowed opacity-70" : "hover:opacity-90"}`}
|
||||
>
|
||||
{isUpdating ? "Updating..." : "Update"}
|
||||
<SendHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
</form>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)}
|
||||
{/* Speaker Notes */}
|
||||
{!isStreaming && slide?.speaker_note && (
|
||||
<div className="absolute top-2 z-20 sm:top-4 right-8 sm:right-12 hidden md:block transition-transform">
|
||||
<Popover>
|
||||
|
||||
<Popover open={isSpeakerPopoverOpen} onOpenChange={setIsSpeakerPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<div className=" cursor-pointer ">
|
||||
<ToolTip content="Show speaker notes">
|
||||
<StickyNote className="text-xl text-gray-500" />
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
background: "linear-gradient(270deg, #D5CAFC 2.4%, #E3D2EB 27.88%, #F4DCD3 69.23%, #FDE4C2 100%)",
|
||||
|
||||
}}
|
||||
className={`flex px-4 py-2.5 items-center justify-center rounded-full border font-syne ${slide?.speaker_note
|
||||
? "border-violet-200 bg-violet-50 text-violet-700"
|
||||
: "border-gray-200 bg-white text-gray-600"
|
||||
}`}
|
||||
>
|
||||
<ToolTip content="Edit speaker notes">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M5.13334 11.6665V9.27482L6.24167 9.39149C6.56434 9.37356 6.86969 9.23977 7.1016 9.01472C7.33351 8.78966 7.4764 8.48847 7.50401 8.16649V4.84149C7.50787 4.0011 7.17774 3.1936 6.58624 2.59663C5.99473 1.99965 5.1903 1.6621 4.34992 1.65824C3.50954 1.65437 2.70204 1.9845 2.10506 2.57601C1.50809 3.16751 1.17054 3.97194 1.16667 4.81232C1.16667 6.44565 1.54934 6.59382 1.75001 7.46649C1.88562 7.99351 1.89143 8.54556 1.76692 9.07532L1.16667 11.6665" stroke="black" strokeWidth="1.16667" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M11.55 10.3833C12.3701 9.56317 12.8309 8.45095 12.8312 7.29115C12.8316 6.13134 12.3714 5.01886 11.5518 4.19824" stroke="black" strokeWidth="1.16667" strokeLinecap="round" strokeLinejoin="round" />
|
||||
<path d="M9.91667 8.74974C10.1075 8.55893 10.2586 8.33217 10.3613 8.08258C10.464 7.83299 10.5161 7.56553 10.5148 7.29566C10.5134 7.02578 10.4586 6.75885 10.3534 6.51031C10.2482 6.26177 10.0948 6.03654 9.90208 5.84766" stroke="black" strokeWidth="1.16667" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</ToolTip>
|
||||
</div>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent side="left" align="start" sideOffset={10} className="w-[320px] z-30">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-semibold text-gray-600">Speaker notes</p>
|
||||
<div className="text-sm text-gray-800 whitespace-pre-wrap max-h-64 overflow-auto">
|
||||
{slide.speaker_note}
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="center"
|
||||
sideOffset={12}
|
||||
className="z-30 w-[340px] rounded-2xl border border-gray-200 bg-white p-0 shadow-2xl font-syne"
|
||||
>
|
||||
<div className="border-b border-gray-100 px-4 py-3">
|
||||
<p className="text-sm font-semibold text-gray-900">Speaker notes</p>
|
||||
|
||||
</div>
|
||||
<div className="space-y-3 p-4">
|
||||
<div className="max-h-[220px] min-h-[100px] overflow-auto whitespace-pre-wrap rounded-xl border border-gray-200 bg-gray-50 p-3 text-sm text-gray-800">
|
||||
{slide?.speaker_note?.trim() || "No speaker notes for this slide."}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDeleteSlide}
|
||||
className="flex px-4 py-2.5 items-center justify-center rounded-full border border-gray-200 bg-white text-gray-600 font-syne"
|
||||
>
|
||||
<ToolTip content="Delete slide">
|
||||
<Trash className="h-4 w-4" />
|
||||
</ToolTip>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ interface SortableSlideProps {
|
|||
selectedSlide: number;
|
||||
onSlideClick: (index: any) => void;
|
||||
}
|
||||
const SCALE = 0.2;
|
||||
const SCALE = 0.125;
|
||||
|
||||
export function SortableSlide({ slide, index, selectedSlide, onSlideClick }: SortableSlideProps) {
|
||||
const searchParams = useSearchParams();
|
||||
|
|
@ -55,7 +55,7 @@ export function SortableSlide({ slide, index, selectedSlide, onSlideClick }: Sor
|
|||
{...attributes}
|
||||
{...listeners}
|
||||
onClick={handleClick}
|
||||
className={` cursor-pointer border-[3px] relative p-1 shadow-lg rounded-md transition-all duration-200 ${selectedSlide === index ? ' border-[#5141e5]' : 'border-gray-300'
|
||||
className={` cursor-pointer border relative p-1 rounded-[12px] transition-all duration-200 ${selectedSlide === index ? ' border-[#BDB4FE]' : 'border-[#EDEEEF]'
|
||||
}`}
|
||||
>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,122 @@
|
|||
"use client";
|
||||
import React, { useState } from 'react'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Palette } from 'lucide-react';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { updateTheme } from '@/store/slices/presentationGeneration';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useFontLoader } from '../../hooks/useFontLoad';
|
||||
import { RootState } from '@/store/store';
|
||||
const ThemeSelector = ({ presentation_id, current_theme, themes: allThemes }: { presentation_id: string, current_theme: any, themes: any[] }) => {
|
||||
const [currentTheme, setCurrentTheme] = useState<any>(current_theme)
|
||||
const dispatch = useDispatch()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const router = useRouter()
|
||||
const applyTheme = async (theme: any) => {
|
||||
const element = document.getElementById('presentation-slides-wrapper')
|
||||
if (!element) return;
|
||||
if (allThemes.length === 0) return;
|
||||
setCurrentTheme(theme)
|
||||
clearTheme()
|
||||
if (!theme.data.colors['graph_0']) { return; }
|
||||
const cssVariables = {
|
||||
'--primary-color': theme.data.colors['primary'],
|
||||
'--background-color': theme.data.colors['background'],
|
||||
'--card-color': theme.data.colors['card'],
|
||||
'--stroke': theme.data.colors['stroke'],
|
||||
'--primary-text': theme.data.colors['primary_text'],
|
||||
'--background-text': theme.data.colors['background_text'],
|
||||
'--graph-0': theme.data.colors['graph_0'],
|
||||
'--graph-1': theme.data.colors['graph_1'],
|
||||
'--graph-2': theme.data.colors['graph_2'],
|
||||
'--graph-3': theme.data.colors['graph_3'],
|
||||
'--graph-4': theme.data.colors['graph_4'],
|
||||
'--graph-5': theme.data.colors['graph_5'],
|
||||
'--graph-6': theme.data.colors['graph_6'],
|
||||
'--graph-7': theme.data.colors['graph_7'],
|
||||
'--graph-8': theme.data.colors['graph_8'],
|
||||
'--graph-9': theme.data.colors['graph_9'],
|
||||
}
|
||||
Object.entries(cssVariables).forEach(([key, value]) => {
|
||||
element.style.setProperty(key, value)
|
||||
})
|
||||
useFontLoader({ [theme.data.fonts.textFont.name]: theme.data.fonts.textFont.url })
|
||||
|
||||
// Apply fonts to preview container
|
||||
element.style.setProperty('font-family', `"${theme.data.fonts.textFont.name}"`)
|
||||
element.style.setProperty('--heading-font-family', `"${theme.data.fonts.textFont.name}"`)
|
||||
element.style.setProperty('--body-font-family', `"${theme.data.fonts.textFont.name}"`)
|
||||
|
||||
dispatch(updateTheme(theme))
|
||||
}
|
||||
const clearTheme = () => {
|
||||
const element = document.getElementById('presentation-slides-wrapper')
|
||||
if (!element) return;
|
||||
element.style.removeProperty('--primary-color');
|
||||
element.style.removeProperty('--background-color');
|
||||
element.style.removeProperty('--card-color');
|
||||
element.style.removeProperty('--stroke');
|
||||
element.style.removeProperty('--primary-text');
|
||||
element.style.removeProperty('--background-text');
|
||||
element.style.removeProperty('--graph-0');
|
||||
element.style.removeProperty('--graph-1');
|
||||
element.style.removeProperty('--graph-2');
|
||||
element.style.removeProperty('--graph-3');
|
||||
element.style.removeProperty('--graph-4');
|
||||
element.style.removeProperty('--graph-5');
|
||||
element.style.removeProperty('--graph-6');
|
||||
element.style.removeProperty('--graph-7');
|
||||
element.style.removeProperty('--graph-8');
|
||||
element.style.removeProperty('--graph-9');
|
||||
}
|
||||
const resetTheme = async () => {
|
||||
clearTheme();
|
||||
|
||||
dispatch(updateTheme(null))
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger>
|
||||
<button className={`text-sm px-[18px] py-2.5 gap-1.5 flex items-center border border-[#EDEEEF] bg-[#F6F6F9] duration-300 rounded-[88px] font-medium font-syne ${isOpen ? 'text-[#007AFF]' : 'text-black'}`}>
|
||||
<Palette className={`h-4 w-4 ${isOpen ? 'text-[#007AFF]' : 'text-black'}`} /> Theme
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-fit rounded-[18px] max-h-80 overflow-y-auto hide-scrollbar">
|
||||
<div className='pb-2 flex gap-2 justify-end'>
|
||||
<button className='text-xs text-gray-500 pb-2 text-right underline' onClick={() => router.push(`/theme?tab=new-theme`)}>+Customize Theme</button>
|
||||
<button className='text-xs text-gray-500 pb-2 text-right underline' onClick={resetTheme}>Reset Theme</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
|
||||
{allThemes && allThemes.length > 0 && allThemes.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
onClick={() => applyTheme(t)}
|
||||
className={`text-left group relative`}
|
||||
>
|
||||
|
||||
<div className={`rounded-xl cursor-pointer p-1 border shadow-sm bg-white transition-all group-hover:shadow-md ${currentTheme.id === t.id ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-gray-300'}`}>
|
||||
<div className="rounded-lg p-2" style={{ backgroundColor: t.data.colors['background'] }}>
|
||||
<div className="rounded-md shadow-sm p-3" style={{ backgroundColor: t.data.colors['card'] }}>
|
||||
<div className="w-16 h-2 rounded-full mb-2" style={{ backgroundColor: t.data.colors['background_text'] }} />
|
||||
<div className="w-12 h-2 rounded-full mb-1" style={{ backgroundColor: t.data.colors['background_text'] }} />
|
||||
<div className="w-8 h-2 rounded-full mb-3" style={{ backgroundColor: t.data.colors['background_text'] }} />
|
||||
<div className="w-8 h-3 rounded-full" style={{ backgroundColor: t.data.colors['primary'] }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-center font-medium text-gray-700 truncate w-full">
|
||||
{t.name}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
export default ThemeSelector
|
||||
|
|
@ -3,7 +3,9 @@ import { useDispatch } from "react-redux";
|
|||
import { toast } from "sonner";
|
||||
import { setPresentationData } from "@/store/slices/presentationGeneration";
|
||||
import { DashboardApi } from '../../services/api/dashboard';
|
||||
import { clearHistory } from "@/store/slices/undoRedoSlice";
|
||||
import { clearHistory } from "@/store/slices/undoRedoSlice";
|
||||
import { useFontLoader } from "../../hooks/useFontLoad";
|
||||
import { Theme } from "../../services/api/types";
|
||||
|
||||
|
||||
export const usePresentationData = (
|
||||
|
|
@ -13,6 +15,41 @@ export const usePresentationData = (
|
|||
) => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const applyTheme = async (theme: Theme) => {
|
||||
const element = document.getElementById('presentation-slides-wrapper')
|
||||
if (!element) return;
|
||||
if (!theme || !theme.data) { return; }
|
||||
if (!theme.data.colors['graph_0']) { return; }
|
||||
const cssVariables = {
|
||||
'--primary-color': theme.data.colors['primary'],
|
||||
'--background-color': theme.data.colors['background'],
|
||||
'--card-color': theme.data.colors['card'],
|
||||
'--stroke': theme.data.colors['stroke'],
|
||||
'--primary-text': theme.data.colors['primary_text'],
|
||||
'--background-text': theme.data.colors['background_text'],
|
||||
'--graph-0': theme.data.colors['graph_0'],
|
||||
'--graph-1': theme.data.colors['graph_1'],
|
||||
'--graph-2': theme.data.colors['graph_2'],
|
||||
'--graph-3': theme.data.colors['graph_3'],
|
||||
'--graph-4': theme.data.colors['graph_4'],
|
||||
'--graph-5': theme.data.colors['graph_5'],
|
||||
'--graph-6': theme.data.colors['graph_6'],
|
||||
'--graph-7': theme.data.colors['graph_7'],
|
||||
'--graph-8': theme.data.colors['graph_8'],
|
||||
'--graph-9': theme.data.colors['graph_9'],
|
||||
}
|
||||
Object.entries(cssVariables).forEach(([key, value]) => {
|
||||
element.style.setProperty(key, value)
|
||||
})
|
||||
useFontLoader({ [theme.data.fonts.textFont.name]: theme.data.fonts.textFont.url })
|
||||
|
||||
// Apply fonts to preview container
|
||||
element.style.setProperty('font-family', `"${theme.data.fonts.textFont.name}"`)
|
||||
element.style.setProperty('--heading-font-family', `"${theme.data.fonts.textFont.name}"`)
|
||||
element.style.setProperty('--body-font-family', `"${theme.data.fonts.textFont.name}"`)
|
||||
// Update the Presentation content with theme
|
||||
}
|
||||
|
||||
const fetchUserSlides = useCallback(async () => {
|
||||
try {
|
||||
const data = await DashboardApi.getPresentation(presentationId);
|
||||
|
|
@ -21,6 +58,9 @@ export const usePresentationData = (
|
|||
dispatch(clearHistory());
|
||||
setLoading(false);
|
||||
}
|
||||
if (data?.theme) {
|
||||
applyTheme(data.theme);
|
||||
}
|
||||
} catch (error) {
|
||||
setError(true);
|
||||
toast.error("Failed to load presentation");
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import {
|
|||
import { jsonrepair } from "jsonrepair";
|
||||
import { toast } from "sonner";
|
||||
import { MixpanelEvent, trackEvent } from "@/utils/mixpanel";
|
||||
import { getApiUrl } from "@/utils/api";
|
||||
|
||||
export const usePresentationStreaming = (
|
||||
presentationId: string,
|
||||
|
|
@ -31,7 +30,7 @@ export const usePresentationStreaming = (
|
|||
trackEvent(MixpanelEvent.Presentation_Stream_API_Call);
|
||||
|
||||
eventSource = new EventSource(
|
||||
getApiUrl(`api/v1/ppt/presentation/stream/${presentationId}`)
|
||||
`/api/v1/ppt/presentation/stream/${presentationId}`
|
||||
);
|
||||
|
||||
eventSource.addEventListener("response", (event) => {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ const page = () => {
|
|||
const queryId = params.get("id");
|
||||
if (!queryId) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-screen">
|
||||
<div className="flex flex-col items-center justify-center h-screen font-syne">
|
||||
<h1 className="text-2xl font-bold">No presentation id found</h1>
|
||||
<p className="text-gray-500 pb-4">Please try again</p>
|
||||
<Button onClick={() => router.push("/dashboard")}>Go to home</Button>
|
||||
|
|
|
|||
|
|
@ -14,13 +14,13 @@ export interface PresentationResponse {
|
|||
n_slides: number;
|
||||
prompt: string;
|
||||
summary: string | null;
|
||||
theme: string;
|
||||
titles: string[];
|
||||
user_id: string;
|
||||
vector_store: any;
|
||||
theme: Record<string, any> | null;
|
||||
titles: string[];
|
||||
user_id: string;
|
||||
vector_store: any;
|
||||
|
||||
thumbnail: string;
|
||||
slides: any[];
|
||||
thumbnail: string;
|
||||
slides: any[];
|
||||
}
|
||||
|
||||
export class DashboardApi {
|
||||
|
|
@ -28,45 +28,45 @@ export class DashboardApi {
|
|||
static async getPresentations(): Promise<PresentationResponse[]> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
getApiUrl("api/v1/ppt/presentation/all"),
|
||||
getApiUrl(`/api/v1/ppt/presentation/all`),
|
||||
{
|
||||
method: "GET",
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
// Handle the special case where 404 means "no presentations found"
|
||||
if (response.status === 404) {
|
||||
console.log("No presentations found");
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
return await ApiResponseHandler.handleResponse(response, "Failed to fetch presentations");
|
||||
} catch (error) {
|
||||
console.error("Error fetching presentations:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static async getPresentation(id: string) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
getApiUrl(`api/v1/ppt/presentation/${id}`),
|
||||
getApiUrl(`/api/v1/ppt/presentation/${id}`),
|
||||
{
|
||||
method: "GET",
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
return await ApiResponseHandler.handleResponse(response, "Presentation not found");
|
||||
} catch (error) {
|
||||
console.error("Error fetching presentation:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static async deletePresentation(presentation_id: string) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
getApiUrl(`api/v1/ppt/presentation/${presentation_id}`),
|
||||
getApiUrl(`/api/v1/ppt/presentation/${presentation_id}`),
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: getHeader(),
|
||||
|
|
|
|||
|
|
@ -19,13 +19,12 @@ export interface IconSearch {
|
|||
}
|
||||
|
||||
export interface PreviousGeneratedImagesResponse {
|
||||
|
||||
extras: {
|
||||
prompt: string;
|
||||
theme_prompt: string | null;
|
||||
},
|
||||
created_at: string;
|
||||
id: string;
|
||||
path: string;
|
||||
file_url: string;
|
||||
extras: {
|
||||
prompt: string;
|
||||
theme_prompt: string | null;
|
||||
};
|
||||
created_at: string;
|
||||
id: string;
|
||||
path: string;
|
||||
file_url?: string;
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ export class PresentationGenerationApi {
|
|||
|
||||
try {
|
||||
const response = await fetch(
|
||||
getApiUrl("api/v1/ppt/files/upload"),
|
||||
getApiUrl(`/api/v1/ppt/files/upload`),
|
||||
{
|
||||
method: "POST",
|
||||
headers: getHeaderForFormData(),
|
||||
|
|
@ -32,7 +32,7 @@ export class PresentationGenerationApi {
|
|||
static async decomposeDocuments(documentKeys: string[]) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
getApiUrl("api/v1/ppt/files/decompose"),
|
||||
getApiUrl(`/api/v1/ppt/files/decompose`),
|
||||
{
|
||||
method: "POST",
|
||||
headers: getHeader(),
|
||||
|
|
@ -76,7 +76,7 @@ export class PresentationGenerationApi {
|
|||
}) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
getApiUrl("api/v1/ppt/presentation/create"),
|
||||
getApiUrl(`/api/v1/ppt/presentation/create`),
|
||||
{
|
||||
method: "POST",
|
||||
headers: getHeader(),
|
||||
|
|
@ -109,7 +109,7 @@ export class PresentationGenerationApi {
|
|||
) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
getApiUrl("api/v1/ppt/slide/edit"),
|
||||
getApiUrl(`/api/v1/ppt/slide/edit`),
|
||||
{
|
||||
method: "POST",
|
||||
headers: getHeader(),
|
||||
|
|
@ -130,9 +130,8 @@ export class PresentationGenerationApi {
|
|||
|
||||
static async updatePresentationContent(body: any) {
|
||||
try {
|
||||
console.log("Updating presentation with data:", body);
|
||||
const response = await fetch(
|
||||
getApiUrl("api/v1/ppt/presentation/update"),
|
||||
getApiUrl(`/api/v1/ppt/presentation/update`),
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: getHeader(),
|
||||
|
|
@ -151,7 +150,7 @@ export class PresentationGenerationApi {
|
|||
static async presentationPrepare(presentationData: any) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
getApiUrl("api/v1/ppt/presentation/prepare"),
|
||||
getApiUrl(`/api/v1/ppt/presentation/prepare`),
|
||||
{
|
||||
method: "POST",
|
||||
headers: getHeader(),
|
||||
|
|
@ -173,7 +172,7 @@ export class PresentationGenerationApi {
|
|||
static async generateImage(imageGenerate: ImageGenerate) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
getApiUrl(`api/v1/ppt/images/generate?prompt=${imageGenerate.prompt}`),
|
||||
getApiUrl(`/api/v1/ppt/images/generate?prompt=${imageGenerate.prompt}`),
|
||||
{
|
||||
method: "GET",
|
||||
headers: getHeader(),
|
||||
|
|
@ -191,7 +190,7 @@ export class PresentationGenerationApi {
|
|||
static getPreviousGeneratedImages = async (): Promise<PreviousGeneratedImagesResponse[]> => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
getApiUrl("api/v1/ppt/images/generated"),
|
||||
getApiUrl(`/api/v1/ppt/images/generated`),
|
||||
{
|
||||
method: "GET",
|
||||
headers: getHeader(),
|
||||
|
|
@ -208,7 +207,7 @@ export class PresentationGenerationApi {
|
|||
static async searchIcons(iconSearch: IconSearch) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
getApiUrl(`api/v1/ppt/icons/search?query=${iconSearch.query}&limit=${iconSearch.limit}`),
|
||||
getApiUrl(`/api/v1/ppt/icons/search?query=${iconSearch.query}&limit=${iconSearch.limit}`),
|
||||
{
|
||||
method: "GET",
|
||||
headers: getHeader(),
|
||||
|
|
@ -229,7 +228,7 @@ export class PresentationGenerationApi {
|
|||
static async exportAsPPTX(presentationData: any) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
getApiUrl("api/v1/ppt/presentation/export/pptx"),
|
||||
getApiUrl(`/api/v1/ppt/presentation/export/pptx`),
|
||||
{
|
||||
method: "POST",
|
||||
headers: getHeader(),
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { ApiResponseHandler } from "./api-error-handler";
|
||||
import { getApiUrl } from "@/utils/api";
|
||||
|
||||
class TemplateService {
|
||||
|
||||
static async getCustomTemplateSummaries() {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/ppt/template-management/summary`,);
|
||||
const response = await fetch(getApiUrl(`/api/v1/ppt/template-management/summary`),);
|
||||
return await ApiResponseHandler.handleResponse(response, "Failed to get custom template summaries");
|
||||
} catch (error) {
|
||||
console.error("Failed to get custom template summaries", error);
|
||||
|
|
@ -14,7 +15,7 @@ class TemplateService {
|
|||
|
||||
static async getCustomTemplateDetails(templateId: string) {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/ppt/template-management/get-templates/${templateId}`,);
|
||||
const response = await fetch(getApiUrl(`/api/v1/ppt/template-management/get-templates/${templateId}`),);
|
||||
return await ApiResponseHandler.handleResponse(response, "Failed to get custom template details");
|
||||
} catch (error) {
|
||||
console.error("Failed to get custom template details", error);
|
||||
|
|
@ -24,7 +25,7 @@ class TemplateService {
|
|||
|
||||
static async deleteCustomTemplate(presentationId: string) {
|
||||
try {
|
||||
const response = await fetch(`/api/v1/ppt/template-management/delete-templates/${presentationId}`, { method: "DELETE" });
|
||||
const response = await fetch(getApiUrl(`/api/v1/ppt/template-management/delete-templates/${presentationId}`), { method: "DELETE" });
|
||||
return await ApiResponseHandler.handleResponseWithResult(response, "Failed to delete custom template");
|
||||
} catch (error) {
|
||||
console.error("Failed to delete custom template", error);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,121 @@
|
|||
import { ApiResponseHandler } from "./api-error-handler"
|
||||
import { getHeader, getHeaderForFormData } from "./header"
|
||||
import { Theme, ThemeParams } from "./types"
|
||||
import { getApiUrl } from "@/utils/api"
|
||||
|
||||
|
||||
|
||||
class ThemeApi {
|
||||
|
||||
static async getThemes(): Promise<Theme[]> {
|
||||
try {
|
||||
const response = await fetch(getApiUrl(`/api/v1/ppt/themes/all`), {
|
||||
method: "GET",
|
||||
headers: getHeader(),
|
||||
cache: "no-store",
|
||||
})
|
||||
return await ApiResponseHandler.handleResponse(response, "Failed to get themes")
|
||||
} catch (error) {
|
||||
console.error("Error getting themes:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
static async createTheme(theme: ThemeParams) {
|
||||
try {
|
||||
|
||||
const response = await fetch(getApiUrl(`/api/v1/ppt/themes/create`), {
|
||||
method: "POST",
|
||||
headers: getHeader(),
|
||||
body: JSON.stringify(theme),
|
||||
cache: "no-store",
|
||||
})
|
||||
return await ApiResponseHandler.handleResponse(response, "Failed to create theme")
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Error creating theme:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
static async updateTheme(theme: ThemeParams) {
|
||||
try {
|
||||
const response = await fetch(getApiUrl(`/api/v1/ppt/themes/update/${theme.id}`), {
|
||||
method: "PATCH",
|
||||
headers: getHeader(),
|
||||
body: JSON.stringify(theme),
|
||||
cache: "no-store",
|
||||
})
|
||||
return await ApiResponseHandler.handleResponse(response, "Failed to update theme")
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Error updating theme:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
static async deleteTheme(themeId: string) {
|
||||
try {
|
||||
const response = await fetch(getApiUrl(`/api/v1/ppt/themes/delete/${themeId}`), {
|
||||
method: "DELETE",
|
||||
headers: getHeader(),
|
||||
cache: "no-store",
|
||||
})
|
||||
return await ApiResponseHandler.handleResponse(response, "Failed to delete theme")
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Error deleting theme:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
static async generateTheme({ primary, background }: { primary?: string, background?: string }) {
|
||||
try {
|
||||
let body = {}
|
||||
if (primary || background) {
|
||||
body = {
|
||||
primary: primary ?? undefined,
|
||||
background: background ?? undefined,
|
||||
}
|
||||
}
|
||||
const response = await fetch(getApiUrl(`/api/v1/ppt/theme/generate`), {
|
||||
method: "POST",
|
||||
headers: getHeader(),
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
return await ApiResponseHandler.handleResponse(response, "Failed to generate theme")
|
||||
}
|
||||
|
||||
catch (error) {
|
||||
console.error("Error generating theme:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
static async uploadFont(font: File) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("file", font);
|
||||
const response = await fetch(getApiUrl(`/api/v1/ppt/fonts/upload`), {
|
||||
method: "POST",
|
||||
headers: getHeaderForFormData(),
|
||||
body: formData,
|
||||
})
|
||||
return await ApiResponseHandler.handleResponse(response, "Failed to upload font")
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Error uploading font:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
static async getUserFonts() {
|
||||
try {
|
||||
const response = await fetch(getApiUrl(`/api/v1/ppt/fonts/uploaded`), {
|
||||
method: "GET",
|
||||
headers: getHeader(),
|
||||
})
|
||||
return await ApiResponseHandler.handleResponse(response, "Failed to get user fonts")
|
||||
}
|
||||
catch (error) {
|
||||
console.error("Error getting user fonts:", error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default ThemeApi
|
||||
|
|
@ -25,8 +25,46 @@ export interface DeplotResponse {
|
|||
}
|
||||
|
||||
export interface ImageAssetResponse {
|
||||
message:string;
|
||||
path:string;
|
||||
id:string;
|
||||
file_url:string;
|
||||
message: string;
|
||||
path: string;
|
||||
id: string;
|
||||
file_url?: string;
|
||||
}
|
||||
|
||||
|
||||
|
||||
export interface DefaultTheme {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
data: any;
|
||||
}
|
||||
|
||||
|
||||
export interface Theme {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
user: string;
|
||||
logo: string; // image id
|
||||
logo_url?: string; // preview url
|
||||
company_name?: string;
|
||||
data: any;
|
||||
}
|
||||
export interface ThemeParams {
|
||||
id?: string;
|
||||
name: string;
|
||||
description: string;
|
||||
logo: string | null; // image id
|
||||
logo_url?: string | null; // preview url
|
||||
data: any;
|
||||
company_name?: string | null;
|
||||
}
|
||||
|
||||
|
||||
export interface DefaultTheme {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
data: any;
|
||||
}
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect, useRef, useCallback, useState } from 'react';
|
||||
import { getFastAPIUrl } from '@/utils/api';
|
||||
import { getHeader } from '@/app/(presentation-generator)/services/api/header';
|
||||
import { ApiResponseHandler } from '@/app/(presentation-generator)/services/api/api-error-handler';
|
||||
import { ProcessedSlide } from '@/app/(presentation-generator)/custom-template/types';
|
||||
import { CustomTemplateLayout } from '@/app/hooks/useCustomTemplates';
|
||||
import { getApiUrl } from '@/utils/api';
|
||||
|
||||
interface LayoutPayload {
|
||||
layout_id: string;
|
||||
|
|
@ -13,10 +13,16 @@ interface LayoutPayload {
|
|||
layout_name: string;
|
||||
}
|
||||
|
||||
/** Slide state for template preview: ProcessedSlide plus saved layout code and name */
|
||||
export type TemplatePreviewSlideState = ProcessedSlide & {
|
||||
react?: string;
|
||||
layout_name?: string;
|
||||
};
|
||||
|
||||
interface UseTemplateLayoutsAutoSaveOptions {
|
||||
templateId: string | null;
|
||||
layouts: CustomTemplateLayout[];
|
||||
slideStates: ProcessedSlide[];
|
||||
slideStates: TemplatePreviewSlideState[];
|
||||
debounceMs?: number;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
|
@ -72,7 +78,7 @@ export const useTemplateLayoutsAutoSave = ({
|
|||
setSaveStatus('saving');
|
||||
console.log('🔄 Auto-saving template layouts...');
|
||||
|
||||
const response = await fetch(`${getFastAPIUrl()}/api/v1/ppt/template/update`, {
|
||||
const response = await fetch(getApiUrl('/api/v1/ppt/template/update'), {
|
||||
method: 'PUT',
|
||||
headers: getHeader(),
|
||||
body: JSON.stringify({
|
||||
|
|
|
|||
|
|
@ -1,22 +1,308 @@
|
|||
import GroupLayoutPreview from './components/TemplatePreviewClient';
|
||||
"use client";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useParams, usePathname, useRouter } from "next/navigation";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowLeft, Home, Loader2, Trash2 } from "lucide-react";
|
||||
|
||||
// Allow dynamic params for custom templates
|
||||
export const dynamicParams = true;
|
||||
import { useFontLoader } from "../../hooks/useFontLoader";
|
||||
import { MixpanelEvent, trackEvent } from "@/utils/mixpanel";
|
||||
import TemplateService from "../../services/api/template";
|
||||
import Header from "../../(dashboard)/dashboard/components/Header";
|
||||
import { toast } from "sonner";
|
||||
import { CustomTemplateLayout, useCustomTemplateDetails } from "@/app/hooks/useCustomTemplates";
|
||||
import { templates as templateGroups, getTemplatesByTemplateName } from "@/app/presentation-templates";
|
||||
|
||||
// Generate static params for built-in template groups
|
||||
export async function generateStaticParams() {
|
||||
// Pre-render built-in template routes at build time
|
||||
// Custom templates (custom-*) will be generated on-demand
|
||||
return [
|
||||
{ slug: 'neo-general' },
|
||||
{ slug: 'neo-standard' },
|
||||
{ slug: 'neo-modern' },
|
||||
{ slug: 'general' },
|
||||
{ slug: 'modern' },
|
||||
{ slug: 'standard' },
|
||||
];
|
||||
}
|
||||
const GroupLayoutPreview = () => {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
export default function GroupLayoutPreviewPage() {
|
||||
return <GroupLayoutPreview />;
|
||||
}
|
||||
const templateParams = params.slug as string;
|
||||
|
||||
// Check if this is a custom template
|
||||
const isCustom = templateParams.startsWith("custom-");
|
||||
const customTemplateId = isCustom ? templateParams.split("custom-")[1] : null;
|
||||
|
||||
|
||||
// Fetch static templates if not custom
|
||||
const staticTemplates = !isCustom ? getTemplatesByTemplateName(templateParams) : [];
|
||||
|
||||
const staticGroup = !isCustom ? templateGroups.find((g: { id: string }) => g.id === templateParams) : null;
|
||||
|
||||
// Fetch custom template details if custom
|
||||
const {
|
||||
template: customTemplate,
|
||||
loading: customLoading,
|
||||
error: customError,
|
||||
fonts: customFonts,
|
||||
} = useCustomTemplateDetails({ id: templateParams?.split("custom-")[1] || "", name: "", description: "" });
|
||||
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
const existingScript = document.querySelector('script[src*="tailwindcss.com"]');
|
||||
if (!existingScript) {
|
||||
const script = document.createElement("script");
|
||||
script.src = "https://cdn.tailwindcss.com";
|
||||
script.async = true;
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
}, [templateParams]);
|
||||
|
||||
const handleDeleteCustomTemplate = async () => {
|
||||
if (!customTemplateId) return;
|
||||
|
||||
const confirmed = window.confirm(
|
||||
"Are you sure you want to delete this template? This action cannot be undone."
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
const success = await TemplateService.deleteCustomTemplate(customTemplateId);
|
||||
if (success.success) {
|
||||
toast.success("Template deleted successfully");
|
||||
router.push("/template-preview");
|
||||
} else {
|
||||
toast.error("Failed to delete template");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Loading state for custom templates
|
||||
if (isCustom && (customLoading)) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Header />
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-blue-600" />
|
||||
<span className="ml-3 text-gray-600">Compiling templates...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (isCustom && customError) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Header />
|
||||
<div className="flex flex-col items-center justify-center py-24">
|
||||
<h2 className="text-2xl font-bold text-red-600 mb-4">Error loading template</h2>
|
||||
<p className="text-gray-600 mb-4">{customError}</p>
|
||||
<Button onClick={() => router.push("/template-preview")}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to Templates
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if (
|
||||
(!isCustom && (!staticGroup || staticTemplates.length === 0)) ||
|
||||
(isCustom && (!customTemplate))
|
||||
) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Header />
|
||||
<div className="flex flex-col items-center justify-center py-24">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
||||
Template not found
|
||||
</h2>
|
||||
<Button onClick={() => router.push("/template-preview")}>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
Back to Templates
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Determine what to render
|
||||
const templateName = isCustom ? customTemplate?.template.name || "Custom Template" : staticGroup?.name || "";
|
||||
const templateDescription = isCustom
|
||||
? customTemplate?.template.description || ""
|
||||
: staticGroup?.description || "";
|
||||
const layoutCount = isCustom
|
||||
? customTemplate?.layouts.length || 0
|
||||
: staticTemplates.length;
|
||||
|
||||
console.log('compileLayout', customTemplate)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<Header />
|
||||
|
||||
{/* Header */}
|
||||
<header className="bg-white shadow-sm border-b sticky top-0 z-30">
|
||||
<div className=" mx-auto px-6 py-6">
|
||||
<div className="flex items-center justify-between mb-4 max-w-[1440px] mx-auto">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
trackEvent(MixpanelEvent.TemplatePreview_Back_Button_Clicked, { pathname });
|
||||
router.back();
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
trackEvent(MixpanelEvent.TemplatePreview_All_Groups_Button_Clicked, { pathname });
|
||||
router.push("/template-preview");
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Home className="w-4 h-4" />
|
||||
All Templates
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isCustom && (
|
||||
<div className="flex items-center gap-4">
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
trackEvent(MixpanelEvent.TemplatePreview_Delete_Templates_Button_Clicked, { pathname });
|
||||
trackEvent(MixpanelEvent.TemplatePreview_Delete_Templates_API_Call);
|
||||
handleDeleteCustomTemplate();
|
||||
}}
|
||||
className="flex items-center gap-2 border-red-200 text-red-700 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
Delete Template
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center gap-2 mb-2">
|
||||
<h1 className="text-3xl font-bold text-gray-900">{templateName}</h1>
|
||||
{isCustom && (
|
||||
<span className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-sm">
|
||||
Custom
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-600">
|
||||
{layoutCount} layout{layoutCount !== 1 ? "s" : ""} •{" "}
|
||||
{templateDescription}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</header>
|
||||
|
||||
{/* Layout Grid - Wrapped in SchemaHighlightProvider for custom templates */}
|
||||
<main className="mx-auto px-2 py-8" id="presentation-page">
|
||||
{/* Static Templates */}
|
||||
{!isCustom && (
|
||||
<div className="space-y-12 w-[1440px] h-[720px] aspect-video mx-auto">
|
||||
{staticTemplates.map((template: any, index: number) => {
|
||||
const LayoutComponent = template.component;
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={`${templateParams}-${template.layoutId}-${index}`}
|
||||
id={template.layoutId}
|
||||
className="overflow-hidden shadow-md"
|
||||
>
|
||||
<div className="bg-white px-6 py-4 border-b">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-gray-900">
|
||||
{template.layoutName}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-1 max-w-2xl">
|
||||
{template.layoutDescription}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="px-3 py-1 bg-gray-100 text-gray-600 rounded text-sm font-mono">
|
||||
{template.layoutId}
|
||||
</span>
|
||||
<span className="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm font-medium">
|
||||
#{index + 1}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-100 p-6 flex justify-center overflow-x-auto">
|
||||
<div
|
||||
className="flex-shrink-0"
|
||||
style={{ width: "1280px", height: "720px" }}
|
||||
>
|
||||
<LayoutComponent data={template.sampleData} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom Templates - with page-level schema editor */}
|
||||
{isCustom && (
|
||||
|
||||
<div className="flex flex-col items-center justify-center w-full gap-10 aspect-video mx-auto">
|
||||
{/* Slides List */}
|
||||
|
||||
{customTemplate && customTemplate.layouts.map((layout: CustomTemplateLayout, index: number) => {
|
||||
const LayoutComponent = layout.component;
|
||||
return (
|
||||
<Card
|
||||
key={`${templateParams}-${layout.layoutId}-${index}`}
|
||||
id={layout.layoutId}
|
||||
className="overflow-hidden shadow-md"
|
||||
>
|
||||
<div className="bg-white px-6 py-4 border-b">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold text-gray-900">
|
||||
{layout.rawLayoutName}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 mt-1 max-w-2xl">
|
||||
{layout.layoutDescription}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-end justify-end ">
|
||||
<span className="px-3 py-1 bg-gray-100 text-gray-600 rounded text-sm font-mono">
|
||||
{templateParams}:{layout.layoutId}
|
||||
</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-100 p-6 flex justify-center overflow-x-auto">
|
||||
<div
|
||||
className="flex-shrink-0"
|
||||
style={{ width: "1280px", height: "720px" }}
|
||||
>
|
||||
<LayoutComponent data={layout.sampleData} />
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupLayoutPreview;
|
||||
|
|
@ -13,7 +13,7 @@ import {
|
|||
CustomTemplates,
|
||||
} from "@/app/hooks/useCustomTemplates";
|
||||
import { CompiledLayout } from "@/app/hooks/compileLayout";
|
||||
import Header from "../dashboard/components/Header";
|
||||
import Header from "../(dashboard)/dashboard/components/Header";
|
||||
|
||||
// Component for rendering custom template card with lazy-loaded previews
|
||||
const CustomTemplateCard = ({ template }: { template: CustomTemplates }) => {
|
||||
|
|
@ -219,4 +219,4 @@ const LayoutPreview = () => {
|
|||
);
|
||||
};
|
||||
|
||||
export default LayoutPreview;
|
||||
export default LayoutPreview;
|
||||
|
|
|
|||
|
|
@ -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,25 +1,21 @@
|
|||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useState } from "react";
|
||||
|
||||
|
||||
interface PromptInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
|
||||
}
|
||||
|
||||
export function PromptInput({
|
||||
value,
|
||||
onChange,
|
||||
export function PromptInput({ value, onChange }: PromptInputProps) {
|
||||
|
||||
}: PromptInputProps) {
|
||||
const [showHint, setShowHint] = useState(false);
|
||||
const handleChange = (value: string) => {
|
||||
setShowHint(value.length > 0);
|
||||
onChange(value);
|
||||
|
||||
const handleChange = (val: string) => {
|
||||
|
||||
onChange(val);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-2 font-syne">
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
value={value}
|
||||
|
|
@ -30,13 +26,7 @@ export function PromptInput({
|
|||
className={`py-4 px-5 border-2 font-medium font-instrument_sans text-base min-h-[150px] max-h-[300px] border-[#5146E5] focus-visible:ring-offset-0 focus-visible:ring-[#5146E5] overflow-y-auto custom_scrollbar `}
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
className={`text-sm text-gray-500 font-inter font-medium ${showHint ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
Provide specific details about your presentation needs (e.g., topic,
|
||||
style, key points) for more accurate results
|
||||
</p>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,230 +1,240 @@
|
|||
'use client'
|
||||
|
||||
import React, { useRef, useState } from 'react'
|
||||
import { File, X, Upload } from 'lucide-react'
|
||||
import React, { ChangeEvent, useEffect, useMemo, useState } from 'react'
|
||||
import { File, Paperclip, X } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface FileWithId extends File {
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface SupportingDocProps {
|
||||
files: File[];
|
||||
onFilesChange: (files: File[]) => void;
|
||||
files: File[]
|
||||
onFilesChange: (files: File[]) => void
|
||||
accept?: string
|
||||
multiple?: boolean
|
||||
}
|
||||
|
||||
const SupportingDoc = ({ files, onFilesChange }: SupportingDocProps) => {
|
||||
const PDF_TYPES = ['.pdf']
|
||||
const TEXT_TYPES = ['.txt']
|
||||
const POWERPOINT_TYPES = ['.pptx']
|
||||
const WORD_TYPES = ['.docx']
|
||||
|
||||
const ACCEPT_DEFAULT = [
|
||||
'application/pdf',
|
||||
'text/plain',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
...PDF_TYPES,
|
||||
...TEXT_TYPES,
|
||||
...POWERPOINT_TYPES,
|
||||
...WORD_TYPES,
|
||||
].join(',')
|
||||
const ALLOWED_MIME_PREFIXES: string[] = []
|
||||
const ALLOWED_MIME_TYPES = [
|
||||
'application/pdf',
|
||||
'application/x-pdf',
|
||||
'application/acrobat',
|
||||
'applications/pdf',
|
||||
'text/pdf',
|
||||
'application/vnd.pdf',
|
||||
'text/plain',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
]
|
||||
const ALLOWED_EXTENSIONS = [
|
||||
...PDF_TYPES,
|
||||
...TEXT_TYPES,
|
||||
...POWERPOINT_TYPES,
|
||||
...WORD_TYPES,
|
||||
]
|
||||
|
||||
const SupportingDoc = ({
|
||||
files,
|
||||
onFilesChange,
|
||||
accept = ACCEPT_DEFAULT,
|
||||
multiple = true,
|
||||
}: SupportingDocProps) => {
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [previewUrls, setPreviewUrls] = useState<(string | null)[]>([])
|
||||
|
||||
// Convert Files to FileWithId with proper type checking
|
||||
const filesWithIds: FileWithId[] = files.map(file => {
|
||||
const fileWithId = file as FileWithId
|
||||
fileWithId.id = `${file.name || 'unnamed'}-${file.lastModified || Date.now()}-${file.size || 0}`
|
||||
return fileWithId
|
||||
})
|
||||
const hasFiles = files.length > 0
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (!bytes || bytes === 0) return '0 Bytes'
|
||||
const k = 1024
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||||
const filteredFiles = useMemo(() => {
|
||||
return files.filter(isAllowedFile)
|
||||
}, [files])
|
||||
|
||||
useEffect(() => {
|
||||
const urls = filteredFiles.map((file) => (file.type.startsWith('image/') ? URL.createObjectURL(file) : null))
|
||||
setPreviewUrls(urls)
|
||||
|
||||
return () => {
|
||||
urls.forEach((url) => {
|
||||
if (url) URL.revokeObjectURL(url)
|
||||
})
|
||||
}
|
||||
}, [filteredFiles])
|
||||
|
||||
const handleValidate = (filesToReview: File[]) => {
|
||||
const disallowed = filesToReview.filter((file) => !isAllowedFile(file))
|
||||
if (disallowed.length > 0) {
|
||||
toast.error('Some files are not supported', {
|
||||
description: 'Only PDF, TXT, PPTX, and DOCX files are allowed.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragEvents = (e: React.DragEvent<HTMLDivElement>, isDragging: boolean) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(isDragging)
|
||||
const handleFilesSelected = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFiles = Array.from(e.target.files ?? [])
|
||||
if (selectedFiles.length === 0) return
|
||||
|
||||
const nextFiles = multiple ? [...files, ...selectedFiles] : [selectedFiles[0]]
|
||||
const allowedFiles = nextFiles.filter(isAllowedFile)
|
||||
|
||||
onFilesChange(allowedFiles)
|
||||
handleValidate(nextFiles)
|
||||
if (allowedFiles.length > files.length) {
|
||||
toast.success('Files selected', {
|
||||
description: `${allowedFiles.length - files.length} file(s) have been added`,
|
||||
})
|
||||
}
|
||||
e.currentTarget.value = ''
|
||||
}
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
const handleDrop = (e: React.DragEvent<HTMLLabelElement>) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setIsDragging(false)
|
||||
|
||||
const droppedFiles = Array.from(e.dataTransfer.files);
|
||||
const hasPdf = files.some(file => file.type === 'application/pdf');
|
||||
const droppedFiles = Array.from(e.dataTransfer.files ?? [])
|
||||
if (droppedFiles.length === 0) return
|
||||
|
||||
const validTypes = [
|
||||
'application/pdf',
|
||||
'text/plain',
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
];
|
||||
|
||||
const invalidFiles = droppedFiles.filter(file => !validTypes.includes(file.type));
|
||||
if (invalidFiles.length > 0) {
|
||||
toast.error('Invalid file type', {
|
||||
description: 'Please upload only PDF, TXT, PPTX, or DOCX files',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasPdf && droppedFiles.some(file => file.type === 'application/pdf')) {
|
||||
toast.error('Multiple PDF files are not allowed', {
|
||||
description: 'Please select only one PDF file',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const validFiles = droppedFiles.filter(file => {
|
||||
return !(hasPdf && file.type === 'application/pdf');
|
||||
});
|
||||
|
||||
if (validFiles.length > 0) {
|
||||
const updatedFiles = [...files, ...validFiles]
|
||||
onFilesChange(updatedFiles)
|
||||
const nextFiles = multiple ? [...files, ...droppedFiles] : [droppedFiles[0]]
|
||||
const allowedFiles = nextFiles.filter(isAllowedFile)
|
||||
|
||||
onFilesChange(allowedFiles)
|
||||
handleValidate(nextFiles)
|
||||
if (allowedFiles.length > files.length) {
|
||||
toast.success('Files selected', {
|
||||
description: `${validFiles.length} file(s) have been added`,
|
||||
description: `${allowedFiles.length - files.length} file(s) have been added`,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileInput = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFiles = Array.from(e.target.files || []);
|
||||
|
||||
const hasPdf = files.some(file => file.type === 'application/pdf');
|
||||
|
||||
const validFiles = selectedFiles.filter(file => {
|
||||
return !(hasPdf && file.type === 'application/pdf');
|
||||
});
|
||||
|
||||
if (validFiles.length > 0) {
|
||||
const updatedFiles = [...files, ...validFiles]
|
||||
onFilesChange(updatedFiles)
|
||||
|
||||
toast.success('Files selected', {
|
||||
description: `${validFiles.length} file(s) have been added`,
|
||||
})
|
||||
}
|
||||
const handleDragOver = (e: React.DragEvent<HTMLLabelElement>) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(true)
|
||||
}
|
||||
|
||||
const removeFile = (fileId: string) => {
|
||||
const updatedFiles = files.filter(file => {
|
||||
const currentFileId = `${file.name || 'unnamed'}-${file.lastModified || Date.now()}-${file.size || 0}`
|
||||
return currentFileId !== fileId
|
||||
})
|
||||
onFilesChange(updatedFiles)
|
||||
const handleDragLeave = (e: React.DragEvent<HTMLLabelElement>) => {
|
||||
e.preventDefault()
|
||||
setIsDragging(false)
|
||||
}
|
||||
|
||||
const handleRemoveFileAt = (index: number) => {
|
||||
const nextFiles = filteredFiles.filter((_, i) => i !== index)
|
||||
onFilesChange(nextFiles)
|
||||
}
|
||||
|
||||
const handleClearFiles = () => {
|
||||
if (!hasFiles) return
|
||||
onFilesChange([])
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<h2 className="text-[#444] font-instrument_sans pt-4 text-lg mb-4">Supporting Documents</h2>
|
||||
<div
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className={cn(
|
||||
"w-full border-2 border-dashed border-gray-400 rounded-lg",
|
||||
"transition-all duration-300 ease-in-out bg-white",
|
||||
"min-h-[300px] flex flex-col mb-8",
|
||||
isDragging && "border-purple-400 bg-purple-50"
|
||||
)}
|
||||
onDragOver={(e) => handleDragEvents(e, true)}
|
||||
onDragLeave={(e) => handleDragEvents(e, false)}
|
||||
<div className="space-y-2" data-testid="attachments-uploader">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-gray-600 font-syne">
|
||||
{hasFiles ? `${filteredFiles.length} attachment${filteredFiles.length > 1 ? 's' : ''}` : 'No attachments yet'}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearFiles}
|
||||
disabled={!hasFiles}
|
||||
className={`text-sm font-medium font-syne ${!hasFiles ? 'cursor-not-allowed text-gray-400' : 'text-red-600 hover:text-red-700'}`}
|
||||
data-testid="attachments-clear-button"
|
||||
aria-disabled={!hasFiles}
|
||||
>
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label
|
||||
className={`mt-1 block cursor-pointer rounded-lg border-2 border-dashed px-4 py-6 text-center transition-colors ${isDragging ? 'border-[#5146E5] bg-[#5146E5]/5' : 'border-gray-200 hover:border-[#5146E5]'}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-6">
|
||||
<Upload className={cn(
|
||||
"w-12 h-12 text-gray-400 mb-4",
|
||||
isDragging && "text-purple-400"
|
||||
)} />
|
||||
|
||||
<p className="text-gray-600 text-center mb-2">
|
||||
{isDragging
|
||||
? 'Drop your file here'
|
||||
: 'Drag and drop your file here or click below button'
|
||||
}
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={handleFilesSelected}
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
data-testid="file-upload-input"
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Paperclip className="h-6 w-6 text-[#5146E5]" />
|
||||
<p className="text-sm font-medium text-gray-800 font-syne">
|
||||
Drag and drop PDF, TXT, PPTX, DOCX, or <span className="text-[#5146E5]">click to browse</span>
|
||||
</p>
|
||||
<p className="text-gray-400 text-sm text-center mb-4">
|
||||
Supports PDFs, Text files, PPTX, DOCX
|
||||
</p>
|
||||
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf,.txt,.pptx,.docx"
|
||||
onChange={handleFileInput}
|
||||
className="hidden"
|
||||
id="file-upload"
|
||||
ref={fileInputRef}
|
||||
multiple
|
||||
data-testid="file-upload-input"
|
||||
/>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
fileInputRef.current?.click()
|
||||
}}
|
||||
className="px-6 py-2 bg-purple-600 text-white rounded-full
|
||||
hover:bg-purple-700 transition-colors duration-200
|
||||
font-medium text-sm"
|
||||
>
|
||||
Choose Files
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{files.length > 0 && (
|
||||
<div className="border-t border-gray-200 bg-gray-50 rounded-b-lg">
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium text-gray-700">
|
||||
Selected Files ({files.length})
|
||||
</h3>
|
||||
</div>
|
||||
<div data-testid="file-list" className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
{filesWithIds.map((file) => {
|
||||
{hasFiles && (
|
||||
<div className="mt-2">
|
||||
<ul data-testid="file-list" className="grid grid-cols-1 gap-2 sm:grid-cols-2" aria-label="Attached files">
|
||||
{filteredFiles.map((file, idx) => (
|
||||
<li
|
||||
key={`${file.name}-${idx}`}
|
||||
className="flex items-center gap-3 rounded-md border border-gray-200 px-3 py-2"
|
||||
data-testid="attached-file-item"
|
||||
>
|
||||
{previewUrls[idx] ? (
|
||||
<img src={previewUrls[idx] as string} alt="Preview" className="h-10 w-10 flex-none rounded object-cover" />
|
||||
) : (
|
||||
<div className="flex h-10 w-10 flex-none items-center justify-center rounded bg-gray-100 text-gray-600">
|
||||
<File className="h-5 w-5" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
return (
|
||||
(
|
||||
<div key={file.id}
|
||||
className="bg-white rounded-lg border border-gray-200 overflow-hidden
|
||||
hover:border-purple-200 group relative"
|
||||
>
|
||||
<div className="p-4 bg-purple-50 group-hover:bg-purple-100
|
||||
transition-colors flex items-center justify-center relative"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-gray-900 font-syne" title={file.name}>
|
||||
{file.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 font-syne">{formatFileSize(file.size)}</p>
|
||||
</div>
|
||||
|
||||
<File className="w-8 h-8 text-purple-600" />
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
removeFile(file.id)
|
||||
}}
|
||||
className="absolute top-1 right-2 p-1.5
|
||||
bg-white/80 backdrop-blur-sm rounded-full
|
||||
text-gray-500 hover:text-red-500
|
||||
shadow-sm hover:shadow-md
|
||||
transition-all duration-200"
|
||||
aria-label="Remove file"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-3 relative">
|
||||
<p className="text-sm font-medium text-gray-700 truncate mb-1 pr-2">
|
||||
{file.name || 'Unnamed File'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{formatFileSize(file.size)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveFileAt(idx)}
|
||||
className="ml-2 inline-flex h-8 w-8 items-center justify-center rounded text-red-600 hover:bg-red-50 hover:text-red-700"
|
||||
aria-label={`Remove ${file.name}`}
|
||||
data-testid="remove-file-button"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{filteredFiles.length !== files.length && (
|
||||
<p className="mt-2 text-xs text-amber-600 font-syne">
|
||||
Some files were skipped. Only PDF, TXT, PPTX, and DOCX files are supported.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (!bytes || bytes <= 0) return '0 KB'
|
||||
return `${(bytes / 1024).toFixed(1)} KB`
|
||||
}
|
||||
|
||||
function isAllowedFile(file: File): boolean {
|
||||
const type = (file.type || '').toLowerCase()
|
||||
const name = (file.name || '').toLowerCase()
|
||||
const typeAllowed = ALLOWED_MIME_TYPES.includes(type) || ALLOWED_MIME_PREFIXES.some((prefix) => type.startsWith(prefix))
|
||||
|
||||
if (typeAllowed) return true
|
||||
return ALLOWED_EXTENSIONS.some((ext) => name.endsWith(ext))
|
||||
}
|
||||
|
||||
export default SupportingDoc
|
||||
|
|
|
|||
|
|
@ -14,9 +14,8 @@ import React, { useState } from "react";
|
|||
import { useRouter, usePathname } from "next/navigation";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { clearOutlines, setPresentationId } from "@/store/slices/presentationGeneration";
|
||||
import { ConfigurationSelects } from "./ConfigurationSelects";
|
||||
import { PromptInput } from "./PromptInput";
|
||||
import { LanguageType, PresentationConfig, ToneType, VerbosityType } from "../type";
|
||||
import { LanguageType, PresentationConfig, ToneType, VerbosityType } from "../type";
|
||||
import SupportingDoc from "./SupportingDoc";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
|
|
@ -26,6 +25,7 @@ import { OverlayLoader } from "@/components/ui/overlay-loader";
|
|||
import Wrapper from "@/components/Wrapper";
|
||||
import { setPptGenUploadState } from "@/store/slices/presentationGenUpload";
|
||||
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
|
||||
import { ConfigurationSelects } from "./ConfigurationSelects";
|
||||
|
||||
// Types for loading state
|
||||
interface LoadingState {
|
||||
|
|
@ -44,7 +44,7 @@ const UploadPage = () => {
|
|||
// State management
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [config, setConfig] = useState<PresentationConfig>({
|
||||
slides: "8",
|
||||
slides: "5",
|
||||
language: LanguageType.English,
|
||||
prompt: "",
|
||||
tone: ToneType.Default,
|
||||
|
|
@ -202,36 +202,58 @@ const UploadPage = () => {
|
|||
duration={loadingState.duration}
|
||||
extra_info={loadingState.extra_info}
|
||||
/>
|
||||
<div className="flex flex-col gap-4 md:items-center md:flex-row justify-between py-4">
|
||||
<p></p>
|
||||
<ConfigurationSelects
|
||||
config={config}
|
||||
onConfigChange={handleConfigChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200/70 bg-white/80 shadow-sm backdrop-blur supports-[backdrop-filter]:bg-white/60" >
|
||||
<div className="flex flex-col gap-4 md:items-center md:flex-row justify-between p-4">
|
||||
<div >
|
||||
<h2 className="text-lg font-unbounded tracking-tight text-slate-900 ">Configuration</h2>
|
||||
<p className="text-sm text-slate-500 font-syne">Choose slides, tone, and language preferences.</p>
|
||||
</div>
|
||||
<ConfigurationSelects
|
||||
config={config}
|
||||
onConfigChange={handleConfigChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-slate-200/70" />
|
||||
|
||||
<div className="p-4 md:p-6">
|
||||
<h3 className="text-base font-normal font-unbounded text-slate-900 mb-2">Content</h3>
|
||||
<div className="relative">
|
||||
<PromptInput
|
||||
value={config.prompt}
|
||||
onChange={(value) => handleConfigChange("prompt", value)}
|
||||
data-testid="prompt-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-slate-200/70" />
|
||||
<div className="p-4 md:p-6">
|
||||
<h3 className="text-base font-normal font-unbounded text-slate-900 mb-2">Attachments (optional)</h3>
|
||||
|
||||
|
||||
<SupportingDoc
|
||||
files={[...files]}
|
||||
onFilesChange={setFiles}
|
||||
data-testid="file-upload-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="border-t border-slate-200/70" />
|
||||
|
||||
<div className="p-4 md:p-6">
|
||||
<Button
|
||||
onClick={handleGeneratePresentation}
|
||||
className="w-full rounded-[28px] flex items-center justify-center py-5 bg-[#5141e5] text-white font-syne font-semibold text-lg hover:bg-[#5141e5]/85 focus-visible:ring-2 focus-visible:ring-[#5141e5]/40"
|
||||
data-testid="next-button"
|
||||
>
|
||||
<span>Generate Presentation</span>
|
||||
<ChevronRight className="!w-5 !h-5 ml-1.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div className="relative">
|
||||
<PromptInput
|
||||
value={config.prompt}
|
||||
onChange={(value) => handleConfigChange("prompt", value)}
|
||||
data-testid="prompt-input"
|
||||
/>
|
||||
</div>
|
||||
<SupportingDoc
|
||||
files={[...files]}
|
||||
onFilesChange={setFiles}
|
||||
data-testid="file-upload-input"
|
||||
/>
|
||||
<Button
|
||||
onClick={handleGeneratePresentation}
|
||||
className="w-full rounded-[32px] flex items-center justify-center py-6 bg-[#5141e5] text-white font-instrument_sans font-semibold text-xl hover:bg-[#5141e5]/80 transition-colors duration-300"
|
||||
data-testid="next-button"
|
||||
>
|
||||
<span>Next</span>
|
||||
<ChevronRight className="!w-6 !h-6" />
|
||||
</Button>
|
||||
</Wrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default UploadPage;
|
||||
export default UploadPage;
|
||||
|
|
@ -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-normal font-unbounded text-[#101323] ">
|
||||
AI Presentation
|
||||
</h1>
|
||||
{/* <p className='text-sm text-gray-500'>We will generate a presentation for you</p> */}
|
||||
<p className="text-xl font-syne text-[#101323CC]">Choose a design, set preferences, and generate polished slides.</p>
|
||||
</div>
|
||||
|
||||
<UploadPage />
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
|
||||
body {
|
||||
font-family: var(--font-inter), var(--font-roboto), sans-serif;
|
||||
font-family: var(--font-inter), var(--font-unbounded), var(--font-syne), sans-serif;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
|
|
@ -449,7 +449,7 @@ thead {
|
|||
}
|
||||
|
||||
.container__editor {
|
||||
|
||||
|
||||
font-variant-ligatures: common-ligatures;
|
||||
background-color: #fafafa;
|
||||
border-radius: 3px;
|
||||
|
|
@ -458,6 +458,7 @@ thead {
|
|||
.container__editor textarea {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
/* Syntax highlighting */
|
||||
.token.comment,
|
||||
.token.prolog,
|
||||
|
|
@ -465,12 +466,15 @@ thead {
|
|||
.token.cdata {
|
||||
color: #90a4ae;
|
||||
}
|
||||
|
||||
.token.punctuation {
|
||||
color: #9e9e9e;
|
||||
}
|
||||
|
||||
.namespace {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.token.property,
|
||||
.token.tag,
|
||||
.token.boolean,
|
||||
|
|
@ -480,6 +484,7 @@ thead {
|
|||
.token.deleted {
|
||||
color: #e91e63;
|
||||
}
|
||||
|
||||
.token.selector,
|
||||
.token.attr-name,
|
||||
.token.string,
|
||||
|
|
@ -488,6 +493,7 @@ thead {
|
|||
.token.inserted {
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
.token.operator,
|
||||
.token.entity,
|
||||
.token.url,
|
||||
|
|
@ -495,26 +501,32 @@ thead {
|
|||
.style .token.string {
|
||||
color: #795548;
|
||||
}
|
||||
|
||||
.token.atrule,
|
||||
.token.attr-value,
|
||||
.token.keyword {
|
||||
color: #3f51b5;
|
||||
}
|
||||
|
||||
.token.function {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
.token.regex,
|
||||
.token.important,
|
||||
.token.variable {
|
||||
color: #ff9800;
|
||||
}
|
||||
|
||||
.token.important,
|
||||
.token.bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.token.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.token.entity {
|
||||
cursor: help;
|
||||
}
|
||||
|
|
@ -1,5 +1,4 @@
|
|||
import React from "react";
|
||||
import { getFastAPIUrl } from "@/utils/api";
|
||||
|
||||
export type RemoteSvgOptions = {
|
||||
strokeColor?: string;
|
||||
|
|
@ -145,14 +144,8 @@ export function useRemoteSvgIcon(url?: string, options: RemoteSvgOptions = {}) {
|
|||
return;
|
||||
}
|
||||
try {
|
||||
// If URL starts with /static/, proxy it through FastAPI
|
||||
let fetchUrl = url;
|
||||
if (url.startsWith('/static/')) {
|
||||
const fastApiUrl = getFastAPIUrl();
|
||||
fetchUrl = `${fastApiUrl}${url}`;
|
||||
}
|
||||
|
||||
const res = await fetch(fetchUrl);
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP ${res.status}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { Metadata } from "next";
|
||||
import localFont from "next/font/local";
|
||||
import { Syne, Unbounded } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { Providers } from "./providers";
|
||||
import MixpanelInitializer from "./MixpanelInitializer";
|
||||
|
|
@ -15,26 +16,16 @@ const inter = localFont({
|
|||
variable: "--font-inter",
|
||||
});
|
||||
|
||||
const instrument_sans = localFont({
|
||||
src: [
|
||||
{
|
||||
path: "./fonts/Inter.ttf",
|
||||
weight: "400",
|
||||
style: "normal",
|
||||
},
|
||||
],
|
||||
variable: "--font-instrument-sans",
|
||||
const syne = Syne({
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600", "700", "800"],
|
||||
variable: "--font-syne",
|
||||
});
|
||||
|
||||
const roboto = localFont({
|
||||
src: [
|
||||
{
|
||||
path: "./fonts/Inter.ttf",
|
||||
weight: "400",
|
||||
style: "normal",
|
||||
},
|
||||
],
|
||||
variable: "--font-roboto",
|
||||
const unbounded = Unbounded({
|
||||
subsets: ["latin"],
|
||||
weight: ["400", "500", "600", "700", "800"],
|
||||
variable: "--font-unbounded",
|
||||
});
|
||||
|
||||
|
||||
|
|
@ -91,7 +82,7 @@ export default function RootLayout({
|
|||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${inter.variable} ${roboto.variable} ${instrument_sans.variable} antialiased`}
|
||||
className={`${inter.variable} ${unbounded.variable} ${syne.variable} antialiased`}
|
||||
>
|
||||
<Providers>
|
||||
<MixpanelInitializer>
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@ const ChartWithBulletsSlideLayout: React.FC<ChartWithBulletsSlideLayoutProps> =
|
|||
<YAxis {...axisProps} />
|
||||
{showTooltip && <Tooltip content={<CustomTooltip />} />}
|
||||
{showLegend && <Legend wrapperStyle={{ fontSize: '10px' }} />}
|
||||
<Bar dataKey={yAxis} barSize={70} radius={[8, 8, 0, 0]} isAnimationActive={false} >
|
||||
<Bar dataKey={yAxis} barSize={70} radius={[8, 8, 0, 0]} >
|
||||
{chartData.map((_, index) => (
|
||||
<Cell key={`cell-${index}`} fill={`var(--graph-${index}, ${CHART_COLORS[index % CHART_COLORS.length]})`} />
|
||||
))}
|
||||
|
|
@ -182,7 +182,6 @@ const ChartWithBulletsSlideLayout: React.FC<ChartWithBulletsSlideLayoutProps> =
|
|||
type="monotone"
|
||||
dataKey={yAxis}
|
||||
strokeWidth={3}
|
||||
isAnimationActive={false}
|
||||
dot={{ fill: `var(--graph-0, ${CHART_COLORS[0]})`, strokeWidth: 2, r: 4 }}
|
||||
>
|
||||
{chartData.map((_, index) => (
|
||||
|
|
@ -204,7 +203,6 @@ const ChartWithBulletsSlideLayout: React.FC<ChartWithBulletsSlideLayoutProps> =
|
|||
type="monotone"
|
||||
dataKey={yAxis}
|
||||
fillOpacity={0.6}
|
||||
isAnimationActive={false}
|
||||
>
|
||||
{chartData.map((_, index) => (
|
||||
<Cell key={`cell-${index}`} fill={`var(--graph-${index}, ${CHART_COLORS[index % CHART_COLORS.length]})`} />
|
||||
|
|
@ -224,7 +222,6 @@ const ChartWithBulletsSlideLayout: React.FC<ChartWithBulletsSlideLayoutProps> =
|
|||
fill={`var(--background-text, ${color})`}
|
||||
dataKey={yAxis}
|
||||
label={renderPieLabel}
|
||||
isAnimationActive={false}
|
||||
>
|
||||
{chartData.map((_, index) => (
|
||||
<Cell key={`cell-${index}`} fill={`var(--graph-${index}, ${CHART_COLORS[index % CHART_COLORS.length]})`} />
|
||||
|
|
@ -241,7 +238,7 @@ const ChartWithBulletsSlideLayout: React.FC<ChartWithBulletsSlideLayoutProps> =
|
|||
<YAxis dataKey={yAxis} type="number" {...axisProps} />
|
||||
{showTooltip && <Tooltip content={<CustomTooltip />} />}
|
||||
{showLegend && <Legend wrapperStyle={{ fontSize: '10px' }} />}
|
||||
<Scatter dataKey="value" isAnimationActive={false} >
|
||||
<Scatter dataKey="value" >
|
||||
{chartData.map((_, index) => (
|
||||
<Cell key={`cell-${index}`} fill={`var(--graph-${index}, ${CHART_COLORS[index % CHART_COLORS.length]})`} />
|
||||
))}
|
||||
|
|
@ -297,13 +294,13 @@ const ChartWithBulletsSlideLayout: React.FC<ChartWithBulletsSlideLayoutProps> =
|
|||
</p>
|
||||
|
||||
{/* Chart Container */}
|
||||
<div className="flex-1 min-h-[280px] rounded-lg shadow-sm border border-gray-100 p-4"
|
||||
<div className="flex-1 rounded-lg shadow-sm border border-gray-100 p-4"
|
||||
style={{
|
||||
borderColor: 'var(--stroke, #F8F9FA)',
|
||||
}}
|
||||
>
|
||||
{/* <ChartContainer config={chartConfig} className="h-full w-full"> */}
|
||||
<ResponsiveContainer width="100%" height="100%" className="">
|
||||
<ResponsiveContainer maxHeight={460} height='100%' className="">
|
||||
|
||||
{renderChart()}
|
||||
</ResponsiveContainer>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
||||
|
||||
];
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ import {
|
|||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import { getApiUrl } from "@/utils/api";
|
||||
import { Switch } from "./ui/switch";
|
||||
import { getApiUrl } from "@/utils/api";
|
||||
|
||||
interface AnthropicConfigProps {
|
||||
anthropicApiKey: string;
|
||||
|
|
@ -54,7 +54,7 @@ export default function AnthropicConfig({
|
|||
|
||||
setModelsLoading(true);
|
||||
try {
|
||||
const response = await fetch(getApiUrl('api/v1/ppt/anthropic/models/available'), {
|
||||
const response = await fetch(getApiUrl('/api/v1/ppt/anthropic/models/available'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -85,167 +85,172 @@ export default function AnthropicConfig({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6 ">
|
||||
{/* API Key Input */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Anthropic API Key
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={anthropicApiKey}
|
||||
onChange={(e) => onApiKeyChange(e.target.value)}
|
||||
className="w-full px-4 py-2.5 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
|
||||
placeholder="Enter your Anthropic API key"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-gray-500 flex items-center gap-2">
|
||||
<span className="block w-1 h-1 rounded-full bg-gray-400"></span>
|
||||
Your API key will be stored locally and never shared
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Extended Reasoning Toggle */}
|
||||
{/* <div>
|
||||
<div className="flex items-center justify-between mb-4 bg-green-50 p-2 rounded-sm">
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
Extended Reasoning
|
||||
</label>
|
||||
<Switch
|
||||
checked={extendedReasoning}
|
||||
onCheckedChange={(checked) => onInputChange(checked, "extended_reasoning")}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-gray-500 flex items-center gap-2">
|
||||
<span className="block w-1 h-1 rounded-full bg-gray-400"></span>
|
||||
Enable extended reasoning for more detailed and thorough responses
|
||||
</p>
|
||||
</div> */}
|
||||
|
||||
{/* Check for available models button - show when no models checked or no models found */}
|
||||
{(!modelsChecked || (modelsChecked && availableModels.length === 0)) && (
|
||||
<div className="mb-4">
|
||||
<button
|
||||
onClick={fetchAvailableModels}
|
||||
disabled={modelsLoading || !anthropicApiKey}
|
||||
className={`w-full py-2.5 px-4 rounded-lg transition-all duration-200 border-2 ${modelsLoading || !anthropicApiKey
|
||||
? "bg-gray-100 border-gray-300 cursor-not-allowed text-gray-500"
|
||||
: "bg-white border-blue-600 text-blue-600 hover:bg-blue-50 focus:ring-2 focus:ring-blue-500/20"
|
||||
}`}
|
||||
>
|
||||
{modelsLoading ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Checking for models...
|
||||
</div>
|
||||
) : (
|
||||
"Check for available models"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show message if no models found */}
|
||||
{modelsChecked && availableModels.length === 0 && (
|
||||
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<p className="text-sm text-yellow-800">
|
||||
No models found. Please make sure your API key is valid and has access to Anthropic models.
|
||||
<div className="mb-4 flex items-center justify-between bg-white p-10">
|
||||
<div className="">
|
||||
<h3 className="text-xl font-normal text-[#191919]">Anthropic API key</h3>
|
||||
<p className="mt-2 text-sm max-w-[205px] text-gray-500">
|
||||
Your API key will be stored locally and never shared
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative w-[275px] ">
|
||||
<div className="flex flex-col justify-start gap-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Anthropic API Key
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={anthropicApiKey}
|
||||
onChange={(e) => onApiKeyChange(e.target.value)}
|
||||
className="w-full px-2 py-3 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
|
||||
placeholder="Enter your Anthropic API key"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Model Selection - only show if models are available */}
|
||||
{modelsChecked && availableModels.length > 0 ? (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
Select Anthropic Model
|
||||
</label>
|
||||
<div className="w-full">
|
||||
<Popover
|
||||
open={openModelSelect}
|
||||
onOpenChange={setOpenModelSelect}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openModelSelect}
|
||||
className="w-full h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between"
|
||||
>
|
||||
<div className="flex gap-3 items-center">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{anthropicModel
|
||||
? availableModels.find(model => model === anthropicModel) || anthropicModel
|
||||
: "Select a model"}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="w-4 h-4 text-gray-500" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
align="start"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
{/* Check for available models button - show when no models checked or no models found */}
|
||||
{(!modelsChecked || (modelsChecked && availableModels.length === 0)) && (
|
||||
<button
|
||||
onClick={fetchAvailableModels}
|
||||
disabled={modelsLoading || !anthropicApiKey}
|
||||
className={` mt-7 py-2.5 bg-[#F7F6F9] px-3.5 rounded-[48px] text-xs font-semibold text-[#101323] transition-all duration-200 border ${modelsLoading || !anthropicApiKey
|
||||
? " border-gray-300 cursor-not-allowed text-gray-500"
|
||||
: " border-[#EDEEEF] text-blue-600 hover:bg-[#E8F0FF]/90 focus:ring-2 focus:ring-blue-500/20"
|
||||
}`}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search models..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No model found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableModels.map((model, index) => (
|
||||
<CommandItem
|
||||
key={index}
|
||||
value={model}
|
||||
onSelect={(value) => {
|
||||
onInputChange(value, "anthropic_model");
|
||||
setOpenModelSelect(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
anthropicModel === model
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="flex flex-col space-y-1 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{model}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{modelsLoading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Checking for models...
|
||||
</span>
|
||||
) : (
|
||||
"Check for available models"
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-[295px]">
|
||||
{/* Show message if no models found */}
|
||||
{modelsChecked && availableModels.length === 0 && (
|
||||
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<p className="text-sm text-yellow-800">
|
||||
No models found. Please make sure your API key is valid and has access to Anthropic models.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model Selection - only show if models are available */}
|
||||
{modelsChecked && availableModels.length > 0 ? (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
Select Anthropic Model
|
||||
</label>
|
||||
<div className="w-full">
|
||||
<Popover
|
||||
open={openModelSelect}
|
||||
onOpenChange={setOpenModelSelect}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openModelSelect}
|
||||
className="w-full h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between"
|
||||
>
|
||||
<div className="flex gap-3 items-center">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{anthropicModel
|
||||
? availableModels.find(model => model === anthropicModel) || anthropicModel
|
||||
: "Select a model"}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="w-4 h-4 text-gray-500" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
align="start"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search models..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No model found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableModels.map((model, index) => (
|
||||
<CommandItem
|
||||
key={index}
|
||||
value={model}
|
||||
onSelect={(value) => {
|
||||
onInputChange(value, "anthropic_model");
|
||||
setOpenModelSelect(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
anthropicModel === model
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="flex flex-col space-y-1 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{model}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Web Grounding Toggle - at the end, below models dropdown */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4 bg-green-50 p-2 rounded-sm">
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
Enable Web Grounding
|
||||
</label>
|
||||
<Switch
|
||||
checked={!!webGrounding}
|
||||
onCheckedChange={(checked) => onInputChange(checked, "web_grounding")}
|
||||
/>
|
||||
{/* Web Grounding Toggle - show at the end, below models dropdown */}
|
||||
<div className="bg-white flex justify-between items-center p-10 rounded-[12px]">
|
||||
<div>
|
||||
<h4 className="text-xl font-normal text-[#191919]">Model Controls</h4>
|
||||
<p className="mt-2 text-sm max-w-[205px] text-gray-500">
|
||||
Configure web access and advanced AI features.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-[275px]">
|
||||
<div className="flex items-center mb-4 gap-2.5 ">
|
||||
<Switch
|
||||
checked={!!webGrounding}
|
||||
onCheckedChange={(checked) => onInputChange(checked, "web_grounding")}
|
||||
/>
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
Enable Web Grounding
|
||||
</label>
|
||||
</div>
|
||||
{/* Extended Reasoning Toggle */}
|
||||
{/* <div className="flex items-center mb-4 gap-2.5 ">
|
||||
<Switch
|
||||
checked={extendedReasoning}
|
||||
onCheckedChange={(checked) => onInputChange(checked, "extended_reasoning")}
|
||||
/>
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
Extended Reasoning
|
||||
</label>
|
||||
</div> */}
|
||||
</div>
|
||||
<div className="w-[295px]"></div>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-gray-500 flex items-center gap-2">
|
||||
<span className="block w-1 h-1 rounded-full bg-gray-400"></span>
|
||||
If enabled, the model can use web search grounding when available.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ export default function CodexConfig({
|
|||
|
||||
const checkCurrentAuthStatus = async () => {
|
||||
try {
|
||||
const res = await fetch(getApiUrl("api/v1/ppt/codex/auth/status"));
|
||||
const res = await fetch(getApiUrl("/api/v1/ppt/codex/auth/status"));
|
||||
if (!res.ok) {
|
||||
setAuthStatus("unauthenticated");
|
||||
return;
|
||||
|
|
@ -102,7 +102,7 @@ export default function CodexConfig({
|
|||
|
||||
const handleSignIn = async () => {
|
||||
try {
|
||||
const res = await fetch(getApiUrl("api/v1/ppt/codex/auth/initiate"), {
|
||||
const res = await fetch(getApiUrl("/api/v1/ppt/codex/auth/initiate"), {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to initiate auth");
|
||||
|
|
@ -117,7 +117,7 @@ export default function CodexConfig({
|
|||
pollIntervalRef.current = setInterval(async () => {
|
||||
try {
|
||||
const pollRes = await fetch(
|
||||
getApiUrl(`api/v1/ppt/codex/auth/status/${session_id}`)
|
||||
getApiUrl(`/api/v1/ppt/codex/auth/status/${session_id}`)
|
||||
);
|
||||
if (!pollRes.ok) return;
|
||||
const pollData: StatusResponse = await pollRes.json();
|
||||
|
|
@ -151,7 +151,7 @@ export default function CodexConfig({
|
|||
if (!sessionId || !manualCode.trim()) return;
|
||||
setIsExchanging(true);
|
||||
try {
|
||||
const res = await fetch(getApiUrl("api/v1/ppt/codex/auth/exchange"), {
|
||||
const res = await fetch(getApiUrl("/api/v1/ppt/codex/auth/exchange"), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ session_id: sessionId, code: manualCode.trim() }),
|
||||
|
|
@ -187,7 +187,7 @@ export default function CodexConfig({
|
|||
const handleSignOut = async () => {
|
||||
setIsLoggingOut(true);
|
||||
try {
|
||||
await fetch(getApiUrl("api/v1/ppt/codex/auth/logout"), { method: "POST" });
|
||||
await fetch(getApiUrl("/api/v1/ppt/codex/auth/logout"), { method: "POST" });
|
||||
setAuthStatus("unauthenticated");
|
||||
setAccountId(null);
|
||||
onInputChange("", "codex_model");
|
||||
|
|
@ -202,7 +202,7 @@ export default function CodexConfig({
|
|||
const handleRefreshToken = async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
const res = await fetch(getApiUrl("api/v1/ppt/codex/auth/refresh"), {
|
||||
const res = await fetch(getApiUrl("/api/v1/ppt/codex/auth/refresh"), {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) throw new Error("Refresh failed");
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ export default function CustomConfig({
|
|||
|
||||
try {
|
||||
setCustomModelsLoading(true);
|
||||
const response = await fetch(getApiUrl("api/v1/ppt/openai/models/available"), {
|
||||
const response = await fetch(getApiUrl("/api/v1/ppt/openai/models/available"), {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ import {
|
|||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import { getApiUrl } from "@/utils/api";
|
||||
import { Switch } from "./ui/switch";
|
||||
import { getApiUrl } from "@/utils/api";
|
||||
|
||||
interface GoogleConfigProps {
|
||||
googleApiKey: string;
|
||||
|
|
@ -51,7 +51,7 @@ export default function GoogleConfig({
|
|||
|
||||
setModelsLoading(true);
|
||||
try {
|
||||
const response = await fetch(getApiUrl('api/v1/ppt/google/models/available'), {
|
||||
const response = await fetch(getApiUrl('/api/v1/ppt/google/models/available'), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
|
|
@ -82,150 +82,162 @@ export default function GoogleConfig({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-6 ">
|
||||
{/* API Key Input */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Google API Key
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={googleApiKey}
|
||||
onChange={(e) => onApiKeyChange(e.target.value)}
|
||||
className="w-full px-4 py-2.5 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
|
||||
placeholder="Enter your API key"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-gray-500 flex items-center gap-2">
|
||||
<span className="block w-1 h-1 rounded-full bg-gray-400"></span>
|
||||
Your API key will be stored locally and never shared
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Check for available models button - show when no models checked or no models found */}
|
||||
{(!modelsChecked || (modelsChecked && availableModels.length === 0)) && (
|
||||
<div className="mb-4">
|
||||
<button
|
||||
onClick={fetchAvailableModels}
|
||||
disabled={modelsLoading || !googleApiKey}
|
||||
className={`w-full py-2.5 px-4 rounded-lg transition-all duration-200 border-2 ${modelsLoading || !googleApiKey
|
||||
? "bg-gray-100 border-gray-300 cursor-not-allowed text-gray-500"
|
||||
: "bg-white border-blue-600 text-blue-600 hover:bg-blue-50 focus:ring-2 focus:ring-blue-500/20"
|
||||
}`}
|
||||
>
|
||||
{modelsLoading ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Checking for models...
|
||||
</div>
|
||||
) : (
|
||||
"Check for available models"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show message if no models found */}
|
||||
{modelsChecked && availableModels.length === 0 && (
|
||||
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<p className="text-sm text-yellow-800">
|
||||
No models found. Please make sure your API key is valid and has access to Google models.
|
||||
<div className="mb-4 flex items-center justify-between bg-white p-10">
|
||||
<div className="">
|
||||
<h3 className="text-xl font-normal text-[#191919]">Google API key</h3>
|
||||
<p className="mt-2 text-sm max-w-[205px] text-gray-500">
|
||||
Your API key will be stored locally and never shared
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative w-[275px] ">
|
||||
<div className="flex flex-col justify-start gap-2">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Google API Key
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={googleApiKey}
|
||||
onChange={(e) => onApiKeyChange(e.target.value)}
|
||||
className="w-full px-2 py-3 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
|
||||
placeholder="Enter your API key"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Model Selection - only show if models are available */}
|
||||
{modelsChecked && availableModels.length > 0 ? (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
Select Google Model
|
||||
</label>
|
||||
<div className="w-full">
|
||||
<Popover
|
||||
open={openModelSelect}
|
||||
onOpenChange={setOpenModelSelect}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openModelSelect}
|
||||
className="w-full h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between"
|
||||
>
|
||||
<div className="flex gap-3 items-center">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{googleModel
|
||||
? availableModels.find(model => model === googleModel) || googleModel
|
||||
: "Select a model"}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="w-4 h-4 text-gray-500" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
align="start"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
{/* Check for available models button - show when no models checked or no models found */}
|
||||
{(!modelsChecked || (modelsChecked && availableModels.length === 0)) && (
|
||||
<button
|
||||
onClick={fetchAvailableModels}
|
||||
disabled={modelsLoading || !googleApiKey}
|
||||
className={` mt-7 py-2.5 bg-[#F7F6F9] px-3.5 rounded-[48px] text-xs font-semibold text-[#101323] transition-all duration-200 border ${modelsLoading || !googleApiKey
|
||||
? " border-gray-300 cursor-not-allowed text-gray-500"
|
||||
: " border-[#EDEEEF] text-blue-600 hover:bg-[#E8F0FF]/90 focus:ring-2 focus:ring-blue-500/20"
|
||||
}`}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search models..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No model found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableModels.map((model, index) => (
|
||||
<CommandItem
|
||||
key={index}
|
||||
value={model}
|
||||
onSelect={(value) => {
|
||||
onInputChange(value, "google_model");
|
||||
setOpenModelSelect(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
googleModel === model
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="flex flex-col space-y-1 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{model}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{modelsLoading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Checking for models...
|
||||
</span>
|
||||
) : (
|
||||
"Check for available models"
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-[295px]">
|
||||
{/* Show message if no models found */}
|
||||
{modelsChecked && availableModels.length === 0 && (
|
||||
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<p className="text-sm text-yellow-800">
|
||||
No models found. Please make sure your API key is valid and has access to Google models.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model Selection - only show if models are available */}
|
||||
{modelsChecked && availableModels.length > 0 ? (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
Select Google Model
|
||||
</label>
|
||||
<div className="w-full">
|
||||
<Popover
|
||||
open={openModelSelect}
|
||||
onOpenChange={setOpenModelSelect}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openModelSelect}
|
||||
className="w-full h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between"
|
||||
>
|
||||
<div className="flex gap-3 items-center">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{googleModel
|
||||
? availableModels.find(model => model === googleModel) || googleModel
|
||||
: "Select a model"}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="w-4 h-4 text-gray-500" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
align="start"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search models..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No model found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{availableModels.map((model, index) => (
|
||||
<CommandItem
|
||||
key={index}
|
||||
value={model}
|
||||
onSelect={(value) => {
|
||||
onInputChange(value, "google_model");
|
||||
setOpenModelSelect(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
googleModel === model
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="flex flex-col space-y-1 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{model}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Web Grounding Toggle - at the end, below models dropdown */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4 bg-green-50 p-2 rounded-sm">
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
Enable Web Grounding
|
||||
</label>
|
||||
<Switch
|
||||
checked={!!webGrounding}
|
||||
onCheckedChange={(checked) => onInputChange(checked, "web_grounding")}
|
||||
/>
|
||||
{/* Web Grounding Toggle - show at the end, below models dropdown */}
|
||||
<div className="bg-white flex justify-between items-center p-10 rounded-[12px]">
|
||||
<div>
|
||||
<h4 className="text-xl font-normal text-[#191919]">Model Controls</h4>
|
||||
<p className="mt-2 text-sm max-w-[205px] text-gray-500">
|
||||
Configure web access and advanced AI features.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-[275px]">
|
||||
<div className="flex items-center mb-4 gap-2.5 ">
|
||||
<Switch
|
||||
checked={!!webGrounding}
|
||||
onCheckedChange={(checked) => onInputChange(checked, "web_grounding")}
|
||||
/>
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
Enable Web Grounding
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-[295px]"></div>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-gray-500 flex items-center gap-2">
|
||||
<span className="block w-1 h-1 rounded-full bg-gray-400"></span>
|
||||
If enabled, the model can use web search grounding when available.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,12 @@ import {
|
|||
import { LLMConfig } from "@/types/llm_config";
|
||||
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
|
||||
import { usePathname } from "next/navigation";
|
||||
import OnBoardingSlidebar from "./OnBoarding/OnBoardingSlidebar";
|
||||
import OnBoardingHeader from "./OnBoarding/OnBoardingHeader";
|
||||
import ModeSelectStep from "./OnBoarding/ModeSelectStep";
|
||||
import PresentonMode from "./OnBoarding/PresentonMode";
|
||||
import GenerationWithImage from "./OnBoarding/GenerationWithImage";
|
||||
import FinalStep from "./OnBoarding/FinalStep";
|
||||
|
||||
// Button state interface
|
||||
interface ButtonState {
|
||||
|
|
@ -25,9 +31,59 @@ interface ButtonState {
|
|||
status?: string;
|
||||
}
|
||||
|
||||
const FINAL_STEP_CONFETTI_PIECES = [
|
||||
// left: denser at top
|
||||
{ side: "left", offset: 1, top: 3, width: 28, height: 10, color: "#F59E0B", rotate: 12 },
|
||||
{ side: "left", offset: 7, top: 5, width: 18, height: 7, color: "#7C3AED", rotate: -10 },
|
||||
{ side: "left", offset: 12, top: 7, width: 20, height: 7, color: "#14B8A6", rotate: 22 },
|
||||
{ side: "left", offset: 3, top: 10, width: 22, height: 8, color: "#22C55E", rotate: -18 },
|
||||
{ side: "left", offset: 9, top: 12, width: 24, height: 8, color: "#E11D48", rotate: 18 },
|
||||
{ side: "left", offset: 14, top: 15, width: 18, height: 7, color: "#F43F5E", rotate: 23 },
|
||||
{ side: "left", offset: 5, top: 18, width: 20, height: 7, color: "#0EA5E9", rotate: -12 },
|
||||
{ side: "left", offset: 11, top: 21, width: 26, height: 9, color: "#2563EB", rotate: 20 },
|
||||
{ side: "left", offset: 2, top: 24, width: 19, height: 7, color: "#14B8A6", rotate: -16 },
|
||||
{ side: "left", offset: 8, top: 28, width: 21, height: 8, color: "#FB7185", rotate: 27 },
|
||||
{ side: "left", offset: 13, top: 32, width: 20, height: 7, color: "#06B6D4", rotate: 16 },
|
||||
{ side: "left", offset: 3, top: 36, width: 24, height: 9, color: "#EAB308", rotate: -22 },
|
||||
{ side: "left", offset: 10, top: 41, width: 18, height: 7, color: "#A855F7", rotate: -14 },
|
||||
{ side: "left", offset: 2, top: 50, width: 30, height: 10, color: "#EC4899", rotate: -28 },
|
||||
{ side: "left", offset: 13, top: 58, width: 19, height: 7, color: "#22C55E", rotate: 17 },
|
||||
{ side: "left", offset: 5, top: 66, width: 24, height: 8, color: "#8B5CF6", rotate: 14 },
|
||||
{ side: "left", offset: 11, top: 74, width: 18, height: 7, color: "#3B82F6", rotate: 12 },
|
||||
{ side: "left", offset: 4, top: 82, width: 20, height: 7, color: "#14B8A6", rotate: 21 },
|
||||
{ side: "left", offset: 7, top: 90, width: 24, height: 8, color: "#D946EF", rotate: -26 },
|
||||
|
||||
// right: denser at top
|
||||
{ side: "right", offset: 1, top: 4, width: 30, height: 10, color: "#F97316", rotate: -14 },
|
||||
{ side: "right", offset: 8, top: 6, width: 19, height: 7, color: "#0EA5E9", rotate: 12 },
|
||||
{ side: "right", offset: 13, top: 9, width: 20, height: 7, color: "#22C55E", rotate: -20 },
|
||||
{ side: "right", offset: 4, top: 12, width: 24, height: 8, color: "#EC4899", rotate: 20 },
|
||||
{ side: "right", offset: 10, top: 15, width: 22, height: 8, color: "#06B6D4", rotate: -18 },
|
||||
{ side: "right", offset: 15, top: 18, width: 20, height: 7, color: "#22C55E", rotate: -25 },
|
||||
{ side: "right", offset: 5, top: 21, width: 18, height: 7, color: "#8B5CF6", rotate: 19 },
|
||||
{ side: "right", offset: 12, top: 24, width: 21, height: 8, color: "#F43F5E", rotate: 14 },
|
||||
{ side: "right", offset: 2, top: 28, width: 26, height: 9, color: "#84CC16", rotate: 15 },
|
||||
{ side: "right", offset: 9, top: 33, width: 21, height: 8, color: "#F97316", rotate: -11 },
|
||||
{ side: "right", offset: 14, top: 38, width: 20, height: 7, color: "#A855F7", rotate: -19 },
|
||||
{ side: "right", offset: 4, top: 44, width: 19, height: 7, color: "#F43F5E", rotate: 20 },
|
||||
{ side: "right", offset: 2, top: 52, width: 28, height: 10, color: "#FACC15", rotate: 25 },
|
||||
{ side: "right", offset: 12, top: 60, width: 18, height: 7, color: "#14B8A6", rotate: -15 },
|
||||
{ side: "right", offset: 6, top: 68, width: 24, height: 8, color: "#22C55E", rotate: -17 },
|
||||
{ side: "right", offset: 1, top: 76, width: 20, height: 7, color: "#A855F7", rotate: 14 },
|
||||
{ side: "right", offset: 13, top: 84, width: 20, height: 7, color: "#3B82F6", rotate: -24 },
|
||||
{ side: "right", offset: 5, top: 92, width: 26, height: 9, color: "#EAB308", rotate: 18 },
|
||||
] as const;
|
||||
|
||||
const getTaperedSideOffset = (offset: number, top: number) => {
|
||||
const taperMultiplier = Math.max(0.72, 1.85 - top * 0.012);
|
||||
return Math.min(29, Number((offset * taperMultiplier).toFixed(2)));
|
||||
};
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const [step, setStep] = useState<number>(1)
|
||||
const [selectedMode, setSelectedMode] = useState<string>("presenton")
|
||||
const config = useSelector((state: RootState) => state.userConfig);
|
||||
const [llmConfig, setLlmConfig] = useState<LLMConfig>(config.llm_config);
|
||||
|
||||
|
|
@ -144,124 +200,154 @@ export default function Home() {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen bg-gradient-to-b font-instrument_sans from-gray-50 to-white flex flex-col overflow-hidden">
|
||||
<main className="flex-1 container mx-auto px-4 max-w-3xl overflow-hidden flex flex-col">
|
||||
{/* Branding Header */}
|
||||
<div className="text-center mb-2 mt-4 flex-shrink-0">
|
||||
<div className="flex items-center justify-center gap-3 mb-2">
|
||||
<img src="/Logo.png" alt="Presenton Logo" className="h-12" />
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm">
|
||||
Open-source AI presentation generator
|
||||
</p>
|
||||
</div>
|
||||
// <div className="h-screen bg-gradient-to-b font-instrument_sans from-gray-50 to-white flex flex-col overflow-hidden">
|
||||
// <main className="flex-1 container mx-auto px-4 max-w-3xl overflow-hidden flex flex-col">
|
||||
// {/* Branding Header */}
|
||||
// <div className="text-center mb-2 mt-4 flex-shrink-0">
|
||||
// <div className="flex items-center justify-center gap-3 mb-2">
|
||||
// <img src="/Logo.png" alt="Presenton Logo" className="h-12" />
|
||||
// </div>
|
||||
// <p className="text-gray-600 text-sm">
|
||||
// Open-source AI presentation generator
|
||||
// </p>
|
||||
// </div>
|
||||
|
||||
{/* Main Configuration Card */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<LLMProviderSelection
|
||||
initialLLMConfig={llmConfig}
|
||||
onConfigChange={setLlmConfig}
|
||||
buttonState={buttonState}
|
||||
setButtonState={setButtonState}
|
||||
/>
|
||||
</div>
|
||||
// {/* Main Configuration Card */}
|
||||
// <div className="flex-1 overflow-hidden">
|
||||
// <LLMProviderSelection
|
||||
// initialLLMConfig={llmConfig}
|
||||
// onConfigChange={setLlmConfig}
|
||||
// buttonState={buttonState}
|
||||
// setButtonState={setButtonState}
|
||||
// />
|
||||
// </div>
|
||||
// </main>
|
||||
|
||||
// {/* Download Progress Modal */}
|
||||
// {showDownloadModal && downloadingModel && (
|
||||
// <div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
// <div className="bg-white/95 backdrop-blur-md rounded-xl shadow-2xl max-w-md w-full p-6 relative">
|
||||
// {/* Modal Content */}
|
||||
// <div className="text-center">
|
||||
// {/* Icon */}
|
||||
// <div className="mb-4">
|
||||
// {downloadingModel.done ? (
|
||||
// <CheckCircle className="w-12 h-12 text-green-600 mx-auto" />
|
||||
// ) : (
|
||||
// <Download className="w-12 h-12 text-blue-600 mx-auto animate-pulse" />
|
||||
// )}
|
||||
// </div>
|
||||
|
||||
// {/* Title */}
|
||||
// <h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
// {downloadingModel.done ? "Download Complete!" : "Downloading Model"}
|
||||
// </h3>
|
||||
|
||||
// {/* Model Name */}
|
||||
// <p className="text-sm text-gray-600 mb-6">
|
||||
// {llmConfig.OLLAMA_MODEL}
|
||||
// </p>
|
||||
|
||||
// {/* Progress Bar */}
|
||||
// {downloadProgress > 0 && (
|
||||
// <div className="mb-4">
|
||||
// <div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
|
||||
// <div
|
||||
// className="bg-blue-600 h-3 rounded-full transition-all duration-300 ease-out"
|
||||
// style={{ width: `${downloadProgress}%` }}
|
||||
// />
|
||||
// </div>
|
||||
// <p className="text-sm text-gray-600 mt-2">
|
||||
// {downloadProgress}% Complete
|
||||
// </p>
|
||||
// </div>
|
||||
// )}
|
||||
|
||||
// {/* Status */}
|
||||
// {downloadingModel.status && (
|
||||
// <div className="flex items-center justify-center gap-2 mb-4">
|
||||
// <CheckCircle className="w-4 h-4 text-green-600" />
|
||||
// <span className="text-sm font-medium text-green-700 capitalize">
|
||||
// {downloadingModel.status}
|
||||
// </span>
|
||||
// </div>
|
||||
// )}
|
||||
|
||||
// {/* Status Message */}
|
||||
// {downloadingModel.status && downloadingModel.status !== "pulled" && (
|
||||
// <div className="text-xs text-gray-500">
|
||||
// {downloadingModel.status === "downloading" && "Downloading model files..."}
|
||||
// {downloadingModel.status === "verifying" && "Verifying model integrity..."}
|
||||
// {downloadingModel.status === "pulling" && "Pulling model from registry..."}
|
||||
// </div>
|
||||
// )}
|
||||
|
||||
// {/* Download Info */}
|
||||
// {downloadingModel.downloaded && downloadingModel.size && (
|
||||
// <div className="mt-4 p-3 bg-gray-50 rounded-lg">
|
||||
// <div className="flex justify-between text-xs text-gray-600">
|
||||
// <span>Downloaded: {(downloadingModel.downloaded / 1024 / 1024).toFixed(1)} MB</span>
|
||||
// <span>Total: {(downloadingModel.size / 1024 / 1024).toFixed(1)} MB</span>
|
||||
// </div>
|
||||
// </div>
|
||||
// )}
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>
|
||||
// )}
|
||||
|
||||
// {/* Fixed Bottom Button */}
|
||||
// <div className="flex-shrink-0 bg-white border-t border-gray-200 p-4">
|
||||
// <div className="container mx-auto max-w-3xl">
|
||||
// <button
|
||||
// onClick={handleSaveConfig}
|
||||
// disabled={buttonState.isDisabled}
|
||||
// className={`w-full font-semibold py-3 px-4 rounded-lg transition-all duration-500 ${buttonState.isDisabled
|
||||
// ? "bg-gray-400 cursor-not-allowed"
|
||||
// : "bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 focus:ring-4 focus:ring-blue-200"
|
||||
// } text-white`}
|
||||
// >
|
||||
// {buttonState.isLoading ? (
|
||||
// <div className="flex items-center justify-center gap-2">
|
||||
// <Loader2 className="w-4 h-4 animate-spin" />
|
||||
// {buttonState.text}
|
||||
// </div>
|
||||
// ) : (
|
||||
// buttonState.text
|
||||
// )}
|
||||
// </button>
|
||||
// </div>
|
||||
// </div>
|
||||
// </div>
|
||||
<div className="flex h-screen">
|
||||
<OnBoardingSlidebar />
|
||||
<main className="w-full pl-20 pr-8 max-w-[1440px] mx-auto relative z-10">
|
||||
{step === 3 && (
|
||||
<div className="pointer-events-none fixed inset-0 z-0 overflow-hidden" aria-hidden>
|
||||
{FINAL_STEP_CONFETTI_PIECES.map((piece, index) => (
|
||||
<span
|
||||
key={`${piece.side}-${index}`}
|
||||
className="absolute rounded-[3px]"
|
||||
style={{
|
||||
top: `${piece.top}%`,
|
||||
...(piece.side === "left"
|
||||
? { left: `${getTaperedSideOffset(piece.offset, piece.top)}%` }
|
||||
: { right: `${getTaperedSideOffset(piece.offset, piece.top)}%` }),
|
||||
width: `${piece.width}px`,
|
||||
height: `${piece.height}px`,
|
||||
backgroundColor: piece.color,
|
||||
transform: `rotate(${piece.rotate}deg)`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<OnBoardingHeader currentStep={step} />
|
||||
{step === 1 && <ModeSelectStep setStep={setStep} setSelectedMode={setSelectedMode} />}
|
||||
{step === 2 && selectedMode === "presenton" && <PresentonMode currentStep={step} setStep={setStep} />}
|
||||
{step === 2 && selectedMode === "image" && <GenerationWithImage />}
|
||||
{step === 3 && <FinalStep />}
|
||||
</main>
|
||||
|
||||
{/* Download Progress Modal */}
|
||||
{showDownloadModal && downloadingModel && (
|
||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white/95 backdrop-blur-md rounded-xl shadow-2xl max-w-md w-full p-6 relative">
|
||||
{/* Modal Content */}
|
||||
<div className="text-center">
|
||||
{/* Icon */}
|
||||
<div className="mb-4">
|
||||
{downloadingModel.done ? (
|
||||
<CheckCircle className="w-12 h-12 text-green-600 mx-auto" />
|
||||
) : (
|
||||
<Download className="w-12 h-12 text-blue-600 mx-auto animate-pulse" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
{downloadingModel.done ? "Download Complete!" : "Downloading Model"}
|
||||
</h3>
|
||||
|
||||
{/* Model Name */}
|
||||
<p className="text-sm text-gray-600 mb-6">
|
||||
{llmConfig.OLLAMA_MODEL}
|
||||
</p>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{downloadProgress > 0 && (
|
||||
<div className="mb-4">
|
||||
<div className="w-full bg-gray-200 rounded-full h-3 overflow-hidden">
|
||||
<div
|
||||
className="bg-blue-600 h-3 rounded-full transition-all duration-300 ease-out"
|
||||
style={{ width: `${downloadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 mt-2">
|
||||
{downloadProgress}% Complete
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status */}
|
||||
{downloadingModel.status && (
|
||||
<div className="flex items-center justify-center gap-2 mb-4">
|
||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||
<span className="text-sm font-medium text-green-700 capitalize">
|
||||
{downloadingModel.status}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Message */}
|
||||
{downloadingModel.status && downloadingModel.status !== "pulled" && (
|
||||
<div className="text-xs text-gray-500">
|
||||
{downloadingModel.status === "downloading" && "Downloading model files..."}
|
||||
{downloadingModel.status === "verifying" && "Verifying model integrity..."}
|
||||
{downloadingModel.status === "pulling" && "Pulling model from registry..."}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Download Info */}
|
||||
{downloadingModel.downloaded && downloadingModel.size && (
|
||||
<div className="mt-4 p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex justify-between text-xs text-gray-600">
|
||||
<span>Downloaded: {(downloadingModel.downloaded / 1024 / 1024).toFixed(1)} MB</span>
|
||||
<span>Total: {(downloadingModel.size / 1024 / 1024).toFixed(1)} MB</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fixed Bottom Button */}
|
||||
<div className="flex-shrink-0 bg-white border-t border-gray-200 p-4">
|
||||
<div className="container mx-auto max-w-3xl">
|
||||
<button
|
||||
onClick={handleSaveConfig}
|
||||
disabled={buttonState.isDisabled}
|
||||
className={`w-full font-semibold py-3 px-4 rounded-lg transition-all duration-500 ${buttonState.isDisabled
|
||||
? "bg-gray-400 cursor-not-allowed"
|
||||
: "bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 focus:ring-4 focus:ring-blue-200"
|
||||
} text-white`}
|
||||
>
|
||||
{buttonState.isLoading ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{buttonState.text}
|
||||
</div>
|
||||
) : (
|
||||
buttonState.text
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
357
electron/servers/nextjs/components/ImageSelectionConfig.tsx
Normal file
357
electron/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,12 @@ import {
|
|||
updateLLMConfig,
|
||||
changeProvider as changeProviderUtil,
|
||||
} from "@/utils/providerUtils";
|
||||
import { IMAGE_PROVIDERS, LLM_PROVIDERS } from "@/utils/providerConstants";
|
||||
import { LLMConfig } from "@/types/llm_config";
|
||||
import ImageSelectionConfig from "./ImageSelectionConfig";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
|
||||
|
||||
const DALLE_3_QUALITY_OPTIONS = [
|
||||
{
|
||||
label: "Standard",
|
||||
value: "standard",
|
||||
description: "Faster generation with lower cost",
|
||||
},
|
||||
{
|
||||
label: "HD",
|
||||
value: "hd",
|
||||
description: "Higher quality images with increased cost",
|
||||
},
|
||||
];
|
||||
|
||||
const GPT_IMAGE_1_5_QUALITY_OPTIONS = [
|
||||
{
|
||||
label: "Low",
|
||||
value: "low",
|
||||
description: "Fastest and most cost-effective",
|
||||
},
|
||||
{
|
||||
label: "Medium",
|
||||
value: "medium",
|
||||
description: "Balanced quality and speed",
|
||||
},
|
||||
{
|
||||
label: "High",
|
||||
value: "high",
|
||||
description: "Best quality with longer generation time",
|
||||
},
|
||||
];
|
||||
|
||||
// Button state interface
|
||||
interface ButtonState {
|
||||
|
|
@ -77,6 +36,7 @@ interface LLMProviderSelectionProps {
|
|||
) => void;
|
||||
}
|
||||
|
||||
|
||||
export default function LLMProviderSelection({
|
||||
initialLLMConfig,
|
||||
onConfigChange,
|
||||
|
|
@ -85,7 +45,6 @@ export default function LLMProviderSelection({
|
|||
const [llmConfig, setLlmConfig] = useState<LLMConfig>(initialLLMConfig);
|
||||
const [openImageProviderSelect, setOpenImageProviderSelect] = useState(false);
|
||||
const isImageGenerationDisabled = llmConfig.DISABLE_IMAGE_GENERATION ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
onConfigChange(llmConfig);
|
||||
}, [llmConfig]);
|
||||
|
|
@ -135,12 +94,12 @@ export default function LLMProviderSelection({
|
|||
text: needsModelSelection
|
||||
? "Please Select a Model"
|
||||
: needsApiKey
|
||||
? "Please Enter API Key"
|
||||
: needsOllamaUrl
|
||||
? "Please Enter Ollama URL"
|
||||
: needsComfyUIConfig
|
||||
? "Please Configure ComfyUI"
|
||||
: "Save Configuration",
|
||||
? "Please Enter API Key"
|
||||
: needsOllamaUrl
|
||||
? "Please Enter Ollama URL"
|
||||
: needsComfyUIConfig
|
||||
? "Please Configure ComfyUI"
|
||||
: "Save Configuration",
|
||||
showProgress: false,
|
||||
});
|
||||
}, [llmConfig]);
|
||||
|
|
@ -256,77 +215,7 @@ export default function LLMProviderSelection({
|
|||
});
|
||||
}, [llmConfig.IMAGE_PROVIDER]);
|
||||
|
||||
const renderQualitySelector = () => {
|
||||
if (llmConfig.IMAGE_PROVIDER === "dall-e-3") {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
DALL·E 3 Image Quality
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{DALLE_3_QUALITY_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={cn(
|
||||
"border rounded-lg p-3 text-left transition-colors",
|
||||
llmConfig.DALL_E_3_QUALITY === option.value
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: "border-gray-200 hover:border-gray-300"
|
||||
)}
|
||||
onClick={() =>
|
||||
input_field_changed(option.value, "dall_e_3_quality")
|
||||
}
|
||||
>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{option.label}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 mt-1">
|
||||
{option.description}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (llmConfig.IMAGE_PROVIDER === "gpt-image-1.5") {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
GPT Image 1.5 Quality
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{GPT_IMAGE_1_5_QUALITY_OPTIONS.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={cn(
|
||||
"border rounded-lg p-3 text-left transition-colors",
|
||||
llmConfig.GPT_IMAGE_1_5_QUALITY === option.value
|
||||
? "border-blue-500 bg-blue-50"
|
||||
: "border-gray-200 hover:border-gray-300"
|
||||
)}
|
||||
onClick={() =>
|
||||
input_field_changed(option.value, "gpt_image_1_5_quality")
|
||||
}
|
||||
>
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{option.label}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 mt-1">
|
||||
{option.description}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col mt-10">
|
||||
|
|
@ -358,6 +247,7 @@ export default function LLMProviderSelection({
|
|||
{/* OpenAI Content */}
|
||||
<TabsContent value="openai" className="mt-6">
|
||||
<OpenAIConfig
|
||||
llmConfig={llmConfig}
|
||||
openaiApiKey={llmConfig.OPENAI_API_KEY || ""}
|
||||
openaiModel={llmConfig.OPENAI_MODEL || ""}
|
||||
webGrounding={llmConfig.WEB_GROUNDING || false}
|
||||
|
|
@ -418,7 +308,16 @@ export default function LLMProviderSelection({
|
|||
</Tabs>
|
||||
|
||||
{/* Image Generation Toggle */}
|
||||
<div className="my-8">
|
||||
<ImageSelectionConfig
|
||||
isImageGenerationDisabled={isImageGenerationDisabled}
|
||||
openImageProviderSelect={openImageProviderSelect}
|
||||
setOpenImageProviderSelect={setOpenImageProviderSelect}
|
||||
llmConfig={llmConfig}
|
||||
input_field_changed={input_field_changed}
|
||||
getApiKeyValue={getApiKeyValue}
|
||||
handleApiKeyInputChange={handleApiKeyInputChange}
|
||||
/>
|
||||
{/* <div className="my-8">
|
||||
<div className="flex items-center justify-between mb-4 bg-green-50 p-2 rounded-sm">
|
||||
<label className="text-sm font-medium text-gray-700">
|
||||
Disable Image Generation
|
||||
|
|
@ -438,213 +337,12 @@ export default function LLMProviderSelection({
|
|||
When enabled, slides will not include automatically generated
|
||||
images.
|
||||
</p>
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
{!isImageGenerationDisabled && (
|
||||
<>
|
||||
{/* Image Provider Selection */}
|
||||
<div className="my-8">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
||||
Select Image Provider
|
||||
</label>
|
||||
<div className="w-full">
|
||||
<Popover
|
||||
open={openImageProviderSelect}
|
||||
onOpenChange={setOpenImageProviderSelect}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={openImageProviderSelect}
|
||||
className="w-full h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between"
|
||||
>
|
||||
<div className="flex gap-3 items-center">
|
||||
<span className="text-sm font-medium text-gray-900">
|
||||
{llmConfig.IMAGE_PROVIDER
|
||||
? IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER]
|
||||
?.label || llmConfig.IMAGE_PROVIDER
|
||||
: "Select image provider"}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronsUpDown className="w-4 h-4 text-gray-500" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="p-0"
|
||||
align="start"
|
||||
style={{ width: "var(--radix-popover-trigger-width)" }}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search provider..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No provider found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{Object.values(IMAGE_PROVIDERS).map(
|
||||
(provider, index) => (
|
||||
<CommandItem
|
||||
key={index}
|
||||
value={provider.value}
|
||||
onSelect={(value) => {
|
||||
input_field_changed(value, "image_provider");
|
||||
setOpenImageProviderSelect(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
llmConfig.IMAGE_PROVIDER === provider.value
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div className="flex gap-3 items-center">
|
||||
<div className="flex flex-col space-y-1 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-sm font-medium text-gray-900 capitalize">
|
||||
{provider.label}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-600 leading-relaxed">
|
||||
{provider.description}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CommandItem>
|
||||
)
|
||||
)}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderQualitySelector()}
|
||||
|
||||
{/* Dynamic API Key Input for Image Provider */}
|
||||
{llmConfig.IMAGE_PROVIDER &&
|
||||
IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER] &&
|
||||
(() => {
|
||||
const provider = IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER];
|
||||
|
||||
// Show info message when using same API key as main provider
|
||||
if (
|
||||
provider.value === "dall-e-3" &&
|
||||
llmConfig.LLM === "openai"
|
||||
) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
if (
|
||||
provider.value === "gpt-image-1.5" &&
|
||||
llmConfig.LLM === "openai"
|
||||
) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
if (
|
||||
provider.value === "gemini_flash" &&
|
||||
llmConfig.LLM === "google"
|
||||
) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
if (
|
||||
provider.value === "nanobanana_pro" &&
|
||||
llmConfig.LLM === "google"
|
||||
) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
// Show ComfyUI configuration
|
||||
if (provider.value === "comfyui") {
|
||||
return (
|
||||
<div className="mb-8 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
ComfyUI Server URL
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="http://192.168.1.7:8188"
|
||||
className="w-full px-4 py-2.5 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
|
||||
value={llmConfig.COMFYUI_URL || ""}
|
||||
onChange={(e) => {
|
||||
input_field_changed(
|
||||
e.target.value,
|
||||
"comfyui_url"
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-gray-500 flex items-center gap-2">
|
||||
<span className="block w-1 h-1 rounded-full bg-gray-400"></span>
|
||||
Use your machine IP address (not localhost) when
|
||||
running in Docker
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Workflow JSON
|
||||
</label>
|
||||
<div className="relative">
|
||||
<textarea
|
||||
placeholder='Paste your ComfyUI workflow JSON here (export via "Export (API)" in ComfyUI)'
|
||||
className="w-full px-4 py-2.5 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors font-mono text-xs"
|
||||
rows={6}
|
||||
value={llmConfig.COMFYUI_WORKFLOW || ""}
|
||||
onChange={(e) => {
|
||||
input_field_changed(
|
||||
e.target.value,
|
||||
"comfyui_workflow"
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-gray-500">
|
||||
Export your workflow from ComfyUI using "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 +371,7 @@ export default function LLMProviderSelection({
|
|||
<>
|
||||
and{" "}
|
||||
{llmConfig.IMAGE_PROVIDER &&
|
||||
IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER]
|
||||
IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER]
|
||||
? IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER].label
|
||||
: "xxxxx"}{" "}
|
||||
for images
|
||||
|
|
@ -682,7 +380,28 @@ export default function LLMProviderSelection({
|
|||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> */}
|
||||
{/* <button
|
||||
onClick={handleSaveConfig}
|
||||
disabled={buttonState.isDisabled}
|
||||
style={{
|
||||
background: "linear-gradient(270deg, #D5CAFC 2.4%, #E3D2EB 27.88%, #F4DCD3 69.23%, #FDE4C2 100%)",
|
||||
color: "#101323",
|
||||
}}
|
||||
className={`w-full font-semibold py-3 px-4 rounded-lg transition-all duration-500 ${buttonState.isDisabled
|
||||
? "bg-gray-400 cursor-not-allowed"
|
||||
: "bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 focus:ring-4 focus:ring-blue-200"
|
||||
} text-white`}
|
||||
>
|
||||
{buttonState.isLoading ? (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{buttonState.text}
|
||||
</div>
|
||||
) : (
|
||||
buttonState.text
|
||||
)}
|
||||
</button> */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
38
electron/servers/nextjs/components/MarkDownRender.tsx
Normal file
38
electron/servers/nextjs/components/MarkDownRender.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
|
||||
import { marked } from "marked";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({ content, className }) => {
|
||||
const [markdownContent, setMarkdownContent] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
const parseMarkdown = async () => {
|
||||
try {
|
||||
const parsed = await marked.parse(content);
|
||||
setMarkdownContent(parsed);
|
||||
} catch (error) {
|
||||
console.error("Error parsing markdown:", error);
|
||||
setMarkdownContent("");
|
||||
}
|
||||
};
|
||||
|
||||
parseMarkdown();
|
||||
}, [content]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("prose prose-slate max-w-none mb-10", className)}
|
||||
dangerouslySetInnerHTML={{ __html: markdownContent }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarkdownRenderer;
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue