refactor(ui): reorganize profile create dialog with preset categories

- Split presets into recommended (OpenRouter) + alternative (GLM/GLMT/Kimi)
- Show alternative presets only when Custom is selected
- Replace PresetCard with CompactPresetCard (horizontal layout)
- Make baseUrl always editable (pre-filled from preset but customizable)
- Apply model selection to all 4 model tiers (opus/sonnet/haiku)
- Only show URL path warnings for truly custom URLs, not preset URLs
- Use accent tokens for consistent theming
This commit is contained in:
kaitranntt
2025-12-20 21:09:01 -05:00
parent b9f6823fc9
commit 96310dd1ac
@@ -27,7 +27,11 @@ import { useOpenRouterCatalog } from '@/hooks/use-openrouter-models';
import { Loader2, Plus, AlertTriangle, Info, Eye, EyeOff, Settings2, Sparkles } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { PROVIDER_PRESETS, type ProviderPreset } from '@/lib/provider-presets';
import {
PROVIDER_PRESETS,
getPresetsByCategory,
type ProviderPreset,
} from '@/lib/provider-presets';
import {
searchModels,
formatPricingPair,
@@ -153,15 +157,21 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
}
};
// Handle model selection from picker
// Handle model selection from picker - applies to all 4 model tiers
const handleModelSelect = (model: CategorizedModel) => {
setValue('model', model.id);
setValue('opusModel', model.id);
setValue('sonnetModel', model.id);
setValue('haikuModel', model.id);
setModelSearch(model.name);
};
// Check for common URL mistakes
// Check for common URL mistakes - only for truly custom URLs
// Presets (OpenRouter, GLM, GLMT, Kimi) have vetted URLs that may require full paths
useEffect(() => {
if (baseUrlValue && selectedPreset === 'custom') {
// Only warn for custom URLs, not preset-selected ones
const isCustomUrl = selectedPreset === 'custom';
if (baseUrlValue && isCustomUrl) {
const lowerUrl = baseUrlValue.toLowerCase();
for (const path of PROBLEMATIC_PATHS) {
if (lowerUrl.endsWith(path)) {
@@ -177,10 +187,9 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
}, [baseUrlValue, selectedPreset]);
const onSubmit = async (data: FormData) => {
const preset = currentPreset;
// Use user-provided baseUrl (allows customization of preset URLs)
const finalData = {
...data,
baseUrl: preset ? preset.baseUrl : data.baseUrl,
};
try {
await createMutation.mutateAsync(finalData);
@@ -212,33 +221,57 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col overflow-hidden">
{/* Provider Preset Cards */}
<div className="px-6 py-4 border-b bg-muted/30">
<Label className="text-xs text-muted-foreground mb-2 block">Choose a provider</Label>
<div className="grid grid-cols-5 gap-2">
{PROVIDER_PRESETS.map((preset) => (
<PresetCard
key={preset.id}
preset={preset}
isSelected={selectedPreset === preset.id}
onClick={() => handlePresetSelect(preset.id)}
/>
))}
{/* Custom option */}
<button
type="button"
onClick={() => handlePresetSelect('custom')}
className={cn(
'flex flex-col items-center gap-1 p-3 rounded-lg border-2 transition-all text-center',
selectedPreset === 'custom'
? 'border-primary bg-primary/5'
: 'border-muted hover:border-muted-foreground/30'
)}
>
<Settings2 className="w-5 h-5 text-muted-foreground" />
<span className="text-xs font-medium">Custom</span>
</button>
{/* Provider Preset Cards - Compact horizontal layout */}
<div className="px-6 py-3 border-b bg-muted/30 space-y-2">
{/* Main Options: OpenRouter + Custom */}
<div>
<Label className="text-xs text-muted-foreground mb-1.5 block">Provider</Label>
<div className="flex gap-2">
{getPresetsByCategory('recommended').map((preset) => (
<CompactPresetCard
key={preset.id}
preset={preset}
isSelected={selectedPreset === preset.id}
onClick={() => handlePresetSelect(preset.id)}
/>
))}
{/* Custom option */}
<button
type="button"
onClick={() => handlePresetSelect('custom')}
className={cn(
'flex items-center gap-1.5 px-3 py-1.5 rounded-md border transition-all text-sm',
selectedPreset === 'custom' ||
getPresetsByCategory('alternative').some((p) => p.id === selectedPreset)
? 'border-primary bg-primary/5 font-medium'
: 'border-muted hover:border-muted-foreground/30'
)}
>
<Settings2 className="w-3.5 h-3.5 text-muted-foreground" />
<span>Custom</span>
</button>
</div>
</div>
{/* Show alternative presets when Custom is selected or an alternative is selected */}
{(selectedPreset === 'custom' ||
getPresetsByCategory('alternative').some((p) => p.id === selectedPreset)) && (
<div className="pt-2 border-t border-dashed">
<Label className="text-xs text-muted-foreground mb-1.5 block">
Quick Templates
</Label>
<div className="flex gap-2 flex-wrap">
{getPresetsByCategory('alternative').map((preset) => (
<CompactPresetCard
key={preset.id}
preset={preset}
isSelected={selectedPreset === preset.id}
onClick={() => handlePresetSelect(preset.id)}
/>
))}
</div>
</div>
)}
</div>
<Tabs
@@ -286,45 +319,33 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
)}
</div>
{/* Base URL - only show for custom */}
{selectedPreset === 'custom' ? (
<div className="space-y-1.5">
<Label htmlFor="baseUrl">
API Base URL <span className="text-destructive">*</span>
</Label>
<Input
id="baseUrl"
{...register('baseUrl')}
placeholder="https://api.example.com/v1"
/>
{errors.baseUrl ? (
<p className="text-xs text-destructive">{errors.baseUrl.message}</p>
) : urlWarning ? (
<div className="flex items-start gap-2 text-xs text-yellow-600 bg-yellow-50 dark:bg-yellow-900/20 p-2 rounded">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<span>{urlWarning}</span>
</div>
) : (
<p className="text-xs text-muted-foreground">
The endpoint that accepts OpenAI-compatible and Anthropic requests
</p>
)}
</div>
) : (
currentPreset && (
<div className="flex items-center gap-2 p-3 bg-muted/50 rounded-md border">
{currentPreset.icon ? (
<img src={currentPreset.icon} alt="" className="w-5 h-5" />
) : (
<Settings2 className="w-5 h-5 text-muted-foreground" />
)}
<div className="flex-1">
<p className="text-sm font-medium">{currentPreset.name} API</p>
<p className="text-xs text-muted-foreground">{currentPreset.baseUrl}</p>
</div>
{/* Base URL - always editable, pre-filled from preset */}
<div className="space-y-1.5">
<Label htmlFor="baseUrl">
API Base URL <span className="text-destructive">*</span>
</Label>
<Input
id="baseUrl"
{...register('baseUrl')}
placeholder="https://api.example.com/v1"
/>
{errors.baseUrl ? (
<p className="text-xs text-destructive">{errors.baseUrl.message}</p>
) : urlWarning ? (
<div className="flex items-start gap-2 text-xs text-yellow-600 bg-yellow-50 dark:bg-yellow-900/20 p-2 rounded">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<span>{urlWarning}</span>
</div>
)
)}
) : currentPreset ? (
<p className="text-xs text-muted-foreground">
Pre-filled from {currentPreset.name}. You can customize if needed.
</p>
) : (
<p className="text-xs text-muted-foreground">
The endpoint that accepts OpenAI-compatible and Anthropic requests
</p>
)}
</div>
{/* API Key */}
<div className="space-y-1.5">
@@ -392,7 +413,7 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
<div className="p-1">
{!modelSearch && (
<div className="flex items-center gap-1.5 px-2 py-1 text-xs text-muted-foreground">
<Sparkles className="w-3 h-3 text-orange-500" />
<Sparkles className="w-3 h-3 text-accent" />
<span>Newest Models</span>
</div>
)}
@@ -506,8 +527,8 @@ export function ProfileCreateDialog({ open, onOpenChange, onSuccess }: ProfileCr
);
}
/** Preset card component */
function PresetCard({
/** Compact preset card component - horizontal layout */
function CompactPresetCard({
preset,
isSelected,
onClick,
@@ -521,24 +542,24 @@ function PresetCard({
type="button"
onClick={onClick}
className={cn(
'flex flex-col items-center gap-1 p-3 rounded-lg border-2 transition-all text-center',
'flex items-center gap-1.5 px-3 py-1.5 rounded-md border transition-all text-sm',
isSelected
? preset.featured
? 'border-orange-500 bg-orange-50 dark:bg-orange-950/20'
: 'border-primary bg-primary/5'
? 'border-accent bg-accent/10 dark:bg-accent/20 font-medium'
: 'border-primary bg-primary/5 font-medium'
: 'border-muted hover:border-muted-foreground/30'
)}
>
{preset.icon ? (
<img src={preset.icon} alt="" className="w-5 h-5" />
<img src={preset.icon} alt="" className="w-3.5 h-3.5" />
) : (
<div className="w-5 h-5 rounded-full bg-muted flex items-center justify-center text-[10px] font-bold">
<div className="w-3.5 h-3.5 rounded-full bg-muted flex items-center justify-center text-[8px] font-bold">
{preset.name.charAt(0)}
</div>
)}
<span className="text-xs font-medium">{preset.name}</span>
<span>{preset.name}</span>
{preset.badge && (
<Badge variant="secondary" className="text-[9px] px-1 py-0">
<Badge variant="secondary" className="text-[9px] px-1 py-0 ml-0.5">
{preset.badge}
</Badge>
)}
@@ -565,7 +586,7 @@ function ModelSearchItem({
<span className="flex-1 truncate">{model.name}</span>
<span className="text-muted-foreground ml-2 flex items-center gap-2 text-xs">
{showAge && model.created && (
<Badge variant="outline" className="text-[10px] text-orange-600">
<Badge variant="outline" className="text-[10px] text-accent">
{formatModelAge(model.created)}
</Badge>
)}