Merge pull request #16 from Yiling-J/feat-sticky-headers-and-settings-redesign-8421614059187835189

Make sticky headers transparent with separator
This commit is contained in:
Yiling-J authored and GitHub committed 2026-02-13 12:23:28 +08:00
commit f592de52fd
6 files changed
+407 -270

No files matched your search

+3
View File
@@ -99,6 +99,8 @@ We use `bun:test` for testing.
- Fixes must be iterated until **no check causes any other check to fail**.
- Do **not** submit intermediate states where some checks pass and others fail, even temporarily.
- **Cleanup Requirement**: Remove all verification related files (scripts, screenshots, `verification/` folder) before submit.
## Prisma Configuration & Migrations
- Schema: `prisma/schema.prisma`
@@ -113,6 +115,7 @@ Whenever you modify the `prisma/schema.prisma` file, you **must** generate a cor
**Do not create migration SQL files manually.**
Instead, follow this workflow:
1. Ensure you have a local SQLite database configured (e.g., via `.env` setting `DATABASE_URL="file:./dev.db"`).
2. Run the migration command:
```bash
@@ -0,0 +1,172 @@
import { ArrowDown, BoxSelect, FileSearch, Image as ImageIcon, User, Wand2 } from 'lucide-react'
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from './ui/select'
interface ExtractionFlowDesignerProps {
settings: Record<string, string>
openaiTextModels: string[]
openaiImageModels: string[]
googleTextModels: string[]
googleImageModels: string[]
onSelect: (key: string, value: string) => void
}
export function ExtractionFlowDesigner({
settings,
openaiTextModels,
openaiImageModels,
googleTextModels,
googleImageModels,
onSelect,
}: ExtractionFlowDesignerProps) {
const steps = [
{
id: 'analyze',
key: 'step_analyze_model',
title: 'Analyze Image',
description: 'Extracts visual features and descriptions from the input image.',
icon: <FileSearch className="w-5 h-5 text-cyan-600" />,
type: 'text',
},
{
id: 'texture',
key: 'step_texture_model',
title: 'Generate Texture',
description: 'Creates a texture sheet based on the analysis.',
icon: <ImageIcon className="w-5 h-5 text-purple-600" />,
type: 'image',
},
{
id: 'bbox',
key: 'step_bounding_box_model',
title: 'Detect Bounding Boxes',
description: 'Identifies regions for individual equipment pieces.',
icon: <BoxSelect className="w-5 h-5 text-amber-600" />,
type: 'text',
},
{
id: 'refine',
key: 'step_refine_model',
title: 'Refine Asset',
description: 'Polishes and isolates each equipment piece.',
icon: <Wand2 className="w-5 h-5 text-emerald-600" />,
type: 'image',
},
{
id: 'generation',
key: 'step_generation_model',
title: 'Character Generation',
description: 'Generates the final character using the equipment.',
icon: <User className="w-5 h-5 text-blue-600" />,
type: 'image',
},
]
return (
<div className="relative py-8">
{/* Vertical Line */}
<div className="absolute left-6 top-8 bottom-8 w-0.5 bg-gradient-to-b from-cyan-500/20 via-blue-500/20 to-purple-500/20 z-0" />
<div className="flex flex-col gap-6 relative z-10">
{steps.map((step, index) => {
const textModels =
step.type === 'text'
? { openai: openaiTextModels, google: googleTextModels }
: { openai: openaiImageModels, google: googleImageModels }
return (
<div key={step.id} className="group relative flex gap-6 items-start">
{/* Icon Bubble */}
<div className="w-12 h-12 rounded-xl bg-white border border-slate-200 shadow-sm flex items-center justify-center shrink-0 z-10 transition-transform group-hover:scale-110 group-hover:border-cyan-400">
{step.icon}
</div>
{/* Card */}
<div className="flex-1 bg-white rounded-xl border border-slate-200 p-4 shadow-sm transition-all hover:shadow-md hover:border-cyan-200">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h4 className="font-bold text-slate-800 flex items-center gap-2">
<span className="text-slate-400 font-mono text-xs">0{index + 1} //</span>
{step.title}
</h4>
<p className="text-sm text-slate-500 mt-1">{step.description}</p>
</div>
<div className="w-full md:w-64">
<ModelSelect
value={settings[step.key]}
openaiModels={textModels.openai}
googleModels={textModels.google}
onSelect={(val) => onSelect(step.key, val)}
/>
</div>
</div>
</div>
{/* Arrow Connection (except last) */}
{index < steps.length - 1 && (
<div className="absolute left-6 -bottom-6 transform -translate-x-1/2 text-slate-300">
<ArrowDown size={16} />
</div>
)}
</div>
)
})}
</div>
</div>
)
}
function ModelSelect({
value,
openaiModels,
googleModels,
onSelect,
}: {
value?: string
openaiModels: string[]
googleModels: string[]
onSelect: (val: string) => void
}) {
return (
<Select value={value} onValueChange={onSelect}>
<SelectTrigger className="w-full bg-slate-50 border-slate-200">
<SelectValue placeholder="Select Model" />
</SelectTrigger>
<SelectContent>
{openaiModels.length > 0 && (
<SelectGroup>
<SelectLabel className="text-xs font-mono uppercase tracking-wider text-slate-400">
OpenAI
</SelectLabel>
{openaiModels.map((m) => (
<SelectItem key={`openai:${m}`} value={`openai:${m}`}>
{m}
</SelectItem>
))}
</SelectGroup>
)}
{googleModels.length > 0 && (
<SelectGroup>
<SelectLabel className="text-xs font-mono uppercase tracking-wider text-slate-400">
Google
</SelectLabel>
{googleModels.map((m) => (
<SelectItem key={`google:${m}`} value={`google:${m}`}>
{m}
</SelectItem>
))}
</SelectGroup>
)}
</SelectContent>
</Select>
)
}
+53
View File
@@ -0,0 +1,53 @@
import { Hexagon } from 'lucide-react'
import { ReactNode } from 'react'
import { cn } from '../lib/utils'
interface PageHeaderProps {
title: ReactNode
subtitle?: ReactNode
actions?: ReactNode
children?: ReactNode
className?: string
sticky?: boolean
topOffset?: string
}
export function PageHeader({
title,
subtitle,
actions,
children,
className,
sticky = true,
topOffset = '4rem', // Default to 16 (4rem) below global header
}: PageHeaderProps) {
return (
<div
className={cn(
'w-full flex flex-col gap-4 pb-4 pt-2 transition-all duration-300',
sticky &&
'sticky z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60',
className,
)}
style={sticky ? { top: topOffset } : undefined}
>
<div className="flex justify-between items-end animate-fade-in-down">
<div>
{subtitle && (
<div className="flex items-center gap-2 text-cyan-600 mb-2 tracking-[0.3em] text-xs font-mono uppercase">
<Hexagon size={12} className="animate-spin-slow" />
{subtitle}
</div>
)}
<h1 className="text-3xl md:text-5xl font-display font-black uppercase text-slate-900 tracking-tighter">
<span className="text-transparent bg-clip-text bg-gradient-to-r from-cyan-600 to-blue-600">
{title}
</span>
</h1>
</div>
{actions && <div className="flex items-center gap-4">{actions}</div>}
</div>
{children && <div className="animate-fade-in-down delay-100">{children}</div>}
</div>
)
}
+10 -17
View File
@@ -1,9 +1,10 @@
import { InferResponseType } from 'hono/client'
import { Hexagon, Plus } from 'lucide-react'
import { Plus } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { client } from '../client'
import { CreateCharacterDialog } from '../components/CreateCharacterDialog'
import { PageHeader } from '../components/PageHeader'
import { VibeCard } from '../components/VibeCard'
import { Button } from '../components/ui/button'
@@ -60,25 +61,17 @@ export default function Characters() {
}
return (
<div className="w-full min-h-screen p-4 pt-2 flex flex-col font-sans text-slate-900 relative">
<div className="mb-8 animate-fade-in-down flex justify-between items-end">
<div>
<div className="flex items-center gap-2 text-cyan-600 mb-2 tracking-[0.3em] text-xs font-mono uppercase">
<Hexagon size={12} className="animate-spin-slow" />
System // Character_List
</div>
<h1 className="text-3xl md:text-5xl font-display font-black uppercase text-slate-900 tracking-tighter">
<span className="text-transparent bg-clip-text bg-gradient-to-r from-cyan-600 to-blue-600">
Characters
</span>
</h1>
</div>
<div className="flex items-center gap-4">
<div className="w-full h-full p-4 pt-2 flex flex-col font-sans text-slate-900 relative">
<PageHeader
title="Characters"
subtitle="System // Character_List"
actions={
<Button onClick={() => setCreateOpen(true)}>
<Plus className="mr-2 h-4 w-4" /> Create Character
</Button>
</div>
</div>
}
className="mb-8"
/>
{characters.length === 0 ? (
<div className="text-center p-12 bg-white clip-path-slant border border-slate-200">
+22 -27
View File
@@ -1,11 +1,12 @@
import { InferResponseType } from 'hono/client'
import { Hexagon, Plus, Trash2 } from 'lucide-react'
import { Plus, Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { toast } from 'sonner'
import { EQUIPMENT_CATEGORIES } from '../../lib/categories'
import { client } from '../client'
import { CreateOutfitDialog } from '../components/CreateOutfitDialog'
import { ExtractorDialog } from '../components/ExtractorDialog'
import { PageHeader } from '../components/PageHeader'
import { VibeCard } from '../components/VibeCard'
import { Badge } from '../components/ui/badge'
import { Button } from '../components/ui/button'
@@ -108,40 +109,35 @@ export default function Equipments() {
}
return (
<div className="w-full min-h-screen p-4 pt-2 flex flex-col font-sans text-slate-900 relative">
<div className="mb-8 animate-fade-in-down flex justify-between items-end">
<div>
<div className="flex items-center gap-2 text-cyan-600 mb-2 tracking-[0.3em] text-xs font-mono uppercase">
<Hexagon size={12} className="animate-spin-slow" />
<div className="w-full h-full p-4 pt-2 flex flex-col font-sans text-slate-900 relative">
<PageHeader
title={viewMode === 'equipments' ? 'Equipments' : 'Outfits'}
subtitle={
<>
System //{' '}
<span
className={cn(
'cursor-pointer',
'cursor-pointer hover:text-cyan-500 transition-colors',
viewMode === 'equipments' ? '' : 'text-muted-foreground/80',
)}
onClick={() => setViewMode('equipments')}
>
Equipment_List
</span>{' '}
<span>|</span>
<span>|</span>{' '}
<span
className={cn(
'cursor-pointer',
'cursor-pointer hover:text-cyan-500 transition-colors',
viewMode === 'outfits' ? '' : 'text-muted-foreground/80',
)}
onClick={() => setViewMode('outfits')}
>
Outfit_List
</span>
</div>
<h1 className="text-3xl md:text-5xl font-display font-black uppercase text-slate-900 tracking-tighter">
<span className="text-transparent bg-clip-text bg-gradient-to-r from-cyan-600 to-blue-600">
{viewMode === 'equipments' ? 'Equipments' : 'Outfits'}
</span>
</h1>
</div>
<div className="flex items-center gap-4">
{viewMode === 'equipments' ? (
</>
}
actions={
viewMode === 'equipments' ? (
<Button onClick={() => setExtractorOpen(true)}>
<Plus className="mr-2 h-4 w-4" /> Extract Equipment
</Button>
@@ -149,13 +145,12 @@ export default function Equipments() {
<Button onClick={() => setCreateOutfitOpen(true)}>
<Plus className="mr-2 h-4 w-4" /> Create Outfit
</Button>
)}
</div>
</div>
{/* Category Filter */}
{viewMode === 'equipments' && (
<div className="mb-8 animate-fade-in-down delay-100">
)
}
className="mb-8"
>
{/* Category Filter - Sticky inside PageHeader */}
{viewMode === 'equipments' && (
<ScrollArea className="w-full whitespace-nowrap">
<div className="flex w-max space-x-2 p-1">
{EQUIPMENT_CATEGORIES.map((cat) => {
@@ -179,8 +174,8 @@ export default function Equipments() {
</div>
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
)}
)}
</PageHeader>
{loading ? (
<div className="p-12 text-center text-slate-400 font-mono tracking-widest animate-pulse">
+147 -226
View File
@@ -1,20 +1,13 @@
import { useState, useEffect } from 'react'
import { client } from '@/ui/client'
import { ExtractionFlowDesigner } from '@/ui/components/ExtractionFlowDesigner'
import { PageHeader } from '@/ui/components/PageHeader'
import { Button } from '@/ui/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/ui/components/ui/card'
import { Input } from '@/ui/components/ui/input'
import { Label } from '@/ui/components/ui/label'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/ui/components/ui/card'
import { client } from '@/ui/client'
import { Loader2, Plus, Trash2 } from 'lucide-react'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
SelectGroup,
SelectLabel,
} from '@/ui/components/ui/select'
import { useToast } from '@/ui/hooks/use-toast'
import { Loader2, Plus, Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
export default function Settings() {
const { toast } = useToast()
@@ -121,178 +114,154 @@ export default function Settings() {
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin" />
<div className="flex h-full items-center justify-center p-12">
<Loader2 className="h-8 w-8 animate-spin text-slate-400" />
</div>
)
}
return (
<div className="space-y-6">
<h2 className="text-3xl font-bold tracking-tight">Settings</h2>
<div className="w-full h-full p-4 pt-2 flex flex-col font-sans text-slate-900 relative space-y-8">
<PageHeader title="Settings" subtitle="System // Configuration" />
{/* OpenAI Provider */}
<Card>
<CardHeader>
<CardTitle>OpenAI Provider</CardTitle>
<CardDescription>Configure OpenAI API settings and models.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="openai_api_key">API Key</Label>
<div className="flex gap-2">
<Input
id="openai_api_key"
type="password"
value={settings['openai_api_key'] || ''}
onChange={(e) => setSettings({ ...settings, openai_api_key: e.target.value })}
placeholder="sk-..."
/>
<Button
onClick={() => saveSetting('openai_api_key', settings['openai_api_key'] || '')}
disabled={saving['openai_api_key']}
>
{saving['openai_api_key'] ? <Loader2 className="animate-spin" /> : 'Save'}
</Button>
<div className="max-w-4xl mx-auto w-full grid gap-8">
{/* OpenAI Provider */}
<Card className="border-slate-200 shadow-sm">
<CardHeader>
<CardTitle>OpenAI Provider</CardTitle>
<CardDescription>Configure OpenAI API settings and models.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="openai_api_key">API Key</Label>
<div className="flex gap-2">
<Input
id="openai_api_key"
type="password"
value={settings['openai_api_key'] || ''}
onChange={(e) => setSettings({ ...settings, openai_api_key: e.target.value })}
placeholder="sk-..."
className="font-mono text-sm"
/>
<Button
onClick={() => saveSetting('openai_api_key', settings['openai_api_key'] || '')}
disabled={saving['openai_api_key']}
>
{saving['openai_api_key'] ? <Loader2 className="animate-spin" /> : 'Save'}
</Button>
</div>
</div>
</div>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="openai_base_url">Base URL (Optional)</Label>
<div className="flex gap-2">
<Input
id="openai_base_url"
value={settings['openai_base_url'] || ''}
onChange={(e) => setSettings({ ...settings, openai_base_url: e.target.value })}
placeholder="https://api.openai.com/v1"
/>
<Button
onClick={() => saveSetting('openai_base_url', settings['openai_base_url'] || '')}
disabled={saving['openai_base_url']}
>
{saving['openai_base_url'] ? <Loader2 className="animate-spin" /> : 'Save'}
</Button>
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="openai_base_url">Base URL (Optional)</Label>
<div className="flex gap-2">
<Input
id="openai_base_url"
value={settings['openai_base_url'] || ''}
onChange={(e) => setSettings({ ...settings, openai_base_url: e.target.value })}
placeholder="https://api.openai.com/v1"
className="font-mono text-sm"
/>
<Button
onClick={() => saveSetting('openai_base_url', settings['openai_base_url'] || '')}
disabled={saving['openai_base_url']}
>
{saving['openai_base_url'] ? <Loader2 className="animate-spin" /> : 'Save'}
</Button>
</div>
</div>
<ModelList
title="Text Models"
models={openaiTextModels}
onAdd={(m) =>
addModel(openaiTextModels, setOpenaiTextModels, 'openai_text_models', m)
}
onRemove={(m) =>
removeModel(openaiTextModels, setOpenaiTextModels, 'openai_text_models', m)
}
/>
<ModelList
title="Image Models"
models={openaiImageModels}
onAdd={(m) =>
addModel(openaiImageModels, setOpenaiImageModels, 'openai_image_models', m)
}
onRemove={(m) =>
removeModel(openaiImageModels, setOpenaiImageModels, 'openai_image_models', m)
}
/>
</CardContent>
</Card>
{/* Google Provider */}
<Card className="border-slate-200 shadow-sm">
<CardHeader>
<CardTitle>Google Provider</CardTitle>
<CardDescription>Configure Google Gemini API settings and models.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="google_api_key">API Key</Label>
<div className="flex gap-2">
<Input
id="google_api_key"
type="password"
value={settings['google_api_key'] || ''}
onChange={(e) => setSettings({ ...settings, google_api_key: e.target.value })}
placeholder="AIza..."
className="font-mono text-sm"
/>
<Button
onClick={() => saveSetting('google_api_key', settings['google_api_key'] || '')}
disabled={saving['google_api_key']}
>
{saving['google_api_key'] ? <Loader2 className="animate-spin" /> : 'Save'}
</Button>
</div>
</div>
<ModelList
title="Text Models"
models={googleTextModels}
onAdd={(m) =>
addModel(googleTextModels, setGoogleTextModels, 'google_text_models', m)
}
onRemove={(m) =>
removeModel(googleTextModels, setGoogleTextModels, 'google_text_models', m)
}
/>
<ModelList
title="Image Models"
models={googleImageModels}
onAdd={(m) =>
addModel(googleImageModels, setGoogleImageModels, 'google_image_models', m)
}
onRemove={(m) =>
removeModel(googleImageModels, setGoogleImageModels, 'google_image_models', m)
}
/>
</CardContent>
</Card>
{/* Extraction Flow Defaults */}
<div>
<div className="mb-4 ml-1">
<h3 className="text-lg font-semibold text-slate-900">Extraction Flow Defaults</h3>
<p className="text-sm text-slate-500">
Configure the AI models used for each step of the asset generation pipeline.
</p>
</div>
<ModelList
title="Text Models"
models={openaiTextModels}
onAdd={(m) => addModel(openaiTextModels, setOpenaiTextModels, 'openai_text_models', m)}
onRemove={(m) =>
removeModel(openaiTextModels, setOpenaiTextModels, 'openai_text_models', m)
}
<ExtractionFlowDesigner
settings={settings}
openaiTextModels={openaiTextModels}
openaiImageModels={openaiImageModels}
googleTextModels={googleTextModels}
googleImageModels={googleImageModels}
onSelect={(key, val) => saveSetting(key, val)}
/>
<ModelList
title="Image Models"
models={openaiImageModels}
onAdd={(m) =>
addModel(openaiImageModels, setOpenaiImageModels, 'openai_image_models', m)
}
onRemove={(m) =>
removeModel(openaiImageModels, setOpenaiImageModels, 'openai_image_models', m)
}
/>
</CardContent>
</Card>
{/* Google Provider */}
<Card>
<CardHeader>
<CardTitle>Google Provider</CardTitle>
<CardDescription>Configure Google Gemini API settings and models.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid w-full items-center gap-1.5">
<Label htmlFor="google_api_key">API Key</Label>
<div className="flex gap-2">
<Input
id="google_api_key"
type="password"
value={settings['google_api_key'] || ''}
onChange={(e) => setSettings({ ...settings, google_api_key: e.target.value })}
placeholder="AIza..."
/>
<Button
onClick={() => saveSetting('google_api_key', settings['google_api_key'] || '')}
disabled={saving['google_api_key']}
>
{saving['google_api_key'] ? <Loader2 className="animate-spin" /> : 'Save'}
</Button>
</div>
</div>
<ModelList
title="Text Models"
models={googleTextModels}
onAdd={(m) => addModel(googleTextModels, setGoogleTextModels, 'google_text_models', m)}
onRemove={(m) =>
removeModel(googleTextModels, setGoogleTextModels, 'google_text_models', m)
}
/>
<ModelList
title="Image Models"
models={googleImageModels}
onAdd={(m) =>
addModel(googleImageModels, setGoogleImageModels, 'google_image_models', m)
}
onRemove={(m) =>
removeModel(googleImageModels, setGoogleImageModels, 'google_image_models', m)
}
/>
</CardContent>
</Card>
{/* Extraction Flow Defaults */}
<Card>
<CardHeader>
<CardTitle>Extraction Flow Defaults</CardTitle>
<CardDescription>
Select default models for each step of the extraction process.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Step 1: Analyze Image (Text) */}
<StepSelector
label="Step 1: Analyze Image (Text)"
value={settings['step_analyze_model']}
openaiModels={openaiTextModels}
googleModels={googleTextModels}
onSelect={(val) => saveSetting('step_analyze_model', val)}
/>
{/* Step 2: Texture Sheet (Image) */}
<StepSelector
label="Step 2: Generate Texture Sheet (Image)"
value={settings['step_texture_model']}
openaiModels={openaiImageModels}
googleModels={googleImageModels}
onSelect={(val) => saveSetting('step_texture_model', val)}
/>
{/* Step 3: Bounding Box (Text) */}
<StepSelector
label="Step 3: Detect Bounding Boxes (Text)"
value={settings['step_bounding_box_model']}
openaiModels={openaiTextModels}
googleModels={googleTextModels}
onSelect={(val) => saveSetting('step_bounding_box_model', val)}
/>
{/* Step 4: Refine Asset (Image) */}
<StepSelector
label="Step 4: Refine Asset (Image)"
value={settings['step_refine_model']}
openaiModels={openaiImageModels}
googleModels={googleImageModels}
onSelect={(val) => saveSetting('step_refine_model', val)}
/>
{/* Step: Generation (Image) */}
<StepSelector
label="Step: Character Generation (Image)"
value={settings['step_generation_model']}
openaiModels={openaiImageModels}
googleModels={googleImageModels}
onSelect={(val) => saveSetting('step_generation_model', val)}
/>
</CardContent>
</Card>
</div>
</div>
</div>
)
}
@@ -318,7 +287,7 @@ function ModelList({
value={newModel}
onChange={(e) => setNewModel(e.target.value)}
placeholder="Add model (e.g., gpt-4o)"
className="max-w-xs"
className="max-w-xs font-mono text-sm"
/>
<Button
variant="outline"
@@ -336,69 +305,21 @@ function ModelList({
{models.map((model) => (
<div
key={model}
className="flex items-center gap-1 rounded-md border bg-secondary px-2 py-1 text-sm"
className="flex items-center gap-1 rounded-md border border-slate-200 bg-slate-50 px-2 py-1 text-sm font-mono text-slate-700"
>
<span>{model}</span>
<button
onClick={() => onRemove(model)}
className="text-muted-foreground hover:text-foreground"
className="text-slate-400 hover:text-red-500 transition-colors"
>
<Trash2 className="h-3 w-3" />
</button>
</div>
))}
{models.length === 0 && (
<span className="text-sm text-muted-foreground">No models configured.</span>
<span className="text-sm text-slate-400 italic">No models configured.</span>
)}
</div>
</div>
)
}
function StepSelector({
label,
value,
openaiModels,
googleModels,
onSelect,
}: {
label: string
value?: string
openaiModels: string[]
googleModels: string[]
onSelect: (val: string) => void
}) {
return (
<div className="grid w-full items-center gap-1.5">
<Label>{label}</Label>
<Select value={value} onValueChange={onSelect}>
<SelectTrigger>
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
{openaiModels.length > 0 && (
<SelectGroup>
<SelectLabel>OpenAI</SelectLabel>
{openaiModels.map((m) => (
<SelectItem key={`openai:${m}`} value={`openai:${m}`}>
{m}
</SelectItem>
))}
</SelectGroup>
)}
{googleModels.length > 0 && (
<SelectGroup>
<SelectLabel>Google</SelectLabel>
{googleModels.map((m) => (
<SelectItem key={`google:${m}`} value={`google:${m}`}>
{m}
</SelectItem>
))}
</SelectGroup>
)}
</SelectContent>
</Select>
</div>
)
}