fix(ui): improve table column widths and spacing

This commit is contained in:
kaitranntt
2025-12-07 20:47:51 -05:00
parent db68cf06af
commit 9b4a5d80c5
5 changed files with 327 additions and 19 deletions
+18 -7
View File
@@ -104,13 +104,24 @@ export function AccountsTable({ data, defaultAccount }: AccountsTableProps) {
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
{headerGroup.headers.map((header) => {
const widthClass =
{
name: 'w-[200px]',
type: 'w-[100px]',
created: 'w-[150px]',
last_used: 'w-[150px]',
actions: 'w-[120px]',
}[header.id] || 'w-auto';
return (
<TableHead key={header.id} className={widthClass}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
+39 -10
View File
@@ -17,24 +17,27 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { MoreHorizontal, Trash2, Edit } from 'lucide-react';
import { MoreHorizontal, Trash2, Edit, Settings } from 'lucide-react';
import { useDeleteProfile } from '@/hooks/use-profiles';
import type { Profile } from '@/lib/api-client';
interface ProfilesTableProps {
data: Profile[];
onEdit?: (profile: Profile) => void;
onEditSettings?: (profile: Profile) => void;
}
export function ProfilesTable({ data, onEdit }: ProfilesTableProps) {
export function ProfilesTable({ data, onEdit, onEditSettings }: ProfilesTableProps) {
const deleteMutation = useDeleteProfile();
const columns: ColumnDef<Profile>[] = [
{
accessorKey: 'name',
header: 'Name',
size: 200,
},
{
accessorKey: 'settingsPath',
@@ -43,6 +46,7 @@ export function ProfilesTable({ data, onEdit }: ProfilesTableProps) {
{
accessorKey: 'configured',
header: 'Status',
size: 100,
cell: ({ row }) => (
<span className={row.original.configured ? 'text-green-600' : 'text-yellow-600'}>
{row.original.configured ? '[OK]' : '[!]'}
@@ -52,6 +56,7 @@ export function ProfilesTable({ data, onEdit }: ProfilesTableProps) {
{
id: 'actions',
header: 'Actions',
size: 100,
cell: ({ row }) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -63,9 +68,16 @@ export function ProfilesTable({ data, onEdit }: ProfilesTableProps) {
{onEdit && (
<DropdownMenuItem onClick={() => onEdit(row.original)}>
<Edit className="w-4 h-4 mr-2" />
Edit
Edit Profile
</DropdownMenuItem>
)}
{onEditSettings && (
<DropdownMenuItem onClick={() => onEditSettings(row.original)}>
<Settings className="w-4 h-4 mr-2" />
Edit Settings
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-red-600"
onClick={() => deleteMutation.mutate(row.original.name)}
@@ -100,13 +112,30 @@ export function ProfilesTable({ data, onEdit }: ProfilesTableProps) {
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
))}
{headerGroup.headers.map((header) => {
const isAction = header.id === 'actions';
const isStatus = header.id === 'configured';
const isName = header.id === 'name';
return (
<TableHead
key={header.id}
className={
isAction
? 'w-[50px]'
: isStatus
? 'w-[100px]'
: isName
? 'w-[200px]'
: 'w-auto'
}
>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
+246
View File
@@ -0,0 +1,246 @@
/**
* Settings Dialog Component
* Reusable dialog for editing profile environment variables
* Features: masked inputs for sensitive keys, conflict detection, save/cancel
*/
import { useState, useMemo, useCallback } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { MaskedInput } from '@/components/ui/masked-input';
import { ConfirmDialog } from '@/components/confirm-dialog';
import { Save, X, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
interface Settings {
env?: Record<string, string>;
}
interface SettingsResponse {
profile: string;
settings: Settings;
mtime: number;
path: string;
}
interface SettingsDialogProps {
open: boolean;
onClose: () => void;
profileName: string | null;
}
/**
* Inner component that manages local edits state
* Gets unmounted/remounted via key prop when dialog closes/opens
*/
function SettingsDialogContent({
profileName,
onClose,
}: {
profileName: string;
onClose: () => void;
}) {
const [localEdits, setLocalEdits] = useState<Record<string, string>>({});
const [conflictDialog, setConflictDialog] = useState(false);
const queryClient = useQueryClient();
// Fetch settings for selected profile
const { data, isLoading, refetch } = useQuery<SettingsResponse>({
queryKey: ['settings', profileName],
queryFn: () => fetch(`/api/settings/${profileName}/raw`).then((r) => r.json()),
});
// Derive current settings by merging original data with local edits
const currentSettings = useMemo((): Settings | undefined => {
const settings = data?.settings;
if (!settings) return undefined;
return {
...settings,
env: {
...settings.env,
...localEdits,
},
};
}, [data?.settings, localEdits]);
// Save mutation
const saveMutation = useMutation({
mutationFn: async () => {
const settingsToSave: Settings = {
...data?.settings,
env: {
...data?.settings?.env,
...localEdits,
},
};
const res = await fetch(`/api/settings/${profileName}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
settings: settingsToSave,
expectedMtime: data?.mtime,
}),
});
if (res.status === 409) {
throw new Error('CONFLICT');
}
if (!res.ok) {
throw new Error('Failed to save');
}
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['settings', profileName] });
queryClient.invalidateQueries({ queryKey: ['profiles'] });
toast.success('Settings saved');
onClose();
},
onError: (error: Error) => {
if (error.message === 'CONFLICT') {
setConflictDialog(true);
} else {
toast.error(error.message);
}
},
});
const handleSave = () => {
saveMutation.mutate();
};
const handleConflictResolve = async (overwrite: boolean) => {
setConflictDialog(false);
if (overwrite) {
// Refetch to get new mtime, then save
await refetch();
saveMutation.mutate();
} else {
// Discard local changes and close
onClose();
}
};
const updateEnvValue = (key: string, value: string) => {
setLocalEdits((prev) => ({
...prev,
[key]: value,
}));
};
const isSensitiveKey = (key: string): boolean => {
return key.includes('TOKEN') || key.includes('KEY') || key.includes('SECRET');
};
return (
<>
<DialogHeader>
<DialogTitle>Environment Settings: {profileName}</DialogTitle>
<DialogDescription>
Edit environment variables for this profile. Sensitive keys are masked.
</DialogDescription>
</DialogHeader>
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Loading settings...</span>
</div>
) : currentSettings?.env && Object.keys(currentSettings.env).length > 0 ? (
<div className="space-y-4">
{Object.entries(currentSettings.env).map(([key, value]) => (
<div key={key}>
<Label>{key}</Label>
{isSensitiveKey(key) ? (
<MaskedInput value={value} onChange={(e) => updateEnvValue(key, e.target.value)} />
) : (
<Input
value={value}
onChange={(e) => updateEnvValue(key, e.target.value)}
className="font-mono"
/>
)}
</div>
))}
{data && (
<div className="pt-4 text-xs text-muted-foreground border-t">
<p>Path: {data.path}</p>
<p>Last modified: {new Date(data.mtime).toLocaleString()}</p>
</div>
)}
<div className="flex justify-end gap-2 pt-4">
<Button type="button" variant="outline" onClick={onClose}>
<X className="w-4 h-4 mr-2" /> Cancel
</Button>
<Button onClick={handleSave} disabled={saveMutation.isPending}>
{saveMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> Saving...
</>
) : (
<>
<Save className="w-4 h-4 mr-2" /> Save
</>
)}
</Button>
</div>
</div>
) : (
<div className="py-8 text-center text-muted-foreground">
No environment variables configured for this profile.
</div>
)}
<ConfirmDialog
open={conflictDialog}
title="File Modified Externally"
description="This settings file was modified by another process. Overwrite with your changes or discard?"
confirmText="Overwrite"
variant="destructive"
onConfirm={() => handleConflictResolve(true)}
onCancel={() => handleConflictResolve(false)}
/>
</>
);
}
export function SettingsDialog({ open, onClose, profileName }: SettingsDialogProps) {
// Handle dialog open/close state changes
const handleOpenChange = useCallback(
(isOpen: boolean) => {
if (!isOpen) {
onClose();
}
},
[onClose]
);
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
{/* Key prop ensures fresh state on each open */}
{open && profileName && (
<SettingsDialogContent
key={`${profileName}-${open}`}
profileName={profileName}
onClose={onClose}
/>
)}
</DialogContent>
</Dialog>
);
}
+1 -1
View File
@@ -30,7 +30,7 @@ export function AccountsPage() {
<p>
Accounts are isolated Claude instances with separate sessions.
<br />
Use <code className="bg-muted px-1 rounded">ccs login &lt;name&gt;</code> to add new
Use <code className="bg-muted px-1 rounded">ccs auth create &lt;name&gt;</code> to add new
accounts via CLI.
</p>
</div>
+23 -1
View File
@@ -8,12 +8,15 @@ import { Button } from '@/components/ui/button';
import { Plus } from 'lucide-react';
import { ProfilesTable } from '@/components/profiles-table';
import { ProfileDialog } from '@/components/profile-dialog';
import { SettingsDialog } from '@/components/settings-dialog';
import { useProfiles } from '@/hooks/use-profiles';
import type { Profile } from '@/lib/api-client';
export function ApiPage() {
const [dialogOpen, setDialogOpen] = useState(false);
const [editingProfile, setEditingProfile] = useState<Profile | null>(null);
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
const [settingsProfileName, setSettingsProfileName] = useState<string | null>(null);
const { data, isLoading } = useProfiles();
const handleEdit = (profile: Profile) => {
@@ -21,11 +24,21 @@ export function ApiPage() {
setDialogOpen(true);
};
const handleEditSettings = (profile: Profile) => {
setSettingsProfileName(profile.name);
setSettingsDialogOpen(true);
};
const handleCloseDialog = () => {
setDialogOpen(false);
setEditingProfile(null);
};
const handleCloseSettingsDialog = () => {
setSettingsDialogOpen(false);
setSettingsProfileName(null);
};
return (
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
@@ -44,10 +57,19 @@ export function ApiPage() {
{isLoading ? (
<div className="text-muted-foreground">Loading profiles...</div>
) : (
<ProfilesTable data={data?.profiles || []} onEdit={handleEdit} />
<ProfilesTable
data={data?.profiles || []}
onEdit={handleEdit}
onEditSettings={handleEditSettings}
/>
)}
<ProfileDialog open={dialogOpen} onClose={handleCloseDialog} profile={editingProfile} />
<SettingsDialog
open={settingsDialogOpen}
onClose={handleCloseSettingsDialog}
profileName={settingsProfileName}
/>
</div>
);
}