feat: Update Text/Image provider & Pages Designs.

This commit is contained in:
shiva raj badu 2026-03-02 00:02:19 +05:45
parent ebccd4976b
commit 3cdbf246ab
No known key found for this signature in database
17 changed files with 1381 additions and 817 deletions

View file

@ -18,35 +18,13 @@ const Header = () => {
{/* {(pathname !== "/upload" && pathname !== "/dashboard") && <BackBtn />} */}
<Link href="/dashboard" onClick={() => trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/dashboard" })}>
<img
src="/Logo.png"
src="/logo-with-bg.png"
alt="Presentation logo"
className="h-[33px]"
/>
</Link>
</div>
<div className="flex items-center gap-3">
<Link
href="/custom-template"
prefetch={false}
onClick={() => trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/custom-template" })}
className="flex items-center gap-2 px-3 py-2 text-[#101323] rounded-md transition-colors outline-none"
role="menuitem"
>
<FilePlus2 className="w-5 h-5" />
<span className="text-sm font-medium font-inter">Create Template</span>
</Link>
<Link
href="/template-preview"
prefetch={false}
onClick={() => trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/template-preview" })}
className="flex items-center gap-2 px-3 py-2 text-[#101323] rounded-md transition-colors outline-none"
role="menuitem"
>
<Layout className="w-5 h-5" />
<span className="text-sm font-medium font-inter">Templates</span>
</Link>
<HeaderNav />
</div>
</div>
</Wrapper>
</div>

View file

@ -13,7 +13,6 @@ import React, { useState } from 'react'
const ImageProvider = ({ llmConfig, setLlmConfig }: { llmConfig: LLMConfig, setLlmConfig: (config: any) => void }) => {
const [openImageProviderSelect, setOpenImageProviderSelect] = useState(false);
const isImageGenerationDisabled = llmConfig.DISABLE_IMAGE_GENERATION ?? false;
console.log(llmConfig);
const handleChangeImageGenerationDisabled = (value: boolean) => {
setLlmConfig((prev: any) => ({
...prev,
@ -28,13 +27,39 @@ const ImageProvider = ({ llmConfig, setLlmConfig }: { llmConfig: LLMConfig, setL
setOpenImageProviderSelect(false);
}
const getFieldValue = (field?: string) => {
if (!field) return "";
return (llmConfig as Record<string, string | undefined>)[field] || "";
};
const updateFieldValue = (field: string | undefined, value: string) => {
if (!field) return;
setLlmConfig((prev: any) => ({
...prev,
[field]: value,
}));
};
const getTextProviderApiField = () => {
if (llmConfig.LLM === "openai") return "OPENAI_API_KEY";
if (llmConfig.LLM === "google") return "GOOGLE_API_KEY";
if (llmConfig.LLM === "anthropic") return "ANTHROPIC_API_KEY";
return "";
};
const shouldHideImageApiKeyInput = (providerValue: string, providerApiKeyField?: string) => {
if (!providerApiKeyField) return true;
if (providerValue === "comfyui") return false;
return providerApiKeyField === getTextProviderApiField();
};
const renderQualitySelector = (llmConfig: LLMConfig, input_field_changed: (value: string, field: string) => void) => {
if (llmConfig.IMAGE_PROVIDER === "dall-e-3") {
return (
<div className="w-[295px]">
<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>
@ -49,28 +74,7 @@ const ImageProvider = ({ llmConfig, setLlmConfig }: { llmConfig: LLMConfig, setL
))}
</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>
);
@ -78,7 +82,7 @@ const ImageProvider = ({ llmConfig, setLlmConfig }: { llmConfig: LLMConfig, setL
if (llmConfig.IMAGE_PROVIDER === "gpt-image-1.5") {
return (
<div className="w-[295px]">
<div className="w-[205px]">
<label className="block text-sm font-medium text-gray-700 mb-2">
GPT Image 1.5 Quality
</label>
@ -98,28 +102,7 @@ const ImageProvider = ({ llmConfig, setLlmConfig }: { llmConfig: LLMConfig, setL
))}
</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>
);
@ -139,6 +122,7 @@ const ImageProvider = ({ llmConfig, setLlmConfig }: { llmConfig: LLMConfig, setL
<div className='flex justify-end items-center'>
<Switch
checked={isImageGenerationDisabled}
className=''
onCheckedChange={(checked) => handleChangeImageGenerationDisabled(checked)}
/>
</div>
@ -158,217 +142,192 @@ const ImageProvider = ({ llmConfig, setLlmConfig }: { llmConfig: LLMConfig, setL
Choosing where images come from
</p>
</div>
<div className='flex items-center justify-end gap-4'>
<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-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)" }}
{!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}
>
<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}
<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>
<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>
<span className="text-xs text-gray-600 leading-relaxed">
{provider.description}
</span>
</div>
</div>
</CommandItem>
)
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</CommandItem>
)
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
</div>
</div>
{/* Dynamic API Key Input for Image Provider */}
{llmConfig.IMAGE_PROVIDER &&
IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER] &&
(() => {
const provider = IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER];
{/* 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 <></>;
}
// Show info message when using same API key as main provider
if (shouldHideImageApiKeyInput(provider.value, provider.apiKeyField)) {
return <></>;
}
if (
provider.value === "GPT_IMAGE_1_5" &&
llmConfig.LLM === "openai"
) {
return <></>;
}
// Show ComfyUI configuration
if (provider.value === "comfyui") {
return (
<div className=" space-y-4">
<div className='w-[205px]'>
<label className="block text-sm font-medium text-gray-700 mb-2">
ComfyUI Server URL
</label>
<div className="relative">
<input
type="text"
placeholder="http://192.168.1.7:8188"
className="w-full px-4 py-2.5 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
value={llmConfig.COMFYUI_URL || ""}
onChange={(e) => {
input_field_changed(
e.target.value,
"COMFYUI_URL"
);
}}
/>
</div>
if (
provider.value === "GEMINI_FLASH" &&
llmConfig.LLM === "google"
) {
return <></>;
}
</div>
if (
provider.value === "NANO_BANANA_PRO" &&
llmConfig.LLM === "google"
) {
return <></>;
}
</div>
);
}
// Show ComfyUI configuration
if (provider.value === "comfyui") {
// Show API key input for other providers
return (
<div className=" space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
ComfyUI Server URL
</label>
<div className="relative">
<input
type="text"
placeholder="http://192.168.1.7:8188"
className="w-full px-4 py-2.5 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
value={llmConfig.COMFYUI_URL || ""}
onChange={(e) => {
input_field_changed(
e.target.value,
"COMFYUI_URL"
);
}}
/>
</div>
<p className="mt-2 text-sm text-gray-500 flex items-center gap-2">
<span className="block w-1 h-1 rounded-full bg-gray-400"></span>
Use your machine IP address (not localhost) when
running in Docker
</p>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Workflow JSON
</label>
<div className="relative">
<textarea
placeholder='Paste your ComfyUI workflow JSON here (export via "Export (API)" in ComfyUI)'
className="w-full px-4 py-2.5 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors font-mono text-xs"
rows={6}
value={llmConfig.COMFYUI_WORKFLOW || ""}
onChange={(e) => {
input_field_changed(
e.target.value,
"COMFYUI_WORKFLOW"
);
}}
/>
</div>
<p className="mt-2 text-sm text-gray-500">
Export your workflow from ComfyUI using &quot;Export
(API)&quot; and paste the JSON here.
</p>
<div className=" w-[205px]">
<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={getFieldValue(provider.apiKeyField)}
onChange={(e) =>
updateFieldValue(
provider.apiKeyField,
e.target.value
)
}
/>
</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={provider.}
// onChange={(e) =>
// input_field_changed(
// provider.apiKeyField || "",
// e.target.value
// )
// }
/>
</div>
</>
)}
</div>
<div className='flex justify-end items-center mt-[18px]'>
</div>
);
})()}
{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>
{renderQualitySelector(llmConfig, input_field_changed)}
</>
)}
</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="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>
@ -386,7 +345,7 @@ const ImageProvider = ({ llmConfig, setLlmConfig }: { llmConfig: LLMConfig, setL
<div className="w-[295px]"></div>
</div>
</div>
</div> */}
</div>

View file

@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect } from "react";
import { Loader2, Download, CheckCircle } from "lucide-react";
import { Loader2, Download, CheckCircle, ChevronRight } from "lucide-react";
import { toast } from "sonner";
import { RootState } from "@/store/store";
import { useSelector } from "react-redux";
@ -10,8 +10,6 @@ import {
pullOllamaModel,
} from "@/utils/providerUtils";
import { useRouter, usePathname } from "next/navigation";
import LLMProviderSelection from "@/components/LLMSelection";
import Header from "../dashboard/components/Header";
import { LLMConfig } from "@/types/llm_config";
import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
import SettingSideBar from "./SettingSideBar";
@ -157,8 +155,17 @@ const SettingsPage = () => {
return null;
}
return (
<div className="h-screen font-instrument_sans flex flex-col overflow-hidden">
<div className="h-screen font-instrument_sans flex flex-col overflow-hidden relative">
<div
className='fixed z-0 bottom-[-14.5rem] left-0 w-full h-full'
style={{
height: "341px",
borderRadius: '1440px',
background: 'radial-gradient(5.92% 104.69% at 50% 100%, rgba(122, 90, 248, 0.00) 0%, rgba(255, 255, 255, 0.00) 100%), radial-gradient(50% 50% at 50% 50%, rgba(122, 90, 248, 0.80) 0%, rgba(122, 90, 248, 0.00) 100%)',
}}
/>
<main className="w-full mx-auto gap-6 overflow-hidden flex ">
<SettingSideBar mode={mode} setMode={setMode} selectedProvider={selectedProvider} setSelectedProvider={setSelectedProvider} />
@ -168,8 +175,7 @@ const SettingsPage = () => {
<h3 className=" text-[28px] tracking-[-0.84px] font-unbounded font-normal text-black flex items-center gap-2">
Settings
</h3>
<div className="flex gap-2.5 max-sm:w-full max-md:justify-center max-sm:flex-wrap">
</div>
</div>
</div>
@ -193,30 +199,29 @@ const SettingsPage = () => {
</main>
{/* Fixed Bottom Button */}
<div className="flex-shrink-0 bg-white border-t border-gray-200 p-4">
<div className=" mx-auto ">
<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 className=" mx-auto fixed bottom-20 right-5 ">
<button
onClick={handleSaveConfig}
disabled={buttonState.isDisabled}
style={{
background: "linear-gradient(270deg, #D5CAFC 2.4%, #E3D2EB 27.88%, #F4DCD3 69.23%, #FDE4C2 100%)",
color: "#101323",
}}
className={`w-full flex items-center justify-center gap-2 font-semibold py-3 px-5 rounded-[58px] transition-all duration-500 ${buttonState.isDisabled
? "bg-gray-400 cursor-not-allowed"
: "bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 focus:ring-4 focus:ring-blue-200"
} text-white`}
>
{buttonState.isLoading ? (
<div className="flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
{buttonState.text}
</div>
) : (
buttonState.text
)}
<ChevronRight className="w-4 h-4" />
</button>
</div>
{/* Download Progress Modal */}

View file

@ -1,22 +1,11 @@
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 }) => {
console.log(mode, selectedProvider)
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('nanobanana')}
style={{
background: mode === 'nanobanana' ? '#F4F3FF' : 'transparent',
color: mode === 'nanobanana' ? '#5146E5' : '#3A3A3A'
}}
>Nanobanana</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-3 py-2 text-xs font-medium text-[#3A3A3A] rounded-[70px]'
onClick={() => setMode('presenton')}
style={{
@ -24,6 +13,25 @@ const SettingSideBar = ({ mode, setMode, selectedProvider, setSelectedProvider }
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'>

View file

@ -4,8 +4,9 @@ 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 { Check, ChevronsUpDown, Loader2 } from 'lucide-react';
import React, { useEffect, useState } from 'react'
import { LLM_PROVIDERS } from '@/utils/providerConstants';
import { Check, ChevronsUpDown, Loader2, Eye, EyeOff } from 'lucide-react';
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { toast } from 'sonner';
@ -21,50 +22,168 @@ const TextProvider = ({
}: 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 [apiKey, setApiKey] = useState('');
const [showApiKey, setShowApiKey] = useState(false);
const isFirstRender = useRef(true);
const openaiUrl = "https://api.openai.com/v1";
const selectedProvider = (llmConfig.LLM || 'openai') as keyof typeof LLM_PROVIDERS;
const selectedProviderMeta = LLM_PROVIDERS[selectedProvider];
const currentModelField = useMemo(() => {
switch (selectedProvider) {
case 'openai':
return 'OPENAI_MODEL';
case 'google':
return 'GOOGLE_MODEL';
case 'anthropic':
return 'ANTHROPIC_MODEL';
case 'ollama':
return 'OLLAMA_MODEL';
case 'custom':
return 'CUSTOM_MODEL';
default:
return '';
}
}, [selectedProvider]);
const currentApiKeyField = useMemo(() => {
switch (selectedProvider) {
case 'openai':
return 'OPENAI_API_KEY';
case 'google':
return 'GOOGLE_API_KEY';
case 'anthropic':
return 'ANTHROPIC_API_KEY';
case 'custom':
return 'CUSTOM_LLM_API_KEY';
default:
return '';
}
}, [selectedProvider]);
const currentModel = currentModelField ? ((llmConfig as Record<string, unknown>)[currentModelField] as string || '') : '';
const currentApiKey = currentApiKeyField ? ((llmConfig as Record<string, unknown>)[currentApiKeyField] as string || '') : '';
const currentCustomUrl = llmConfig.CUSTOM_LLM_URL || '';
const currentOllamaUrl = llmConfig.OLLAMA_URL || '';
const modelLabel = selectedProviderMeta?.label || selectedProvider;
useEffect(() => {
if (isFirstRender.current) {
isFirstRender.current = false;
return;
}
setAvailableModels([]);
setModelsChecked(false);
onInputChange("", "openai_model");
}, [apiKey]);
if (currentModelField) {
onInputChange('', currentModelField);
}
}, [selectedProvider, currentApiKey, currentCustomUrl, currentOllamaUrl]);
const onApiKeyChange = (value: string) => {
setApiKey(value);
onInputChange(value, "openai_api_key");
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 (!'openaiApiKey') return;
if (selectedProvider === 'openai' && !currentApiKey) return;
if (selectedProvider === 'google' && !currentApiKey) return;
if (selectedProvider === 'anthropic' && !currentApiKey) return;
if (selectedProvider === 'custom' && !currentCustomUrl) return;
setModelsLoading(true);
try {
const response = await fetch('/api/v1/ppt/openai/models/available', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: openaiUrl,
api_key: 'openaiApiKey'
}),
});
let response: Response;
if (selectedProvider === 'google') {
response = await fetch('/api/v1/ppt/google/models/available', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
api_key: currentApiKey
}),
});
} else if (selectedProvider === 'anthropic') {
response = await fetch('/api/v1/ppt/anthropic/models/available', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
api_key: currentApiKey
}),
});
} else if (selectedProvider === 'ollama') {
response = await fetch('/api/v1/ppt/ollama/models/supported');
} else {
response = await fetch('/api/v1/ppt/openai/models/available', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: selectedProvider === 'custom' ? currentCustomUrl : selectedProviderMeta?.url || '',
api_key: currentApiKey
}),
});
}
if (response.ok) {
const data = await response.json();
setAvailableModels(data);
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);
onInputChange("gpt-4.1", "openai_model");
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);
@ -75,6 +194,12 @@ const TextProvider = ({
setModelsLoading(false);
}
};
useEffect(() => {
if (selectedProvider === 'ollama' && !modelsChecked && !modelsLoading) {
fetchAvailableModels();
}
}, [selectedProvider, modelsChecked, modelsLoading]);
return (
<div className="space-y-6 bg-[#F9F8F8] p-7 rounded-[20px] ">
{/* API Key Input */}
@ -94,32 +219,139 @@ const TextProvider = ({
Choosing where text contets come from
</p>
</div>
<div className="flex items-center gap-4">
<div className="relative w-[275px] ">
<div className="flex flex-col justify-start gap-2">
<div className="flex items-start gap-4 justify-end">
<div className="relative w-[205px] ">
<div className="flex flex-col justify-start ">
<label className="block text-sm font-medium text-gray-700 mb-2">
OpenAI API Key
Select Text Provider
</label>
<input
type="text"
value={'openaiApiKey'}
onChange={(e) => onApiKeyChange(e.target.value)}
className="w-full px-2 py-3 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
placeholder="Enter your API key"
/>
<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>
<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(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>
{/* Check for available models button - show when no models checked or no models found */}
{(!modelsChecked || (modelsChecked && availableModels.length === 0)) && (
</div>
<div className="relative flex flex-col justify-end items-end w-[205px] ">
<div className="flex flex-col justify-start ">
<label className="block text-sm font-medium capitalize text-gray-700 mb-2">
{selectedProvider === 'ollama' ? 'Ollama URL' : selectedProvider === 'custom' ? 'Custom LLM API Key' : `${llmConfig.LLM} API Key`}
</label>
<div className="relative">
<input
type={selectedProvider === 'ollama' ? 'text' : showApiKey ? 'text' : 'password'}
value={selectedProvider === 'ollama' ? currentOllamaUrl : currentApiKey}
onChange={(e) => onApiKeyChange(selectedProvider, e.target.value)}
className="w-full px-2 py-3 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
placeholder={selectedProvider === 'ollama' ? 'http://localhost:11434' : `Enter your ${llmConfig.LLM} API key`}
/>
{selectedProvider !== 'ollama' && (
<button
type="button"
onClick={() => setShowApiKey((prev) => !prev)}
className='absolute right-2 top-1/2 -translate-y-1/2 bg-white px-2 py-1 cursor-pointer'
>
{showApiKey ? <Eye className='w-4 h-4 text-gray-500' /> : <EyeOff className='w-4 h-4 text-gray-500' />}
</button>
)}
</div>
{selectedProvider === 'custom' && (
<input
type="text"
value={currentCustomUrl}
onChange={(e) => onInputChange(e.target.value, 'CUSTOM_LLM_URL')}
className="w-full mt-2 px-2 py-3 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors"
placeholder="OpenAI-compatible URL"
/>
)}
</div>
{selectedProvider !== 'ollama' && (!modelsChecked || (modelsChecked && availableModels.length === 0)) && (
<button
onClick={fetchAvailableModels}
// disabled={modelsLoading || !'openaiApiKey'}
className={` mt-7 py-2.5 bg-[#F7F6F9] px-3.5 rounded-[48px] text-xs font-semibold text-[#101323] transition-all duration-200 border ${modelsLoading
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-blue-600 hover:bg-[#E8F0FF]/90 focus:ring-2 focus:ring-blue-500/20"
: " border-[#EDEEEF] text-[#101323] hover:bg-[#E8F0FF]/90 focus:ring-2 focus:ring-blue-500/20"
}`}
>
{modelsLoading ? (
@ -128,27 +360,20 @@ const TextProvider = ({
Checking for models...
</span>
) : (
"Check for available models"
"Check models"
)}
</button>
)}
</div>
<div className="w-[295px]">
{/* Show message if no models found */}
{modelsChecked && availableModels.length === 0 && (
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm text-yellow-800">
No models found. Please make sure your API key is valid and has access to OpenAI models.
</p>
</div>
)}
{/* Model Selection - only show if models are available */}
{modelsChecked && availableModels.length > 0 ? (
{/* 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">
Select OpenAI Model
{selectedProvider === 'ollama' ? 'Choose a supported model' : `Select ${modelLabel} Model`}
</label>
<div className="w-full">
<Popover
@ -162,13 +387,12 @@ const TextProvider = ({
aria-expanded={openModelSelect}
className="w-full h-12 px-4 py-4 outline-none border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-colors hover:border-gray-400 justify-between"
>
<div className="flex gap-3 items-center">
<span className="text-sm font-medium text-gray-900">
{/* {'openaiModel'
? availableModels.find(model => model === 'openaiModel') || 'openaiModel'
: "Select a model"} */}
</span>
</div>
<span className="text-sm truncate font-medium text-gray-900">
{currentModel
? availableModels.find(model => model === currentModel) || currentModel
: "Select a model"}
</span>
<ChevronsUpDown className="w-4 h-4 text-gray-500" />
</Button>
</PopoverTrigger>
@ -187,14 +411,16 @@ const TextProvider = ({
key={index}
value={model}
onSelect={(value) => {
onInputChange(value, "openai_model");
if (currentModelField) {
onInputChange(value, currentModelField);
}
setOpenModelSelect(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
'openaiModel' === model
currentModel === model
? "opacity-100"
: "opacity-0"
)}
@ -217,10 +443,18 @@ const TextProvider = ({
</Popover>
</div>
</div>
) : null}
</div>
</div>
) : null}
</div>
</div>
{/* Show message if no models found */}
{modelsChecked && availableModels.length === 0 && (
<div className="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm text-yellow-800">
No models found. Please make sure your provider credentials are valid and the selected provider is reachable.
</p>
</div>
)}
{/* Web Grounding Toggle - show at the end, below models dropdown */}
@ -237,8 +471,8 @@ const TextProvider = ({
<div className="w-[275px]">
<div className="flex items-center mb-4 gap-2.5 ">
<Switch
checked={true}
onCheckedChange={(checked) => onInputChange(checked, "")}
checked={!!llmConfig.WEB_GROUNDING}
onCheckedChange={(checked) => onInputChange(checked, "WEB_GROUNDING")}
/>
<label className="text-sm font-medium text-gray-700">
Enable Web Grounding

View file

@ -2,7 +2,7 @@
import React, { useEffect, useState, memo, useCallback } from "react";
import { useDispatch } from "react-redux";
import { addNewSlide } from "@/store/slices/presentationGeneration";
import { Loader2 } from "lucide-react";
import { Loader2, Trash } from "lucide-react";
import { v4 as uuidv4 } from "uuid";
import { Trash2 } from 'lucide-react';
import { toast } from 'sonner';
@ -95,7 +95,7 @@ const NewSlideV1 = ({
<div className="my-6 w-full bg-gray-50 p-8 max-w-[1280px]">
<div className="flex justify-between items-center mb-8">
<h2 className="text-2xl font-semibold">Select a Slide Layout</h2>
<Trash2
<Trash
onClick={() => setShowNewSlideSelection(false)}
className="text-gray-500 text-2xl cursor-pointer"
/>

View file

@ -6,6 +6,8 @@ import {
X,
Minimize2,
Maximize2,
StickyNote,
EyeOff,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Slide } from "../types/slide";
@ -38,6 +40,11 @@ const PresentationMode: React.FC<PresentationModeProps> = ({
return null;
}
const [showSpeakerNotes, setShowSpeakerNotes] = useState(true);
const currentSpeakerNote = useMemo(
() => slides[currentSlide]?.speaker_note?.trim() || "",
[slides, currentSlide]
);
const recomputeScale = useCallback(() => {
@ -90,6 +97,10 @@ const PresentationMode: React.FC<PresentationModeProps> = ({
case "F":
onFullscreenToggle();
break;
case "n":
case "N":
setShowSpeakerNotes((prev) => !prev);
break;
}
},
[currentSlide, slides.length, onSlideChange, onExit, onFullscreenToggle, isFullscreen]
@ -223,24 +234,70 @@ const PresentationMode: React.FC<PresentationModeProps> = ({
</>
)}
{/* Slides (all mounted, only current visible) */}
<div className={`flex-1 flex items-center justify-center ${isFullscreen ? "p-0" : "p-8"}`}>
<div className="w-full h-full flex items-center justify-center relative" >
<div
className={` rounded-sm font-inter relative w-full h-full flex items-center justify-center`}
>
{slides.length > 0 && slides.map((slide, index) => (
<div
key={slide.id}
className={index === currentSlide ? " w-full h-full flex items-center justify-center" : "hidden w-full h-full"}
>
<V1ContentRender slide={slide} isEditMode={true} />
</div>
))}
</div>
{/* Centered 16:9 stage for consistent alignment in normal + fullscreen modes */}
<div className={`flex-1 min-h-0 flex items-center justify-center ${isFullscreen ? "px-6 py-8 md:px-10 md:py-12" : "p-8"}`}>
<div
className="relative rounded-sm font-inter"
style={{
aspectRatio: "16 / 9",
width: isFullscreen
? "min(90vw, calc(88vh * 16 / 9))"
: "min(calc(100vw - 4rem), calc((100vh - 4rem) * 16 / 9))",
maxHeight: isFullscreen ? "88vh" : "calc(100vh - 4rem)",
}}
>
{slides.length > 0 && slides.map((slide, index) => (
<div
key={slide.id}
className={index === currentSlide ? "h-full w-full" : "hidden h-full w-full"}
>
<V1ContentRender slide={slide} isEditMode={true} />
</div>
))}
</div>
</div>
{currentSpeakerNote && (
<div className="presentation-controls absolute bottom-4 right-4 z-50">
{showSpeakerNotes ? (
<div className="w-[360px] max-w-[50vw] rounded-xl border border-black/10 bg-white/95 shadow-xl backdrop-blur-sm">
<div className="flex items-center justify-between border-b border-black/10 px-3 py-2">
<div className="flex items-center gap-2 text-sm font-medium text-gray-800">
<StickyNote className="h-4 w-4" />
Speaker notes
</div>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
setShowSpeakerNotes(false);
}}
className="h-8 px-2 text-gray-600 hover:bg-black/5 hover:text-gray-800"
>
<EyeOff className="mr-1 h-4 w-4" />
Hide
</Button>
</div>
<div className="max-h-[28vh] overflow-auto whitespace-pre-wrap px-3 py-2 text-sm text-gray-700">
{currentSpeakerNote}
</div>
</div>
) : (
<Button
variant="secondary"
onClick={(e) => {
e.stopPropagation();
setShowSpeakerNotes(true);
}}
className="h-9 rounded-full border border-black/10 bg-white/95 px-3 text-gray-800 shadow-md hover:bg-white"
>
<StickyNote className="mr-2 h-4 w-4" />
Show notes
</Button>
)}
</div>
)}
</div>
);
};

View file

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

View file

@ -40,7 +40,7 @@ const OutlinePage: React.FC = () => {
return (
<div className="h-[calc(100vh-72px)]">
<div className="">
<div
className='fixed z-0 bottom-[-16.5rem] left-0 w-full h-full'
style={{
@ -56,8 +56,8 @@ const OutlinePage: React.FC = () => {
duration={loadingState.duration}
/>
<Wrapper className="h-full flex flex-col w-full">
<div className="flex-grow w-full overflow-y-hidden mx-auto mt-6">
<Wrapper className="h-full flex flex-col w-full relative">
<div className="flex-grow w-full overflow-y-hidden mx-auto mt-6">
<Tabs value={activeTab} onValueChange={setActiveTab} className="h-full flex flex-col">
<TabsList className="my-4 h-auto w-fit rounded-full border border-[#DFDFE1] bg-[#F8F8F9] p-1.5">
<TabsTrigger
@ -101,11 +101,9 @@ const OutlinePage: React.FC = () => {
</TabsContent>
</div>
</Tabs>
</div>
{/* Fixed Button */}
{/* Fixed Button */}
<div className="py-4 border-t border-gray-200">
<div className="max-w-[1200px] mx-auto">
<div className="absolute bottom-36 -right-10 z-50">
<GenerateButton
outlineCount={outlines.length}
loadingState={loadingState}
@ -115,6 +113,9 @@ const OutlinePage: React.FC = () => {
/>
</div>
</div>
</Wrapper>
</div>
);

View file

@ -197,7 +197,7 @@ const PresentationHeader = ({
{isPresentationSaving && <div className="flex items-center gap-2">
<Loader2 className="w-3.5 h-3.5 animate-spin" />
</div>}
<ThemeSelector presentation_id={presentation_id} current_theme={{}} themes={[]} />
{/* <ThemeSelector presentation_id={presentation_id} current_theme={{}} themes={[]} /> */}
<div className="flex items-center gap-2 bg-[#F6F6F9] px-3.5 h-[38px] border border-[#EDECEC] rounded-[80px]">

View file

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

View file

@ -0,0 +1,370 @@
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { LanguageType, PresentationConfig, ToneType, VerbosityType } from "../type";
import { useState } from "react";
import { Check, ChevronsUpDown, GalleryVertical, Languages, SlidersHorizontal } from "lucide-react";
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 { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import ToolTip from "@/components/ToolTip";
// Types
interface ConfigurationSelectsProps {
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";
// Constants
const SLIDE_OPTIONS: SlideOption[] = ["5", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", "20"];
/**
* Renders a select component for slide count
*/
const SlideCountSelect: React.FC<{
value: string | null;
onValueChange: (value: string) => void;
}> = ({ value, onValueChange }) => {
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 applyCustomValue = () => {
const sanitized = sanitizeToPositiveInteger(customInput);
if (sanitized && Number(sanitized) > 0) {
onValueChange(sanitized);
}
};
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>
)}
{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, 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-[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"
/>
<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,
}: ConfigurationSelectsProps) {
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({
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="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">
<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>
<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-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-slate-50 border-slate-200">
<div className="flex items-center justify-between">
<label className="text-sm font-semibold text-gray-700">Include table of contents</label>
<Switch
checked={advancedDraft.includeTableOfContents}
onCheckedChange={(checked) => setAdvancedDraft((prev) => ({ ...prev, includeTableOfContents: checked }))}
/>
</div>
<p className="text-xs text-gray-600">Add an index slide summarizing sections (requires 3+ slides).</p>
</div>
<div className="w-full flex flex-col gap-2 p-3 rounded-md bg-slate-50 border-slate-200">
<div className="flex items-center justify-between">
<label className="text-sm font-semibold text-gray-700">Title slide</label>
<Switch
checked={advancedDraft.includeTitleSlide}
onCheckedChange={(checked) => setAdvancedDraft((prev) => ({ ...prev, includeTitleSlide: checked }))}
/>
</div>
<p className="text-xs text-gray-600">Include a title slide as the first slide.</p>
</div>
<div className="w-full flex flex-col gap-2 p-3 rounded-md bg-slate-50 border-slate-200">
<div className="flex items-center justify-between">
<label className="text-sm font-semibold text-gray-700">Web search</label>
<Switch
checked={advancedDraft.webSearch}
onCheckedChange={(checked) => setAdvancedDraft((prev) => ({ ...prev, webSearch: checked }))}
/>
</div>
<p className="text-xs text-gray-600">Allow the model to consult the web for fresher facts.</p>
</div>
{/* Instructions */}
<div className="w-full sm:col-span-2 flex flex-col gap-2">
<label className="text-sm font-semibold text-gray-700">Instructions</label>
<p className="text-xs text-gray-500">Optional guidance for the AI. These override defaults except format constraints.</p>
<Textarea
value={advancedDraft.instructions}
rows={4}
onChange={(e) => setAdvancedDraft((prev) => ({ ...prev, instructions: e.target.value }))}
placeholder="Example: Focus on enterprise buyers, emphasize ROI and security compliance. Keep slides data-driven, avoid jargon, and include a short call-to-action on the final slide."
className="py-2 px-3 border-2 font-medium text-sm min-h-[100px] max-h-[200px] border-blue-200 focus-visible:ring-offset-0 focus-visible:ring-blue-300"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenAdvancedChange(false)}>Cancel</Button>
<Button onClick={handleSaveAdvanced} className="bg-[#5141e5] text-white hover:bg-[#5141e5]/90">Save</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View file

@ -1,42 +1,32 @@
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) {
const [showHint, setShowHint] = useState(false);
}: PromptInputProps) {
const handleChange = (val: string) => {
setShowHint(val.length > 0);
onChange(val);
};
return (
<div className="space-y-2">
<div className="relative">
<Textarea
value={value}
rows={5}
onChange={(e) => handleChange(e.target.value)}
placeholder="Tell us about your presentation"
data-testid="prompt-input"
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>
<Textarea
value={value}
rows={3}
name="prompt"
id="prompt"
aria-label="Prompt"
aria-describedby="prompt-description"
aria-required="true"
aria-invalid="false"
aria-autocomplete="list"
aria-controls="prompt-list"
aria-expanded="false"
aria-haspopup="listbox"
autoFocus={true}
onChange={(e) => onChange(e.target.value)}
placeholder="Tell us about your presentation"
data-testid="prompt-input"
className={`py-3.5 px-2.5 rounded-[10px] border-none bg-[#F6F6F9] placeholder:text-[#B3B3B3] font-medium font-instrument_sans text-base max-h-[300px] focus-visible:ring-offset-0 focus-visible:ring-0 overflow-y-auto custom_scrollbar `}
/>
</div>
);
}
}

View file

@ -1,233 +1,240 @@
'use client'
import React, { useRef, useState } from 'react'
import { File, X, Upload, Plus } 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 bg-[#F6F6F9] px-2.5 py-3.5 rounded-[10px] ">
<div className="space-y-2" data-testid="attachments-uploader">
<div className="flex items-center justify-between">
<p className="text-sm text-gray-600">
{hasFiles ? `${filteredFiles.length} attachment${filteredFiles.length > 1 ? 's' : ''}` : 'No attachments yet'}
</p>
<button
type="button"
onClick={handleClearFiles}
disabled={!hasFiles}
className={`text-sm font-medium ${!hasFiles ? 'cursor-not-allowed text-gray-400' : 'text-red-600 hover:text-red-700'}`}
data-testid="attachments-clear-button"
aria-disabled={!hasFiles}
>
Clear all
</button>
</div>
<div
onClick={() => fileInputRef.current?.click()}
className={cn(
"w-full border cursor-pointer border-dashed border-[#B8B8C1] rounded-lg",
"transition-all duration-300 ease-in-out ",
" flex flex-col ",
isDragging && "border-purple-400 bg-purple-50"
)}
onDragOver={(e) => handleDragEvents(e, true)}
onDragLeave={(e) => handleDragEvents(e, false)}
<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 gap-2 items-center justify-center p-6">
<div className='w-[42px] h-[42px] flex justify-center items-center rounded-full bg-[#EBE9FE]' >
<div className='w-[22px] h-[22px] rounded-full bg-[#7A5AF8] flex items-center justify-center text-white'>
<Plus className='w-3 h-3' />
</div>
</div>
<div>
<p className=" text-xs text-[#808080] ">
{isDragging
? <span className=' '>Drop your file here</span>
: <span className=' '> <span className=' underline underline-offset-4'>Click to Upload</span> or drag &amp; drop.</span>
}
</p>
<p className="text-gray-400 text-sm text-center mt-1 ">
Supports PDFs, Text files, PPTX, DOCX
</p>
</div>
<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> */}
<input
type="file"
className="hidden"
onChange={handleFilesSelected}
accept={accept}
multiple={multiple}
data-testid="file-upload-input"
/>
<div className="flex flex-col items-center gap-2">
<Paperclip className="h-6 w-6 text-[#5146E5]" />
<p className="text-sm font-medium text-gray-800">
Drag and drop PDF, TXT, PPTX, DOCX, or <span className="text-[#5146E5]">click to browse</span>
</p>
</div>
</label>
{files.length > 0 && (
<div className="border-t border-gray-200 bg-gray-50 rounded-b-lg">
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-medium text-gray-700">
Selected Files ({files.length})
</h3>
</div>
<div data-testid="file-list" className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-3">
{filesWithIds.map((file) => {
{hasFiles && (
<div className="mt-2">
<ul data-testid="file-list" className="grid grid-cols-1 gap-2 sm:grid-cols-2" aria-label="Attached files">
{filteredFiles.map((file, idx) => (
<li
key={`${file.name}-${idx}`}
className="flex items-center gap-3 rounded-md border border-gray-200 px-3 py-2"
data-testid="attached-file-item"
>
{previewUrls[idx] ? (
<img src={previewUrls[idx] as string} alt="Preview" className="h-10 w-10 flex-none rounded object-cover" />
) : (
<div className="flex h-10 w-10 flex-none items-center justify-center rounded bg-gray-100 text-gray-600">
<File className="h-5 w-5" />
</div>
)}
return (
(
<div key={file.id}
className="bg-white rounded-lg border border-gray-200 overflow-hidden
hover:border-purple-200 group relative"
>
<div className="p-4 bg-purple-50 group-hover:bg-purple-100
transition-colors flex items-center justify-center relative"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-gray-900" title={file.name}>
{file.name}
</p>
<p className="text-xs text-gray-500">{formatFileSize(file.size)}</p>
</div>
<File className="w-8 h-8 text-purple-600" />
<button
onClick={(e) => {
e.stopPropagation()
removeFile(file.id)
}}
className="absolute top-1 right-2 p-1.5
bg-white/80 backdrop-blur-sm rounded-full
text-gray-500 hover:text-red-500
shadow-sm hover:shadow-md
transition-all duration-200"
aria-label="Remove file"
>
<X className="w-4 h-4" />
</button>
</div>
<div className="p-3 relative">
<p className="text-sm font-medium text-gray-700 truncate mb-1 pr-2">
{file.name || 'Unnamed File'}
</p>
<p className="text-xs text-gray-500">
{formatFileSize(file.size)}
</p>
</div>
</div>
)
)
})}
</div>
</div>
</div>
)}
</div>
<button
type="button"
onClick={() => handleRemoveFileAt(idx)}
className="ml-2 inline-flex h-8 w-8 items-center justify-center rounded text-red-600 hover:bg-red-50 hover:text-red-700"
aria-label={`Remove ${file.name}`}
data-testid="remove-file-button"
>
<X className="h-5 w-5" />
</button>
</li>
))}
</ul>
{filteredFiles.length !== files.length && (
<p className="mt-2 text-xs text-amber-600">
Some files were skipped. Only PDF, TXT, PPTX, and DOCX files are supported.
</p>
)}
</div>
)}
</div>
)
}
const formatFileSize = (bytes: number): string => {
if (!bytes || bytes <= 0) return '0 KB'
return `${(bytes / 1024).toFixed(1)} KB`
}
function isAllowedFile(file: File): boolean {
const type = (file.type || '').toLowerCase()
const name = (file.name || '').toLowerCase()
const typeAllowed = ALLOWED_MIME_TYPES.includes(type) || ALLOWED_MIME_PREFIXES.some((prefix) => type.startsWith(prefix))
if (typeAllowed) return true
return ALLOWED_EXTENSIONS.some((ext) => name.endsWith(ext))
}
export default SupportingDoc

View file

@ -18,16 +18,14 @@ import { PromptInput } from "./PromptInput";
import { LanguageType, PresentationConfig, ToneType, VerbosityType } from "../type";
import SupportingDoc from "./SupportingDoc";
import { Button } from "@/components/ui/button";
import { ChevronRight, GitPullRequestCreate, UploadIcon } from "lucide-react";
import { ChevronRight } from "lucide-react";
import { toast } from "sonner";
import { PresentationGenerationApi } from "../../services/api/presentation-generation";
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 { LanguageSelector } from "./LanguageSelector";
import AdvanceSettings from "./AdvanceSettings";
import NumberOfSlide from "./NumberOfSlide";
import { ConfigurationSelects } from "./ConfigurationSelects";
// Types for loading state
interface LoadingState {
@ -196,15 +194,7 @@ const UploadPage = () => {
};
return (
<Wrapper className=" pb-10 lg:max-w-[70%] xl:max-w-[65%] relative ">
<div
className='fixed z-0 bottom-[-16.5rem] left-0 w-full h-full'
style={{
height: "341px",
borderRadius: '1440px',
background: 'radial-gradient(5.92% 104.69% at 50% 100%, rgba(122, 90, 248, 0.00) 0%, rgba(255, 255, 255, 0.00) 100%), radial-gradient(50% 50% at 50% 50%, rgba(122, 90, 248, 0.80) 0%, rgba(122, 90, 248, 0.00) 100%)',
}}
/>
<Wrapper className="pb-10 lg:max-w-[70%] xl:max-w-[65%]">
<OverlayLoader
show={loadingState.isLoading}
text={loadingState.message}
@ -212,101 +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=" w-full mx-auto px-2 md:px-0 max-w-[780px] ">
<div className="rounded-2xl border border-slate-200/70 bg-white/80 shadow-sm backdrop-blur supports-[backdrop-filter]:bg-white/60" >
<div className="flex flex-col gap-4 md:items-center md:flex-row justify-between p-4">
<div >
<h2 className="text-lg font-unbounded tracking-tight text-slate-900">Configuration</h2>
<p className="text-sm text-slate-500">Choose slides, tone, and language preferences.</p>
</div>
<ConfigurationSelects
config={config}
onConfigChange={handleConfigChange}
/>
</div>
<div className="border-t border-slate-200/70" />
<div
className='fixed z-0 md:-bottom-[36%] -bottom-[40%] 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="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>
<div className=' w-max ml-9 rounded-tl-[28px] rounded-tr-[28px] flex items-center bg-[#FAFAFF] px-2.5 pt-2.5 pb-1'
style={{
boxShadow: '0 0 16px 0 rgba(80, 71, 230, 0.12)',
}}
>
<SupportingDoc
files={[...files]}
onFilesChange={setFiles}
data-testid="file-upload-input"
/>
</div>
<div className="border-t border-slate-200/70" />
<div className={`flex justify-center gap-1 py-2.5 pl-2 pr-3 cursor-pointer bg-white rounded-[80px] `}
style={{
boxShadow: '0 0 4px 0 rgba(0, 0, 0, 0.06)',
}}
<div className="p-4 md:p-6">
<Button
onClick={handleGeneratePresentation}
className="w-full rounded-[28px] flex items-center justify-center py-5 bg-[#5141e5] text-white font-instrument_sans font-semibold text-lg hover:bg-[#5141e5]/85 focus-visible:ring-2 focus-visible:ring-[#5141e5]/40"
data-testid="next-button"
>
<GitPullRequestCreate className={`w-4 h-4 text-[#6938EF]`} />
<p className='text-xs font-medium text-black'>Create Presentation</p>
</div>
<span>Generate Presentation</span>
<ChevronRight className="!w-5 !h-5 ml-1.5" />
</Button>
</div>
<div className=" w-full bg-[#FAFAFF] rounded-[28px] p-2.5 "
style={{
boxShadow: '0 0 16px 0 rgba(80, 71, 230, 0.12)',
clipPath: 'inset(0px -28px -28px -28px)',
}}
>
<div className="bg-[#FEFEFF] rounded-[18px] p-2 border border-[#EDEEEF] ">
<div className="py-2.5 space-y-2.5">
<PromptInput
value={config.prompt}
onChange={(value) => handleConfigChange("prompt", value)}
data-testid="prompt-input"
/>
<SupportingDoc
files={[...files]}
onFilesChange={setFiles}
data-testid="file-upload-input"
/>
</div>
<div className="mt-2 flex justify-between gap-4">
<div className=" flex items-stretch gap-3">
<LanguageSelector
value={config.language}
onValueChange={(value) => handleConfigChange("language", value)}
/>
<AdvanceSettings
config={config}
onConfigChange={handleConfigChange}
/>
</div>
<div>
<Button
onClick={handleGeneratePresentation}
style={{
background: "linear-gradient(270deg, #D5CAFC 2.4%, #E3D2EB 27.88%, #F4DCD3 69.23%, #FDE4C2 100%)",
}}
className="w-full rounded-[32px] flex items-center justify-center px-4 py-2.5 text-[#101323] font-instrument_sans font-semibold"
data-testid="next-button"
>
<span>Next</span>
<ChevronRight className="!w-6 !h-6" />
</Button>
</div>
</div>
</div>
</div>
</div>
</Wrapper>
);
};
export default UploadPage;
export default UploadPage;

View file

@ -45,8 +45,8 @@ const page = () => {
return (
<div className="relative">
<Header />
<div className="flex flex-col items-center justify-center my-10">
<h1 className="text-[64px] font-semibold font-instrument_sans text-[#101323] pb-3.5">
<div className="flex flex-col items-center justify-center mb-8">
<h1 className="text-[64px] font-semibold font-instrument_sans text-[#101323] ">
AI Presentation
</h1>
<p className="text-xl font-syne text-[#101323CC]">Choose a design, set preferences, and generate polished slides.</p>

View file

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