From 3cdbf246aba52f3b7fcee85003236706f52ebf8b Mon Sep 17 00:00:00 2001
From: shiva raj badu
Date: Mon, 2 Mar 2026 00:02:19 +0545
Subject: [PATCH] feat: Update Text/Image provider & Pages Designs.
---
.../dashboard/components/Header.tsx | 26 +-
.../(dashboard)/settings/ImageProvider.tsx | 421 ++++++++----------
.../(dashboard)/settings/SettingPage.tsx | 65 +--
.../(dashboard)/settings/SettingSideBar.tsx | 30 +-
.../(dashboard)/settings/TextProvider.tsx | 362 ++++++++++++---
.../components/NewSlide.tsx | 4 +-
.../components/PresentationMode.tsx | 89 +++-
.../outline/components/GenerateButton.tsx | 35 +-
.../outline/components/OutlinePage.tsx | 15 +-
.../components/PresentationHeader.tsx | 2 +-
.../presentation/components/SlideContent.tsx | 193 ++++----
.../components/ConfigurationSelects.tsx | 370 +++++++++++++++
.../upload/components/PromptInput.tsx | 48 +-
.../upload/components/SupportingDoc.tsx | 389 ++++++++--------
.../upload/components/UploadPage.tsx | 141 ++----
.../(presentation-generator)/upload/page.tsx | 4 +-
servers/nextjs/utils/providerConstants.ts | 4 +
17 files changed, 1381 insertions(+), 817 deletions(-)
create mode 100644 servers/nextjs/app/(presentation-generator)/upload/components/ConfigurationSelects.tsx
diff --git a/servers/nextjs/app/(presentation-generator)/(dashboard)/dashboard/components/Header.tsx b/servers/nextjs/app/(presentation-generator)/(dashboard)/dashboard/components/Header.tsx
index d88d8894..6c1f4c0e 100644
--- a/servers/nextjs/app/(presentation-generator)/(dashboard)/dashboard/components/Header.tsx
+++ b/servers/nextjs/app/(presentation-generator)/(dashboard)/dashboard/components/Header.tsx
@@ -18,35 +18,13 @@ const Header = () => {
{/* {(pathname !== "/upload" && pathname !== "/dashboard") && } */}
trackEvent(MixpanelEvent.Navigation, { from: pathname, to: "/dashboard" })}>
-
- 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"
- >
-
- Create Template
-
- 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"
- >
-
- Templates
-
-
-
+
diff --git a/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/ImageProvider.tsx b/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/ImageProvider.tsx
index 1a0d006b..96777dbd 100644
--- a/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/ImageProvider.tsx
+++ b/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/ImageProvider.tsx
@@ -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)[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 (
-
+
DALLĀ·E 3 Image Quality
@@ -49,28 +74,7 @@ const ImageProvider = ({ llmConfig, setLlmConfig }: { llmConfig: LLMConfig, setL
))}
- {/* {DALLE_3_QUALITY_OPTIONS.map((option) => (
-
- input_field_changed(option.value, "dall_e_3_quality")
- }
- >
-
- {option.label}
-
-
- {option.description}
-
-
- ))} */}
+
);
@@ -78,7 +82,7 @@ const ImageProvider = ({ llmConfig, setLlmConfig }: { llmConfig: LLMConfig, setL
if (llmConfig.IMAGE_PROVIDER === "gpt-image-1.5") {
return (
-
+
GPT Image 1.5 Quality
@@ -98,28 +102,7 @@ const ImageProvider = ({ llmConfig, setLlmConfig }: { llmConfig: LLMConfig, setL
))}
- {/* {GPT_IMAGE_1_5_QUALITY_OPTIONS.map((option) => (
-
- input_field_changed(option.value, "GPT_IMAGE_1_5_QUALITY")
- }
- >
-
- {option.label}
-
-
- {option.description}
-
-
- ))} */}
+
);
@@ -139,6 +122,7 @@ const ImageProvider = ({ llmConfig, setLlmConfig }: { llmConfig: LLMConfig, setL
handleChangeImageGenerationDisabled(checked)}
/>
@@ -158,217 +142,192 @@ const ImageProvider = ({ llmConfig, setLlmConfig }: { llmConfig: LLMConfig, setL
Choosing where images come from
-
+
+
- {!isImageGenerationDisabled && (
- <>
- {/* Image Provider Selection */}
-
-
- Select Image Provider
-
-
-
-
-
-
-
- {llmConfig.IMAGE_PROVIDER
- ? IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER]
- ?.label || llmConfig.IMAGE_PROVIDER
- : "Select image provider"}
-
-
-
-
-
-
+ {/* Image Provider Selection */}
+
+
+ Select Image Provider
+
+
+
-
-
-
- No provider found.
-
- {Object.values(IMAGE_PROVIDERS).map(
- (provider, index) => (
- {
- input_field_changed(value, "IMAGE_PROVIDER");
- setOpenImageProviderSelect(false);
- }}
- >
-
-
-
-
-
- {provider.label}
+
+
+
+
+ {llmConfig.IMAGE_PROVIDER
+ ? IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER]
+ ?.label || llmConfig.IMAGE_PROVIDER
+ : "Select image provider"}
+
+
+
+
+
+
+
+
+
+ No provider found.
+
+ {Object.values(IMAGE_PROVIDERS).map(
+ (provider, index) => (
+ {
+ input_field_changed(value, "IMAGE_PROVIDER");
+ setOpenImageProviderSelect(false);
+ }}
+ >
+
+
+
+
+
+ {provider.label}
+
+
+
+ {provider.description}
-
- {provider.description}
-
-
-
- )
- )}
-
-
-
-
-
+
+ )
+ )}
+
+
+
+
+
+
-
- {/* 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 (
+
+
+
+ ComfyUI Server URL
+
+
+ {
+ input_field_changed(
+ e.target.value,
+ "COMFYUI_URL"
+ );
+ }}
+ />
+
- if (
- provider.value === "GEMINI_FLASH" &&
- llmConfig.LLM === "google"
- ) {
- return <>>;
- }
+
- if (
- provider.value === "NANO_BANANA_PRO" &&
- llmConfig.LLM === "google"
- ) {
- return <>>;
- }
+
+ );
+ }
- // Show ComfyUI configuration
- if (provider.value === "comfyui") {
+ // Show API key input for other providers
return (
-
-
-
- ComfyUI Server URL
-
-
- {
- input_field_changed(
- e.target.value,
- "COMFYUI_URL"
- );
- }}
- />
-
-
-
- Use your machine IP address (not localhost) when
- running in Docker
-
-
-
-
- Workflow JSON
-
-
-
-
- Export your workflow from ComfyUI using "Export
- (API)" and paste the JSON here.
-
+
+
+ {provider.apiKeyFieldLabel}
+
+
+
+ updateFieldValue(
+ provider.apiKeyField,
+ e.target.value
+ )
+ }
+ />
+
);
- }
+ })()}
- // Show API key input for other providers
- return (
-
-
- {provider.apiKeyFieldLabel}
-
-
-
- // input_field_changed(
- // provider.apiKeyField || "",
- // e.target.value
- // )
- // }
- />
-
+ >
+ )}
+
+
-
- );
- })()}
+ {renderQualitySelector(llmConfig, input_field_changed)}
+ {llmConfig.IMAGE_PROVIDER === "comfyui" &&
+
+ Workflow JSON
+
+
+
- {renderQualitySelector(llmConfig, input_field_changed)}
- >
- )}
+
}
+
{/* Web Grounding Toggle - show at the end, below models dropdown */}
-
+ {/*
Advanced
@@ -386,7 +345,7 @@ const ImageProvider = ({ llmConfig, setLlmConfig }: { llmConfig: LLMConfig, setL
-
+
*/}
diff --git a/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/SettingPage.tsx b/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/SettingPage.tsx
index 77cb4829..c040630f 100644
--- a/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/SettingPage.tsx
+++ b/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/SettingPage.tsx
@@ -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 (
-
+
+
@@ -168,8 +175,7 @@ const SettingsPage = () => {
Settings
-
-
+
@@ -193,30 +199,29 @@ const SettingsPage = () => {
{/* Fixed Bottom Button */}
-
-
-
- {buttonState.isLoading ? (
-
-
- {buttonState.text}
-
- ) : (
- buttonState.text
- )}
-
-
+
+
+ {buttonState.isLoading ? (
+
+
+ {buttonState.text}
+
+ ) : (
+ buttonState.text
+ )}
+
+
{/* Download Progress Modal */}
diff --git a/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/SettingSideBar.tsx b/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/SettingSideBar.tsx
index 882c2181..f2286498 100644
--- a/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/SettingSideBar.tsx
+++ b/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/SettingSideBar.tsx
@@ -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 (
FILTER BY:
Select Mode
-
setMode('nanobanana')}
- style={{
- background: mode === 'nanobanana' ? '#F4F3FF' : 'transparent',
- color: mode === 'nanobanana' ? '#5146E5' : '#3A3A3A'
- }}
- >Nanobanana
-
-
-
setMode('presenton')}
style={{
@@ -24,6 +13,25 @@ const SettingSideBar = ({ mode, setMode, selectedProvider, setSelectedProvider }
color: mode === 'presenton' ? '#5146E5' : '#3A3A3A'
}}
>Presenton
+
+
+
+
+
+ Nanobanana
+
+
+ Coming soon
+
+
+
+
Select Provider
{mode === 'presenton' &&
diff --git a/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/TextProvider.tsx b/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/TextProvider.tsx
index a4ae09a8..47b3f5d3 100644
--- a/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/TextProvider.tsx
+++ b/servers/nextjs/app/(presentation-generator)/(dashboard)/settings/TextProvider.tsx
@@ -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
([]);
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)[currentModelField] as string || '') : '';
+ const currentApiKey = currentApiKeyField ? ((llmConfig as Record)[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 (
{/* API Key Input */}
@@ -94,32 +219,139 @@ const TextProvider = ({
Choosing where text contets come from
-
-
-
+
+
+
- OpenAI API Key
+ Select Text Provider
-
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"
- />
+
+
+
+
+
+ {llmConfig.LLM
+ ? LLM_PROVIDERS[llmConfig.LLM]
+ ?.label || llmConfig.LLM
+ : "Select text provider"}
+
+
+
+
+
+
+
+
+
+ No provider found.
+
+ {Object.values(LLM_PROVIDERS).map(
+ (provider, index) => (
+ {
+ onInputChange(value, "LLM");
+ setOpenProviderSelect(false);
+ }}
+ >
+
+
+
+
+
+ {provider.label}
+
+
+
+ {provider.description}
+
+
+
+
+ )
+ )}
+
+
+
+
+
- {/* Check for available models button - show when no models checked or no models found */}
- {(!modelsChecked || (modelsChecked && availableModels.length === 0)) && (
+
+
+
+
+
+ {selectedProvider !== 'ollama' && (!modelsChecked || (modelsChecked && availableModels.length === 0)) && (
{modelsLoading ? (
@@ -128,27 +360,20 @@ const TextProvider = ({
Checking for models...
) : (
- "Check for available models"
+ "Check models"
)}
)}
-
- {/* Show message if no models found */}
- {modelsChecked && availableModels.length === 0 && (
-
-
- No models found. Please make sure your API key is valid and has access to OpenAI models.
-
-
- )}
- {/* Model Selection - only show if models are available */}
- {modelsChecked && availableModels.length > 0 ? (
+
+ {/* Model Selection - only show if models are available */}
+ {modelsChecked && availableModels.length > 0 ? (
+
- Select OpenAI Model
+ {selectedProvider === 'ollama' ? 'Choose a supported model' : `Select ${modelLabel} Model`}
-
-
- {/* {'openaiModel'
- ? availableModels.find(model => model === 'openaiModel') || 'openaiModel'
- : "Select a model"} */}
-
-
+
+ {currentModel
+ ? availableModels.find(model => model === currentModel) || currentModel
+ : "Select a model"}
+
+
@@ -187,14 +411,16 @@ const TextProvider = ({
key={index}
value={model}
onSelect={(value) => {
- onInputChange(value, "openai_model");
+ if (currentModelField) {
+ onInputChange(value, currentModelField);
+ }
setOpenModelSelect(false);
}}
>
- ) : null}
-
+
+ ) : null}
+ {/* Show message if no models found */}
+ {modelsChecked && availableModels.length === 0 && (
+
+
+ No models found. Please make sure your provider credentials are valid and the selected provider is reachable.
+
+
+ )}
{/* Web Grounding Toggle - show at the end, below models dropdown */}
@@ -237,8 +471,8 @@ const TextProvider = ({
onInputChange(checked, "")}
+ checked={!!llmConfig.WEB_GROUNDING}
+ onCheckedChange={(checked) => onInputChange(checked, "WEB_GROUNDING")}
/>
Enable Web Grounding
diff --git a/servers/nextjs/app/(presentation-generator)/components/NewSlide.tsx b/servers/nextjs/app/(presentation-generator)/components/NewSlide.tsx
index 65d5c17d..a64cac8b 100644
--- a/servers/nextjs/app/(presentation-generator)/components/NewSlide.tsx
+++ b/servers/nextjs/app/(presentation-generator)/components/NewSlide.tsx
@@ -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 = ({
Select a Slide Layout
-
setShowNewSlideSelection(false)}
className="text-gray-500 text-2xl cursor-pointer"
/>
diff --git a/servers/nextjs/app/(presentation-generator)/components/PresentationMode.tsx b/servers/nextjs/app/(presentation-generator)/components/PresentationMode.tsx
index a75663e0..d9fbc1b2 100644
--- a/servers/nextjs/app/(presentation-generator)/components/PresentationMode.tsx
+++ b/servers/nextjs/app/(presentation-generator)/components/PresentationMode.tsx
@@ -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 = ({
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 = ({
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 = ({
>
)}
- {/* Slides (all mounted, only current visible) */}
-
-
-
- {slides.length > 0 && slides.map((slide, index) => (
-
-
-
- ))}
-
+ {/* Centered 16:9 stage for consistent alignment in normal + fullscreen modes */}
+
+
+ {slides.length > 0 && slides.map((slide, index) => (
+
+
+
+ ))}
+
+ {currentSpeakerNote && (
+
+ {showSpeakerNotes ? (
+
+
+
+
+ Speaker notes
+
+
{
+ e.stopPropagation();
+ setShowSpeakerNotes(false);
+ }}
+ className="h-8 px-2 text-gray-600 hover:bg-black/5 hover:text-gray-800"
+ >
+
+ Hide
+
+
+
+ {currentSpeakerNote}
+
+
+ ) : (
+
{
+ 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"
+ >
+
+ Show notes
+
+ )}
+
+ )}
);
};
diff --git a/servers/nextjs/app/(presentation-generator)/outline/components/GenerateButton.tsx b/servers/nextjs/app/(presentation-generator)/outline/components/GenerateButton.tsx
index 1cd950b9..a750df95 100644
--- a/servers/nextjs/app/(presentation-generator)/outline/components/GenerateButton.tsx
+++ b/servers/nextjs/app/(presentation-generator)/outline/components/GenerateButton.tsx
@@ -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
= ({
}
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%)",
+ }}
>
-
-
-
-
-
-
-
-
-
-
+
{getButtonText()}
+
);
};
diff --git a/servers/nextjs/app/(presentation-generator)/outline/components/OutlinePage.tsx b/servers/nextjs/app/(presentation-generator)/outline/components/OutlinePage.tsx
index 631d2b05..46ce2422 100644
--- a/servers/nextjs/app/(presentation-generator)/outline/components/OutlinePage.tsx
+++ b/servers/nextjs/app/(presentation-generator)/outline/components/OutlinePage.tsx
@@ -40,7 +40,7 @@ const OutlinePage: React.FC = () => {
return (
-
+
{
duration={loadingState.duration}
/>
-
-
+ {/* Fixed Button */}
- {/* Fixed Button */}
-
);
diff --git a/servers/nextjs/app/(presentation-generator)/presentation/components/PresentationHeader.tsx b/servers/nextjs/app/(presentation-generator)/presentation/components/PresentationHeader.tsx
index 691cb222..2b9b4c1a 100644
--- a/servers/nextjs/app/(presentation-generator)/presentation/components/PresentationHeader.tsx
+++ b/servers/nextjs/app/(presentation-generator)/presentation/components/PresentationHeader.tsx
@@ -197,7 +197,7 @@ const PresentationHeader = ({
{isPresentationSaving &&
}
-
+ {/* */}
diff --git a/servers/nextjs/app/(presentation-generator)/presentation/components/SlideContent.tsx b/servers/nextjs/app/(presentation-generator)/presentation/components/SlideContent.tsx
index d694c594..6030e48f 100644
--- a/servers/nextjs/app/(presentation-generator)/presentation/components/SlideContent.tsx
+++ b/servers/nextjs/app/(presentation-generator)/presentation/components/SlideContent.tsx
@@ -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 && (
-
- {
- 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"
- >
-
-
-
- )}
- {!isStreaming && (
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
-
-
+
+
Update slide
+
+ Describe how this slide should be improved.
+
+
{
+ e.preventDefault();
+ handleSubmit();
+ }}
+ >
+ setEditPrompt(e.target.value)}
+ rows={5}
+ wrap="soft"
+ />
+
+ {isUpdating ? "Updating..." : "Update"}
+
+
+
-
- )}
- {/* Speaker Notes */}
- {!isStreaming && slide?.speaker_note && (
-
-
+
+
-
+
-
-
-
Speaker notes
-
- {slide.speaker_note}
+
+
+
+
+ {slide?.speaker_note?.trim() || "No speaker notes for this slide."}
+
+
+
+
+
+
)}
diff --git a/servers/nextjs/app/(presentation-generator)/upload/components/ConfigurationSelects.tsx b/servers/nextjs/app/(presentation-generator)/upload/components/ConfigurationSelects.tsx
new file mode 100644
index 00000000..64a4f768
--- /dev/null
+++ b/servers/nextjs/app/(presentation-generator)/upload/components/ConfigurationSelects.tsx
@@ -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 (
+
+
+
+
+
+ {/* Sticky custom input at the top */}
+ e.stopPropagation()}
+ onPointerDown={(e) => e.stopPropagation()}
+ onClick={(e) => e.stopPropagation()}
+ >
+
+ 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"
+ />
+ slides
+
+
+
+ {/* Hidden item to allow SelectValue to render custom selection */}
+ {value && !SLIDE_OPTIONS.includes(value as SlideOption) && (
+
+ {value} slides
+
+ )}
+
+ {SLIDE_OPTIONS.map((option) => (
+
+ {option} slides
+
+ ))}
+
+
+ );
+};
+
+/**
+ * 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 }) => (
+
+
+
+
+
+
+
+
+ {value || "Select language"}
+
+
+
+
+
+
+
+
+
+ No language found.
+
+ {Object.values(LanguageType).map((language) => (
+ {
+ onValueChange(currentValue);
+ onOpenChange(false);
+ }}
+ className="font-instrument_sans"
+ >
+
+ {language}
+
+ ))}
+
+
+
+
+
+);
+
+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 (
+
+
onConfigChange("slides", value)}
+ />
+ onConfigChange("language", value)}
+ open={openLanguage}
+ onOpenChange={setOpenLanguage}
+ />
+
+
+ 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"
+ >
+
+
+
+
+
+
+
+ Advanced settings
+
+
+
+ {/* Tone */}
+
+
Tone
+
Controls the writing style (e.g., casual, professional, funny).
+
setAdvancedDraft((prev) => ({ ...prev, tone: value as ToneType }))}
+ >
+
+
+
+
+ {Object.values(ToneType).map((tone) => (
+
+ {tone}
+
+ ))}
+
+
+
+
+ {/* Verbosity */}
+
+
Verbosity
+
Controls how detailed slide descriptions are: concise, standard, or text-heavy.
+
setAdvancedDraft((prev) => ({ ...prev, verbosity: value as VerbosityType }))}
+ >
+
+
+
+
+ {Object.values(VerbosityType).map((verbosity) => (
+
+ {verbosity}
+
+ ))}
+
+
+
+
+
+
+ {/* Toggles */}
+
+
+ Include table of contents
+ setAdvancedDraft((prev) => ({ ...prev, includeTableOfContents: checked }))}
+ />
+
+
Add an index slide summarizing sections (requires 3+ slides).
+
+
+
+ Title slide
+ setAdvancedDraft((prev) => ({ ...prev, includeTitleSlide: checked }))}
+ />
+
+
Include a title slide as the first slide.
+
+
+
+ Web search
+ setAdvancedDraft((prev) => ({ ...prev, webSearch: checked }))}
+ />
+
+
Allow the model to consult the web for fresher facts.
+
+
+ {/* Instructions */}
+
+
Instructions
+
Optional guidance for the AI. These override defaults except format constraints.
+
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"
+ />
+
+
+
+
+ handleOpenAdvancedChange(false)}>Cancel
+ Save
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/servers/nextjs/app/(presentation-generator)/upload/components/PromptInput.tsx b/servers/nextjs/app/(presentation-generator)/upload/components/PromptInput.tsx
index ad28e4ce..5e1baa7f 100644
--- a/servers/nextjs/app/(presentation-generator)/upload/components/PromptInput.tsx
+++ b/servers/nextjs/app/(presentation-generator)/upload/components/PromptInput.tsx
@@ -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 (
+
+
+ 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 `}
+ />
+
-
-
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 `}
- />
-
-
+
);
-}
+}
\ No newline at end of file
diff --git a/servers/nextjs/app/(presentation-generator)/upload/components/SupportingDoc.tsx b/servers/nextjs/app/(presentation-generator)/upload/components/SupportingDoc.tsx
index 970bdaf2..3a7b6826 100644
--- a/servers/nextjs/app/(presentation-generator)/upload/components/SupportingDoc.tsx
+++ b/servers/nextjs/app/(presentation-generator)/upload/components/SupportingDoc.tsx
@@ -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(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, isDragging: boolean) => {
- e.preventDefault()
- e.stopPropagation()
- setIsDragging(isDragging)
+ const handleFilesSelected = (e: ChangeEvent) => {
+ 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) => {
+ const handleDrop = (e: React.DragEvent) => {
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) => {
- 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) => {
+ 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) => {
+ e.preventDefault()
+ setIsDragging(false)
}
+ const handleRemoveFileAt = (index: number) => {
+ const nextFiles = filteredFiles.filter((_, i) => i !== index)
+ onFilesChange(nextFiles)
+ }
+
+ const handleClearFiles = () => {
+ if (!hasFiles) return
+ onFilesChange([])
+ }
return (
-
+
+
+
+ {hasFiles ? `${filteredFiles.length} attachment${filteredFiles.length > 1 ? 's' : ''}` : 'No attachments yet'}
+
+
+ Clear all
+
+
-
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)}
+
-
-
-
-
-
- {isDragging
- ? Drop your file here
- : Click to Upload or drag & drop.
- }
-
-
- Supports PDFs, Text files, PPTX, DOCX
-
-
-
-
-
- {/*
{
- 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
- */}
+
+
+
+
+ Drag and drop PDF, TXT, PPTX, DOCX, or click to browse
+
+
- {files.length > 0 && (
-
-
-
-
- Selected Files ({files.length})
-
-
-
- {filesWithIds.map((file) => {
+ {hasFiles && (
+
+
+ {filteredFiles.map((file, idx) => (
+
+ {previewUrls[idx] ? (
+
+ ) : (
+
+
+
+ )}
- return (
- (
-
-
+
+
+ {file.name}
+
+
{formatFileSize(file.size)}
+
-
-
-
{
- 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"
- >
-
-
-
-
-
-
- {file.name || 'Unnamed File'}
-
-
- {formatFileSize(file.size)}
-
-
-
- )
- )
- })}
-
-
-
- )}
-
-
+
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"
+ >
+
+
+
+ ))}
+
+ {filteredFiles.length !== files.length && (
+
+ Some files were skipped. Only PDF, TXT, PPTX, and DOCX files are supported.
+
+ )}
+
+ )}
)
}
+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
diff --git a/servers/nextjs/app/(presentation-generator)/upload/components/UploadPage.tsx b/servers/nextjs/app/(presentation-generator)/upload/components/UploadPage.tsx
index c90087a6..5c777d1e 100644
--- a/servers/nextjs/app/(presentation-generator)/upload/components/UploadPage.tsx
+++ b/servers/nextjs/app/(presentation-generator)/upload/components/UploadPage.tsx
@@ -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 (
-
-
+
{
duration={loadingState.duration}
extra_info={loadingState.extra_info}
/>
- {/* */}
-
+
+
+
+
Configuration
+
Choose slides, tone, and language preferences.
+
+
+
+
-
+
+
Content
+
+
handleConfigChange("prompt", value)}
+ data-testid="prompt-input"
+ />
+
+
+
+
+
Attachments (optional)
-
+
+
+
-
+
-
- Create Presentation
-
+
Generate Presentation
+
+
-
-
-
-
handleConfigChange("prompt", value)}
- data-testid="prompt-input"
- />
-
-
-
-
-
-
-
-
handleConfigChange("language", value)}
-
- />
-
-
-
-
- Next
-
-
-
-
-
-
-
-
-
-
);
};
-export default UploadPage;
+export default UploadPage;
\ No newline at end of file
diff --git a/servers/nextjs/app/(presentation-generator)/upload/page.tsx b/servers/nextjs/app/(presentation-generator)/upload/page.tsx
index ffb7ac27..6ff417d8 100644
--- a/servers/nextjs/app/(presentation-generator)/upload/page.tsx
+++ b/servers/nextjs/app/(presentation-generator)/upload/page.tsx
@@ -45,8 +45,8 @@ const page = () => {
return (
-
-
+
+
AI Presentation
Choose a design, set preferences, and generate polished slides.
diff --git a/servers/nextjs/utils/providerConstants.ts b/servers/nextjs/utils/providerConstants.ts
index ecccbfa6..a936c6a5 100644
--- a/servers/nextjs/utils/providerConstants.ts
+++ b/servers/nextjs/utils/providerConstants.ts
@@ -22,6 +22,7 @@ export interface LLMProviderOption {
description?: string;
model_value?: string;
model_label?: string;
+ url?: string;
}
export const IMAGE_PROVIDERS: Record
= {
@@ -95,16 +96,19 @@ export const LLM_PROVIDERS: Record = {
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",