Merge pull request #513 from presenton/refactor/ui_components

refactor/ui components
This commit is contained in:
Shiva Raj Badu 2026-04-12 20:16:42 +05:45 committed by GitHub
commit 2ddcd0e4f3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 312 additions and 76 deletions

View file

@ -32,8 +32,7 @@ const DashboardSidebar = () => {
const router = useRouter();
const { llm_config } = useSelector((state: RootState) => state.userConfig)
const textProviderIcon = LLM_PROVIDERS[llm_config.LLM as keyof typeof LLM_PROVIDERS]?.icon
const imageProviderIcon = IMAGE_PROVIDERS[llm_config.IMAGE_PROVIDER as keyof typeof IMAGE_PROVIDERS]?.icon || '/providers/pexel.png'
@ -45,11 +44,11 @@ const DashboardSidebar = () => {
>
<div>
<div onClick={() => { trackEvent(MixpanelEvent.Sidebar_Navigation_Clicked, { target: '/dashboard' }); router.push("/dashboard"); }} className="flex items-center pb-6 border-b border-[#E1E1E5] gap-2 ">
<Link href={`/dashboard`} onClick={() => { trackEvent(MixpanelEvent.Sidebar_Navigation_Clicked, { target: '/dashboard' }); }} className="flex items-center pb-6 border-b border-[#E1E1E5] gap-2 ">
<div className="bg-[#7C51F8] rounded-full cursor-pointer p-1 flex justify-center items-center mx-auto">
<img src="/logo-with-bg.png" alt="Presenton logo" className="h-[40px] object-contain w-full" />
</div>
</div>
</Link>
<nav className="pt-6 font-syne" aria-label="Dashboard sections">
<div className=" space-y-6">

View file

@ -28,10 +28,10 @@ const Header = () => {
const backHref = backToUpload ? "/upload" : backToTemplates ? "/templates" : "/dashboard";
const backLabel = backToUpload
? "Back"
? "BACK"
: backToTemplates
? "Back"
: "Back";
? "BACK"
: "BACK";
return (
<div className="w-full sticky top-0 z-50 py-7 "
@ -55,12 +55,12 @@ const Header = () => {
<div>
<Link
href={backHref}
className="text-[#7A5AF8] text-xs font-syne font-semibold flex items-center gap-2"
className="text-[#333333] text-xs font-syne font-semibold flex items-center gap-2"
onClick={() =>
trackEvent(MixpanelEvent.Navigation, { from: pathname, to: backHref })
}
>
<ArrowLeft className="w-4 h-4 shrink-0" aria-hidden />
<ArrowLeft className="w-4 h-4 shrink-0 text-[#333333]" aria-hidden />
<span>{backLabel}</span>
</Link>
</div>

View file

@ -10,7 +10,6 @@ import {
User,
UserCheck,
} from "lucide-react";
import { Button } from "./ui/button";
import {
Command,
CommandEmpty,
@ -18,11 +17,12 @@ import {
CommandInput,
CommandItem,
CommandList,
} from "./ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
} from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { getApiUrl } from "@/utils/api";
import { Button } from "@/components/ui/button";
interface CodexConfigProps {
codexModel: string;
@ -205,7 +205,8 @@ export default function CodexConfig({
await fetch(getApiUrl("/api/v1/ppt/codex/auth/logout"), { method: "POST" });
setAuthStatus("unauthenticated");
applyProfile({});
onInputChange("", "codex_model");
onInputChange("codex", "LLM");
onInputChange('', "codex_model");
toast.success("Signed out from ChatGPT");
} catch {
toast.error("Sign out failed");

View file

@ -251,7 +251,6 @@ const SettingsPage = () => {
return null;
}
const textProviderKey = llmConfig.LLM || "openai";
const textProviderLabel =
LLM_PROVIDERS[textProviderKey]?.label || textProviderKey;
@ -279,6 +278,67 @@ const SettingsPage = () => {
? IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER]?.label || llmConfig.IMAGE_PROVIDER
: "No image provider";
useEffect(() => {
if (llmConfig.LLM === "codex" && !llmConfig.CODEX_MODEL || llmConfig.LLM === "openai" && !llmConfig.OPENAI_MODEL || llmConfig.LLM === "google" && !llmConfig.GOOGLE_MODEL || llmConfig.LLM === "anthropic" && !llmConfig.ANTHROPIC_MODEL || llmConfig.LLM === "ollama" && !llmConfig.OLLAMA_MODEL || llmConfig.LLM === "custom" && !llmConfig.CUSTOM_MODEL) {
notify.error("Cannot save settings", "Please select a model for the selected provider");
const currentUrl = window.location.href;
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
console.log("beforeunload");
e.preventDefault();
e.returnValue = "";
};
const handleClick = (e: MouseEvent) => {
const target = e.target as HTMLElement | null;
const link = target?.closest("a");
if (!link) return;
const href = link.getAttribute("href");
const targetAttr = link.getAttribute("target");
if (
href &&
href !== "#" &&
!href.startsWith("javascript:") &&
targetAttr !== "_blank"
) {
// notify.error("Cannot save settings", "Please select a model for the selected provider");
e.preventDefault();
window.history.pushState(null, "", pathname);
}
};
const handlePopState = () => {
console.log("popstate");
window.history.pushState(null, "", pathname);
};
window.addEventListener("beforeunload", handleBeforeUnload);
window.addEventListener("popstate", handlePopState);
document.addEventListener("click", handleClick, true);
// keep current page in history
window.history.pushState(null, "", currentUrl);
return () => {
window.removeEventListener("beforeunload", handleBeforeUnload);
window.removeEventListener("popstate", handlePopState);
document.removeEventListener("click", handleClick, true);
};
}
}, [llmConfig, pathname]);
return (
<div className="h-screen font-syne flex flex-col overflow-hidden relative">
<div

View file

@ -1,4 +1,3 @@
import CodexConfig from '@/components/SettingCodex';
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';
@ -10,6 +9,7 @@ import { LLM_PROVIDERS } from '@/utils/providerConstants';
import { Check, Loader2, Eye, EyeOff, ChevronUp } from 'lucide-react';
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { notify } from '@/components/ui/sonner';
import CodexConfig from './SettingCodex';
interface OpenAIConfigProps {

View file

@ -37,6 +37,13 @@ const OutlinePage: React.FC = () => {
if (!presentation_id) {
return <EmptyStateView />;
}
const handleTabChange = (tab: string) => {
if (streamState.isStreaming) {
return;
}
setActiveTab(tab);
};
return (
@ -51,10 +58,10 @@ const OutlinePage: React.FC = () => {
<Wrapper className="flex flex-col w-full relative px-5 sm:px-10 lg:px-20 ">
<div className="w-full mx-auto">
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex w-full flex-col">
<Tabs value={activeTab} onValueChange={handleTabChange} className="flex w-full flex-col">
{/* Reserves vertical space so content does not sit under the fixed tab bar */}
<div className="h-[4.75rem] shrink-0 sm:h-[5rem]" aria-hidden />
<div className="fixed top-26 left-0 right-0 z-40 pb-2">
<div className="fixed top-26 left-0 right-0 z-50 pb-2">
<div className="mx-auto w-full max-w-[1440px] px-5 sm:px-10 lg:px-20">
<TabsList className="my-4 h-auto w-fit rounded-full border border-[#EDEEEF] bg-white p-1.5">
<TabsTrigger

View file

@ -23,9 +23,15 @@ const LoadingState = () => {
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 mx-auto w-[500px] flex flex-col items-center justify-center p-8">
<div className="w-full bg-white rounded-xl p-[2px] ">
<div className="bg-white rounded-xl p-6 w-full">
<div className="flex items-center justify-center space-x-4 ">
<h2 className="text-2xl font-semibold text-gray-800">Creating Your Presentation</h2>
<div className="flex flex-col items-center justify-center gap-4">
<div
className="presentation-loader-dots shrink-0"
role="status"
aria-label="Loading"
/>
<h2 className="text-2xl font-semibold text-gray-800">
Creating Your Presentation
</h2>
</div>
<div className="w-full max-w-md bg-white/80 backdrop-blur-sm rounded-xl shadow-sm p-6 mb-4">
<div className="min-h-[120px] flex items-center justify-center">
@ -42,6 +48,29 @@ const LoadingState = () => {
</div>
</div>
</div>
<style jsx>{`
.presentation-loader-dots {
width: 50px;
aspect-ratio: 1;
--_c: no-repeat radial-gradient(
farthest-side,
#7a5af8 92%,
#0000
);
background:
var(--_c) top,
var(--_c) left,
var(--_c) right,
var(--_c) bottom;
background-size: 12px 12px;
animation: presentation-loader-l7 1s infinite;
}
@keyframes presentation-loader-l7 {
to {
transform: rotate(0.5turn);
}
}
`}</style>
</div>
);
};

View file

@ -81,19 +81,22 @@ const SlideCountSelect: React.FC<{
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<Button
variant="outline"
<button
role="combobox"
name="slides"
data-testid="slides-select"
aria-expanded={open}
className="w-[105px] overflow-hidden font-syne font-medium bg-[#F6F6F9] text-slate-700 hover:bg-slate-50 focus-visible:ring-[#5146E5]/30 flex justify-between items-center gap-2 h-10 rounded-full px-3.5 ring-1 ring-inset ring-slate-200 shadow-sm"
className=" overflow-hidden font-syne font-medium text-[#191919] focus-visible:ring-[#5146E5]/30 flex justify-between items-center gap-2 h-[34px] rounded-full px-3.5 ring-1 ring-inset ring-slate-200 shadow-sm"
>
<span className="flex min-w-0 flex-1 items-center gap-2.5">
<span className="text-sm font-medium ">{displayLabel}</span>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M4.0835 12.25H9.91683" stroke="black" strokeLinecap="round" strokeLinejoin="round" />
<path d="M11.6665 1.75H2.33317C1.68884 1.75 1.1665 2.27233 1.1665 2.91667V8.75C1.1665 9.39433 1.68884 9.91667 2.33317 9.91667H11.6665C12.3108 9.91667 12.8332 9.39433 12.8332 8.75V2.91667C12.8332 2.27233 12.3108 1.75 11.6665 1.75Z" stroke="black" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span className="flex flex-1 items-center gap-2.5">
<span className="text-xs font-medium ">{displayLabel}</span>
</span>
<ChevronUp className="ml-2 h-4 w-4 shrink-0" />
</Button>
</button>
</PopoverTrigger>
<PopoverContent className="w-[140px] p-0 font-syne" align="end">
<div
@ -170,21 +173,22 @@ const LanguageSelect: React.FC<{
}> = ({ value, onValueChange, open, onOpenChange }) => (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger asChild>
<Button
variant="outline"
<button
role="combobox"
name="language"
data-testid="language-select"
aria-expanded={open}
className="w-[120px] overflow-hidden flex justify-between items-center gap-2 font-syne font-semibold bg-[#F6F6F9] text-slate-700 h-10 rounded-full px-3.5 ring-1 ring-inset ring-slate-200 shadow-sm"
className="w-[125px] flex items-center gap-2 overflow-hidden font-syne font-semibold text-[#191919] h-10 rounded-full px-3.5 ring-1 ring-inset ring-slate-200 shadow-sm"
>
<span className="min-w-[65px] flex-1 text-left">
<span className="text-sm font-medium truncate block">
<Languages className="w-3.5 h-3.5" />
<span className="w-[40px] text-left">
<span className="text-xs font-medium truncate block">
{value || "Select language"}
</span>
</span>
<ChevronUp className="ml-2 h-4 w-4 shrink-0" />
</Button>
</button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0" align="end">
<Command>
@ -284,7 +288,7 @@ export function ConfigurationSelects({
title="Advanced settings"
type="button"
onClick={() => handleOpenAdvancedChange(true)}
className="ml-auto flex items-center gap-2 text-sm bg-[#F6F6F9] 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"
className="ml-auto flex items-center gap-2 text-sm 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" />

View file

@ -0,0 +1,43 @@
import { RootState } from '@/store/store';
import { IMAGE_PROVIDERS, LLM_PROVIDERS } from '@/utils/providerConstants';
import React from 'react'
import { useSelector } from 'react-redux';
const CurrentConfig = () => {
const userConfigState = useSelector((state: RootState) => state.userConfig);
const llmConfig = userConfigState.llm_config;
const textProviderKey = llmConfig.LLM || "openai";
const textProviderLabel =
LLM_PROVIDERS[textProviderKey]?.label || textProviderKey;
const selectedTextModel =
textProviderKey === "openai"
? llmConfig.OPENAI_MODEL
: textProviderKey === "google"
? llmConfig.GOOGLE_MODEL
: textProviderKey === "anthropic"
? llmConfig.ANTHROPIC_MODEL
: textProviderKey === "ollama"
? llmConfig.OLLAMA_MODEL
: textProviderKey === "custom"
? llmConfig.CUSTOM_MODEL
: textProviderKey === "codex"
? llmConfig.CODEX_MODEL
: "";
const textSummary = selectedTextModel
? `${textProviderLabel} (${selectedTextModel})`
: textProviderLabel;
const imageSummary = llmConfig.DISABLE_IMAGE_GENERATION
? "Image generation disabled"
: llmConfig.IMAGE_PROVIDER
? IMAGE_PROVIDERS[llmConfig.IMAGE_PROVIDER]?.label || llmConfig.IMAGE_PROVIDER
: "No image provider";
return (
<p className="text-[10px] px-2.5 py-0.5 rounded-[50px] text-[#7A5AF8] border border-[#EDEEEF] font-medium ">
{textSummary} · {imageSummary}
</p>
)
}
export default CurrentConfig

View file

@ -1,4 +1,5 @@
import { Textarea } from "@/components/ui/textarea";
import { PencilIcon } from "lucide-react";
import { useState } from "react";
interface PromptInputProps {
@ -16,14 +17,24 @@ export function PromptInput({ value, onChange }: PromptInputProps) {
return (
<div className="relative font-syne">
<div className="relative font-syne border border-[#DBDBDB99] rounded-[8px] px-[10px] py-3"
style={{
boxShadow: "0 4px 14px 0 rgba(0, 0, 0, 0.04)",
}}
>
<div className="flex items-center gap-2 mb-1">
<PencilIcon className="w-3.5 h-3.5" />
<p className="text-sm font-normal text-[#333333] font-syne ">Write prompt</p>
</div>
<Textarea
value={value}
rows={5}
autoFocus={true}
rows={4}
onChange={(e) => handleChange(e.target.value)}
placeholder="Tell us about your presentation"
placeholder="Start with your idea… well handle the slides"
data-testid="prompt-input"
className={`py-3 px-2.5 border-2 font-medium font-instrument_sans text-base min-h-[150px] max-h-[300px] border-[#DBDBDB99] focus-visible:ring-offset-0 focus-visible:ring-[#5146E5] overflow-y-auto custom_scrollbar `}
className={`px-2 py-0 font-medium shadow-none font-syne indent-4 text-base min-h-[120px] max-h-[250px] focus-visible:ring-offset-0 focus-visible:ring-transparent focus-visible:ring-0 border-none overflow-y-auto custom_scrollbar `}
/>
</div>

View file

@ -1,7 +1,7 @@
'use client'
import React, { ChangeEvent, useEffect, useMemo, useState } from 'react'
import { File, Paperclip, X } from 'lucide-react'
import { File, Paperclip, Plus, X } from 'lucide-react'
import { toast } from 'sonner'
interface SupportingDocProps {
@ -196,10 +196,12 @@ const SupportingDoc = ({
data-testid="file-upload-input"
/>
<div className="flex flex-col items-center gap-2">
<Paperclip className="h-6 w-6 text-[#5146E5]" />
<p className="text-sm font-medium text-gray-800 font-syne">
Drag and drop Office docs, spreadsheets, images, PDF/TXT, or <span className="text-[#5146E5]">click to browse</span>
</p>
<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>
<p className='text-[#808080] text-sm font-normal'>(Office docs, spreadsheets, images, PDF/TXT)</p>
</div>
</label>

View file

@ -28,6 +28,7 @@ import { trackEvent, MixpanelEvent } from "@/utils/mixpanel";
import { ConfigurationSelects } from "./ConfigurationSelects";
import { RootState } from "@/store/store";
import { ImagesApi } from "../../services/api/images";
import CurrentConfig from "./CurrentConfig";
const STOCK_IMAGE_PROVIDERS = new Set(["pexels", "pixabay"]);
@ -248,20 +249,17 @@ const UploadPage = () => {
duration={loadingState.duration}
extra_info={loadingState.extra_info}
/>
<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 px-4 py-5">
<div >
<h2 className="text-lg font-unbounded tracking-tight text-[#191919] ">Configuration</h2>
</div>
<div className="rounded-2xl " >
<div className="flex flex-col gap-4 md:items-center md:flex-row justify-between px-4 ">
<CurrentConfig />
<ConfigurationSelects
config={config}
onConfigChange={handleConfigChange}
/>
</div>
<div className="border-t border-slate-200/70" />
<div className="p-4 mt-2 ">
<h3 className="text-sm font-normal font-unbounded text-[#333333] mb-2">Content</h3>
<div className="p-4 ">
<div className="relative">
<PromptInput
value={config.prompt}

View file

@ -46,11 +46,24 @@ const page = () => {
<div className="relative">
<Header />
<div className="flex flex-col items-center justify-center mb-8">
<h1 className="text-[42px] font-normal font-unbounded text-[#101323] ">
Generate Presentation
<h1 className="text-[64px] relative leading-[112%] font-semibold font-syne text-[#101323] ">
Generate
<svg className="absolute top-[-4rem] left-[-5rem]" xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 13 13" fill="none">
<path d="M9.73497 5.85272C8.05237 5.69492 6.72098 4.39958 6.55904 2.76316L6.28582 0L6.0126 2.76316C5.85066 4.39985 4.51927 5.6952 2.83667 5.85272L0 6.11849L2.83667 6.38426C4.51927 6.54206 5.85066 7.8374 6.0126 9.47382L6.28582 12.237L6.55904 9.47382C6.72098 7.83713 8.05237 6.54178 9.73497 6.38426L12.5716 6.11849L9.73497 5.85272Z" fill="#09CCFE" />
</svg>
<svg className="absolute top-[-2rem] left-[-1rem]" xmlns="http://www.w3.org/2000/svg" width="26" height="25" viewBox="0 0 26 25" fill="none">
<path d="M19.4699 11.7054C16.1047 11.3898 13.442 8.79915 13.1181 5.52632L12.5716 0L12.0252 5.52632C11.7013 8.79971 9.03854 11.3904 5.67335 11.7054L0 12.237L5.67335 12.7685C9.03854 13.0841 11.7013 15.6748 12.0252 18.9476L12.5716 24.474L13.1181 18.9476C13.442 15.6743 16.1047 13.0836 19.4699 12.7685L25.1433 12.237L19.4699 11.7054Z" fill="#09CCFE" />
</svg>
<svg className="absolute bottom-0 -right-10" xmlns="http://www.w3.org/2000/svg" width="41" height="41" viewBox="0 0 41 41" fill="none">
<path d="M31.6166 19.8734C26.275 19.3587 22.0484 15.134 21.5343 9.797L20.6669 0.785156L19.7995 9.797C19.2854 15.1349 15.0588 19.3596 9.71723 19.8734L0.711914 20.7401L9.71723 21.6069C15.0588 22.1216 19.2854 26.3462 19.7995 31.6833L20.6669 40.6951L21.5343 31.6833C22.0484 26.3453 26.275 22.1207 31.6166 21.6069L40.6219 20.7401L31.6166 19.8734Z" fill="#DF92FC" />
</svg>
</h1>
<p className="text-xl font-syne text-[#101323CC]">Choose a design, set preferences, and generate polished slides.</p>
<p className="text-xl font-syne text-[#101323CC]">Turn prompts or documents into presentations with AI</p>
</div>
{/* stars */}
<UploadPage />
</div>

View file

@ -1,27 +1,12 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
Check,
ChevronUp,
Loader2,
RefreshCw,
Trash2,
Crown,
User,
UserCheck,
ArrowRight,
} from "lucide-react";
import { Button } from "./ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "./ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
import { getApiUrl } from "@/utils/api";
import { MixpanelEvent, trackEvent } from "@/utils/mixpanel";

View file

@ -1,5 +1,4 @@
import { cn } from "@/lib/utils"
import { Loader } from "./loader"
import { ProgressBar } from "./progress-bar"
import { useEffect, useState } from "react"
@ -46,14 +45,18 @@ export const OverlayLoader = ({
>
<div
className={cn(
"flex flex-col items-center justify-center px-6 pt-0 pb-8 rounded-xl bg-[#030303] shadow-2xl",
"min-w-[280px] sm:min-w-[330px] border border-white/10 transition-all duration-400 ease-out",
"flex flex-col items-center justify-center px-6 pt-6 pb-10 rounded-xl bg-white shadow-2xl relative min-h-[347px]",
"min-w-[280px] sm:min-w-[447px] border border-white/10 transition-all duration-400 ease-out",
isVisible ? "opacity-100 scale-100" : "opacity-0 scale-90",
className
)}
>
<img loading="eager" src={'/loading.gif'} alt="loading" width={250} height={250} />
<div
className="overlay-loader-dots shrink-0"
role="status"
aria-label="Loading"
/>
{showProgress ? (
<div className="w-full space-y-6 pt-4">
<ProgressBar
@ -62,23 +65,68 @@ export const OverlayLoader = ({
/>
{text && (
<div className="space-y-1">
<p className="text-white text-base text-center font-semibold font-inter">
<p className="text-[#191919] text-base text-center font-medium font-inter">
{text}
</p>
{extra_info && <p className="text-white/80 text-xs text-center font-semibold font-inter">{extra_info}</p>}
{extra_info && <p className="text-[#191919]/80 text-xs text-center font-medium font-inter">{extra_info}</p>}
</div>
)}
</div>
) : (
<>
<p className="text-white text-base text-center font-semibold font-inter">
<p className="text-[#191919] text-base text-center font-medium font-inter">
{text}
</p>
{extra_info && <p className="text-white/80 text-xs text-center font-semibold font-inter">{extra_info}</p>}
{extra_info && <p className="text-[#191919]/80 text-xs text-center font-medium font-inter">{extra_info}</p>}
</>
)}
<svg className="absolute left-0 bottom-0" xmlns="http://www.w3.org/2000/svg" width="447" height="277" viewBox="0 0 447 277" fill="none">
<g filter="url(#filter0_d_4852_6112)">
<path d="M674.5 748.5C668.101 804.091 669 808.5 657.5 832L639 887.5C627 972.5 668.5 1143.5 785 1158.5C984.755 1184.22 877.602 926.811 837.653 808.716C843.652 768.181 841.852 633.973 786.657 421.42C717.663 155.729 278.698 139.89 18.7199 302.37C-241.259 464.851 -399.894 486.766 -478.239 422.953C-544.734 368.793 -537.234 154.707 -464.24 75L-757.716 82.1532C-760.716 183.831 -739.218 390.764 -726.719 430.617C-715.665 465.864 -652.725 581.857 -516.736 619.156C-390.988 653.646 -209.56 584.814 -169.765 572.66C-136.5 562.5 97.7134 443.561 210.704 380.545C699.164 216.532 682.499 679.012 674.5 748.5Z" fill="url(#paint0_radial_4852_6112)" shape-rendering="crispEdges" />
</g>
<defs>
<filter id="filter0_d_4852_6112" x="-833" y="0" width="1810.32" height="1235.29" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha" />
<feOffset />
<feGaussianBlur stdDeviation="37.5" />
<feComposite in2="hardAlpha" operator="out" />
<feColorMatrix type="matrix" values="0 0 0 0 0.85098 0 0 0 0 0.839216 0 0 0 0 0.996078 0 0 0 1 0" />
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_4852_6112" />
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_4852_6112" result="shape" />
</filter>
<radialGradient id="paint0_radial_4852_6112" cx="0" cy="0" r="1" gradientTransform="matrix(-987.419 -112.408 219.823 -2016.77 351.693 300.327)" gradientUnits="userSpaceOnUse">
<stop stop-color="#D9D6FE" />
<stop offset="1" stop-color="white" stop-opacity="0" />
</radialGradient>
</defs>
</svg>
</div>
<style jsx>{`
.overlay-loader-dots {
width: 50px;
aspect-ratio: 1;
--_c: no-repeat radial-gradient(
farthest-side,
#7A5AF8 92%,
#0000
);
background:
var(--_c) top,
var(--_c) left,
var(--_c) right,
var(--_c) bottom;
background-size: 12px 12px;
animation: overlay-loader-l7 1s infinite;
}
@keyframes overlay-loader-l7 {
to {
transform: rotate(0.5turn);
}
}
`}</style>
</div>
)
}

View file

@ -110,6 +110,42 @@ export const getLLMConfigValidationError = (
return null;
};
/** Codex is selected but no model chosen — block navigation away from Settings. */
export function isCodexMissingSelectedModel(llmConfig: LLMConfig): boolean {
return llmConfig.LLM === "codex" && !isProvided(llmConfig.CODEX_MODEL);
}
/**
* While on Settings with Codex selected and no model (e.g. after sign-out), block leaving
* for any destination other than Settings. Resolves once the user picks a model, signs in again, or switches provider.
*/
export function shouldBlockCodexOutboundNav(
llmConfig: LLMConfig,
destinationPath: string,
currentPathname: string | null
): boolean {
if (!isCodexMissingSelectedModel(llmConfig)) return false;
const onSettings =
currentPathname === "/settings" ||
(currentPathname?.startsWith("/settings/") ?? false);
if (!onSettings) return false;
const path = destinationPath.split("?")[0] || "";
if (path === "/settings" || path.startsWith("/settings/")) return false;
return true;
}
/** Keep Redux in sync when Codex signs out so nav guards see cleared CODEX_MODEL. */
export function syncStoreAfterCodexSignOut(): void {
const prev = store.getState().userConfig.llm_config;
store.dispatch(
setLLMConfig({
...prev,
LLM: "codex",
CODEX_MODEL: "",
})
);
}
export const handleSaveLLMConfig = async (llmConfig: LLMConfig) => {
const validationError = getLLMConfigValidationError(llmConfig);
if (validationError) {