mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 18:15:48 +00:00
fix(ui): show OpenRouterQuickStart by default on API page
- remove auto-select of first profile behavior - show QuickStart panel when no profile selected - users now click profile to see details - ensures OpenRouter promo visible for existing users
This commit is contained in:
1 parent
adcc3235f0
commit
05380e21b4
1 file changed
+140
-187
+140
-187
@@ -7,22 +7,21 @@ import { useState, useMemo } from 'react';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Separator } from '@/components/ui/separator';
|
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Search,
|
Search,
|
||||||
Settings2,
|
|
||||||
Trash2,
|
Trash2,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Server,
|
Server,
|
||||||
ExternalLink,
|
|
||||||
FileJson,
|
FileJson,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { ProfileEditor } from '@/components/profile-editor';
|
import { ProfileEditor } from '@/components/profile-editor';
|
||||||
import { ProfileCreateDialog } from '@/components/profiles/profile-create-dialog';
|
import { ProfileCreateDialog } from '@/components/profiles/profile-create-dialog';
|
||||||
|
import { OpenRouterBanner } from '@/components/profiles/openrouter-banner';
|
||||||
|
import { OpenRouterQuickStart } from '@/components/profiles/openrouter-quick-start';
|
||||||
|
import { OpenRouterPromoCard } from '@/components/profiles/openrouter-promo-card';
|
||||||
import { useProfiles, useDeleteProfile } from '@/hooks/use-profiles';
|
import { useProfiles, useDeleteProfile } from '@/hooks/use-profiles';
|
||||||
import { useOpenRouterModels } from '@/hooks/use-openrouter-models';
|
import { useOpenRouterModels } from '@/hooks/use-openrouter-models';
|
||||||
import { ConfirmDialog } from '@/components/shared/confirm-dialog';
|
import { ConfirmDialog } from '@/components/shared/confirm-dialog';
|
||||||
@@ -36,6 +35,7 @@ export function ApiPage() {
|
|||||||
const [selectedProfile, setSelectedProfile] = useState<string | null>(null);
|
const [selectedProfile, setSelectedProfile] = useState<string | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [isCreateDialogOpen, setCreateDialogOpen] = useState(false);
|
const [isCreateDialogOpen, setCreateDialogOpen] = useState(false);
|
||||||
|
const [createMode, setCreateMode] = useState<'normal' | 'openrouter'>('normal');
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||||
|
|
||||||
// Prefetch OpenRouter models when page loads (lazy - won't block render)
|
// Prefetch OpenRouter models when page loads (lazy - won't block render)
|
||||||
@@ -50,13 +50,11 @@ export function ApiPage() {
|
|||||||
[profiles, searchQuery]
|
[profiles, searchQuery]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Compute effective selected profile (auto-select first if none selected)
|
// selectedProfile is null by default - user must click to select
|
||||||
const effectiveSelectedProfile = useMemo(() => {
|
// This allows OpenRouterQuickStart to show as the default right panel
|
||||||
if (selectedProfile && profiles.some((p) => p.name === selectedProfile)) {
|
const selectedProfileData = selectedProfile
|
||||||
return selectedProfile;
|
? profiles.find((p) => p.name === selectedProfile)
|
||||||
}
|
: null;
|
||||||
return profiles.length > 0 ? profiles[0].name : null;
|
|
||||||
}, [selectedProfile, profiles]);
|
|
||||||
|
|
||||||
// Handle profile deletion
|
// Handle profile deletion
|
||||||
const handleDelete = (name: string) => {
|
const handleDelete = (name: string) => {
|
||||||
@@ -76,137 +74,154 @@ export function ApiPage() {
|
|||||||
setSelectedProfile(name);
|
setSelectedProfile(name);
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectedProfileData = profiles.find((p) => p.name === effectiveSelectedProfile);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-[calc(100vh-100px)] flex">
|
<div className="h-[calc(100vh-100px)] flex flex-col">
|
||||||
{/* Left Panel - Profiles List */}
|
{/* OpenRouter Announcement Banner */}
|
||||||
<div className="w-80 border-r flex flex-col bg-muted/30">
|
<OpenRouterBanner onCreateClick={() => setCreateDialogOpen(true)} />
|
||||||
{/* Header */}
|
|
||||||
<div className="p-4 border-b bg-background">
|
|
||||||
<div className="flex items-center justify-between mb-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Server className="w-5 h-5 text-primary" />
|
|
||||||
<h1 className="font-semibold">API Profiles</h1>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setCreateDialogOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
|
||||||
New
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search */}
|
{/* Main Content */}
|
||||||
<div className="relative">
|
<div className="flex-1 flex min-h-0">
|
||||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
{/* Left Panel - Profiles List */}
|
||||||
<Input
|
<div className="w-80 border-r flex flex-col bg-muted/30">
|
||||||
placeholder="Search profiles..."
|
{/* Header */}
|
||||||
className="pl-8 h-9"
|
<div className="p-4 border-b bg-background">
|
||||||
value={searchQuery}
|
<div className="flex items-center justify-between mb-3">
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
<div className="flex items-center gap-2">
|
||||||
/>
|
<Server className="w-5 h-5 text-primary" />
|
||||||
</div>
|
<h1 className="font-semibold">API Profiles</h1>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Profile List */}
|
|
||||||
<ScrollArea className="flex-1">
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="p-4 text-sm text-muted-foreground">Loading profiles...</div>
|
|
||||||
) : isError ? (
|
|
||||||
<div className="p-4 text-center">
|
|
||||||
<div className="space-y-3 py-8">
|
|
||||||
<AlertCircle className="w-12 h-12 mx-auto text-destructive/50" />
|
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium">Failed to load profiles</p>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
|
||||||
Unable to fetch API profiles. Please try again.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button size="sm" variant="outline" onClick={() => refetch()}>
|
|
||||||
<RefreshCw className="w-4 h-4 mr-1" />
|
|
||||||
Retry
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setCreateDialogOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
|
New
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : filteredProfiles.length === 0 ? (
|
|
||||||
<div className="p-4 text-center">
|
{/* Search */}
|
||||||
{profiles.length === 0 ? (
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search profiles..."
|
||||||
|
className="pl-8 h-9"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Profile List */}
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="p-4 text-sm text-muted-foreground">Loading profiles...</div>
|
||||||
|
) : isError ? (
|
||||||
|
<div className="p-4 text-center">
|
||||||
<div className="space-y-3 py-8">
|
<div className="space-y-3 py-8">
|
||||||
<FileJson className="w-12 h-12 mx-auto text-muted-foreground/50" />
|
<AlertCircle className="w-12 h-12 mx-auto text-destructive/50" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">No API profiles yet</p>
|
<p className="text-sm font-medium">Failed to load profiles</p>
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
Create your first profile to connect to custom API endpoints
|
Unable to fetch API profiles. Please try again.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button size="sm" variant="outline" onClick={() => refetch()}>
|
||||||
size="sm"
|
<RefreshCw className="w-4 h-4 mr-1" />
|
||||||
variant="outline"
|
Retry
|
||||||
onClick={() => {
|
|
||||||
setCreateDialogOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
|
||||||
Create Profile
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<p className="text-sm text-muted-foreground py-4">
|
) : filteredProfiles.length === 0 ? (
|
||||||
No profiles match "{searchQuery}"
|
<div className="p-4 text-center">
|
||||||
</p>
|
{profiles.length === 0 ? (
|
||||||
)}
|
<div className="space-y-3 py-8">
|
||||||
</div>
|
<FileJson className="w-12 h-12 mx-auto text-muted-foreground/50" />
|
||||||
) : (
|
<div>
|
||||||
<div className="p-2 space-y-1">
|
<p className="text-sm font-medium">No API profiles yet</p>
|
||||||
{filteredProfiles.map((profile) => (
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
<ProfileListItem
|
Create your first profile to connect to custom API endpoints
|
||||||
key={profile.name}
|
</p>
|
||||||
profile={profile}
|
</div>
|
||||||
isSelected={effectiveSelectedProfile === profile.name}
|
<Button
|
||||||
onSelect={() => {
|
size="sm"
|
||||||
setSelectedProfile(profile.name);
|
variant="outline"
|
||||||
}}
|
onClick={() => {
|
||||||
onDelete={() => setDeleteConfirm(profile.name)}
|
setCreateDialogOpen(true);
|
||||||
/>
|
}}
|
||||||
))}
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-1" />
|
||||||
|
Create Profile
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground py-4">
|
||||||
|
No profiles match "{searchQuery}"
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-2 space-y-1">
|
||||||
|
{filteredProfiles.map((profile) => (
|
||||||
|
<ProfileListItem
|
||||||
|
key={profile.name}
|
||||||
|
profile={profile}
|
||||||
|
isSelected={selectedProfile === profile.name}
|
||||||
|
onSelect={() => {
|
||||||
|
setSelectedProfile(profile.name);
|
||||||
|
}}
|
||||||
|
onDelete={() => setDeleteConfirm(profile.name)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{/* Footer Stats */}
|
||||||
|
{profiles.length > 0 && (
|
||||||
|
<div className="p-3 border-t bg-background text-xs text-muted-foreground">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>
|
||||||
|
{profiles.length} profile{profiles.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<CheckCircle2 className="w-3 h-3 text-green-600" />
|
||||||
|
{profiles.filter((p) => p.configured).length} configured
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</ScrollArea>
|
|
||||||
|
|
||||||
{/* Footer Stats */}
|
{/* OpenRouter Promo - always visible */}
|
||||||
{profiles.length > 0 && (
|
<OpenRouterPromoCard
|
||||||
<div className="p-3 border-t bg-background text-xs text-muted-foreground">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span>
|
|
||||||
{profiles.length} profile{profiles.length !== 1 ? 's' : ''}
|
|
||||||
</span>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
<CheckCircle2 className="w-3 h-3 text-green-600" />
|
|
||||||
{profiles.filter((p) => p.configured).length} configured
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right Panel - Editor */}
|
|
||||||
<div className="flex-1 flex flex-col min-w-0">
|
|
||||||
{selectedProfileData ? (
|
|
||||||
<ProfileEditor
|
|
||||||
profileName={selectedProfileData.name}
|
|
||||||
onDelete={() => setDeleteConfirm(selectedProfileData.name)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<EmptyState
|
|
||||||
onCreateClick={() => {
|
onCreateClick={() => {
|
||||||
|
setCreateMode('openrouter');
|
||||||
setCreateDialogOpen(true);
|
setCreateDialogOpen(true);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
|
{/* Right Panel - Editor or QuickStart */}
|
||||||
|
<div className="flex-1 flex flex-col min-w-0">
|
||||||
|
{selectedProfileData ? (
|
||||||
|
<ProfileEditor
|
||||||
|
profileName={selectedProfileData.name}
|
||||||
|
onDelete={() => setDeleteConfirm(selectedProfileData.name)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<OpenRouterQuickStart
|
||||||
|
onOpenRouterClick={() => {
|
||||||
|
setCreateMode('openrouter');
|
||||||
|
setCreateDialogOpen(true);
|
||||||
|
}}
|
||||||
|
onCustomClick={() => {
|
||||||
|
setCreateMode('normal');
|
||||||
|
setCreateDialogOpen(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Create Dialog */}
|
{/* Create Dialog */}
|
||||||
@@ -214,6 +229,7 @@ export function ApiPage() {
|
|||||||
open={isCreateDialogOpen}
|
open={isCreateDialogOpen}
|
||||||
onOpenChange={setCreateDialogOpen}
|
onOpenChange={setCreateDialogOpen}
|
||||||
onSuccess={handleCreateSuccess}
|
onSuccess={handleCreateSuccess}
|
||||||
|
initialMode={createMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Delete Confirmation */}
|
{/* Delete Confirmation */}
|
||||||
@@ -289,66 +305,3 @@ function ProfileListItem({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Empty state when no profile is selected */
|
|
||||||
function EmptyState({ onCreateClick }: { onCreateClick: () => void }) {
|
|
||||||
return (
|
|
||||||
<div className="flex-1 flex items-center justify-center bg-muted/20">
|
|
||||||
<div className="text-center max-w-md px-8">
|
|
||||||
<Settings2 className="w-16 h-16 mx-auto text-muted-foreground/30 mb-6" />
|
|
||||||
<h2 className="text-xl font-semibold mb-2">API Profile Manager</h2>
|
|
||||||
<p className="text-muted-foreground mb-6">
|
|
||||||
Configure custom API endpoints for Claude CLI. Connect to proxy services like copilot-api,
|
|
||||||
OpenRouter, or your own API backend.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
<Button onClick={onCreateClick} className="w-full">
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
Create Your First Profile
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Separator className="my-4" />
|
|
||||||
|
|
||||||
<div className="text-left space-y-2">
|
|
||||||
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
|
||||||
What you can configure:
|
|
||||||
</p>
|
|
||||||
<ul className="text-sm text-muted-foreground space-y-1.5">
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<Badge variant="outline" className="text-xs shrink-0 mt-0.5">
|
|
||||||
URL
|
|
||||||
</Badge>
|
|
||||||
<span>Custom API base URL endpoint</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<Badge variant="outline" className="text-xs shrink-0 mt-0.5">
|
|
||||||
Auth
|
|
||||||
</Badge>
|
|
||||||
<span>API key or authentication token</span>
|
|
||||||
</li>
|
|
||||||
<li className="flex items-start gap-2">
|
|
||||||
<Badge variant="outline" className="text-xs shrink-0 mt-0.5">
|
|
||||||
Models
|
|
||||||
</Badge>
|
|
||||||
<span>Model mapping for Opus/Sonnet/Haiku</span>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pt-4">
|
|
||||||
<a
|
|
||||||
href="https://github.com/kaitranntt/ccs#api-profiles"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="inline-flex items-center text-xs text-primary hover:underline"
|
|
||||||
>
|
|
||||||
Learn more about API profiles
|
|
||||||
<ExternalLink className="w-3 h-3 ml-1" />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Reference in new issue
Block a user