mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 20:17:45 +00:00
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:
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user