feat: Update Text/Image provider & Pages Designs.
This commit is contained in:
parent
ebccd4976b
commit
3cdbf246ab
17 changed files with 1381 additions and 817 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 "Export
|
||||
(API)" 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>
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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'>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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]">
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 & 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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue