Files
voicebox/app/src/components/VoiceProfiles/ProfileCard.tsx
T
Richard Orme 9955e1dcb7 a11y: address PR feedback and polish docs
- HistoryTable: skip row key handler when focus is on Actions button (Enter/Space)
- StoryList: expose selected story (aria-pressed, 'Selected' in label)
- ProfileCard: skip card key handler when focus is on Export/Edit/Delete
- VoicesTab: keep table semantics; edit button in first cell instead of role=button on row
- PR-ACCESSIBILITY.md: 'Fine-tune' wording, 'focus on the text area' phrasing

Made-with: Cursor
2026-03-07 12:36:02 -08:00

170 lines
5.7 KiB
TypeScript

import { Download, Edit, Mic, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { CircleButton } from '@/components/ui/circle-button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import type { VoiceProfileResponse } from '@/lib/api/types';
import { useDeleteProfile, useExportProfile } from '@/lib/hooks/useProfiles';
import { cn } from '@/lib/utils/cn';
import { useServerStore } from '@/stores/serverStore';
import { useUIStore } from '@/stores/uiStore';
interface ProfileCardProps {
profile: VoiceProfileResponse;
}
export function ProfileCard({ profile }: ProfileCardProps) {
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [avatarError, setAvatarError] = useState(false);
const deleteProfile = useDeleteProfile();
const exportProfile = useExportProfile();
const setEditingProfileId = useUIStore((state) => state.setEditingProfileId);
const setProfileDialogOpen = useUIStore((state) => state.setProfileDialogOpen);
const selectedProfileId = useUIStore((state) => state.selectedProfileId);
const setSelectedProfileId = useUIStore((state) => state.setSelectedProfileId);
const serverUrl = useServerStore((state) => state.serverUrl);
const isSelected = selectedProfileId === profile.id;
const avatarUrl = profile.avatar_path ? `${serverUrl}/profiles/${profile.id}/avatar` : null;
const handleSelect = () => {
setSelectedProfileId(isSelected ? null : profile.id);
};
const handleEdit = () => {
setEditingProfileId(profile.id);
setProfileDialogOpen(true);
};
const handleDeleteClick = (e: React.MouseEvent) => {
e.stopPropagation();
setDeleteDialogOpen(true);
};
const handleDeleteConfirm = () => {
deleteProfile.mutate(profile.id);
setDeleteDialogOpen(false);
};
const handleExport = (e: React.MouseEvent) => {
e.stopPropagation();
exportProfile.mutate(profile.id);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
const target = e.target as HTMLElement;
if (target.closest('button')) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleSelect();
}
};
const selectLabel = isSelected
? `${profile.name}, ${profile.language}. Selected as voice for generation.`
: `${profile.name}, ${profile.language}. Select as voice for generation.`;
return (
<>
<Card
className={cn(
'cursor-pointer hover:shadow-md transition-all flex flex-col',
isSelected && 'ring-2 ring-primary shadow-md',
)}
onClick={handleSelect}
tabIndex={0}
role="button"
aria-label={selectLabel}
aria-pressed={isSelected}
onKeyDown={handleKeyDown}
>
<CardHeader className="p-3 pb-2">
<CardTitle className="flex items-center gap-1.5 text-base font-medium">
<div className="h-6 w-6 rounded-full bg-muted flex items-center justify-center shrink-0 overflow-hidden">
{avatarUrl && !avatarError ? (
<img
src={avatarUrl}
alt={`${profile.name} avatar`}
className={cn(
'h-full w-full object-cover transition-all duration-200',
!isSelected && 'grayscale',
)}
onError={() => setAvatarError(true)}
/>
) : (
<Mic className="h-3.5 w-3.5 text-muted-foreground" />
)}
</div>
<span className="break-words">{profile.name}</span>
</CardTitle>
</CardHeader>
<CardContent className="p-3 pt-0 flex flex-col flex-1">
<p className="text-xs text-muted-foreground mb-1.5 line-clamp-2 leading-relaxed">
{profile.description || 'No description'}
</p>
<div className="mb-2">
<Badge variant="outline" className="text-xs h-5 px-1.5 text-muted-foreground">
{profile.language}
</Badge>
</div>
<div className="flex gap-0.5 justify-end items-end mt-auto">
<CircleButton
icon={Download}
onClick={handleExport}
disabled={exportProfile.isPending}
aria-label="Export profile"
/>
<CircleButton
icon={Edit}
onClick={(e) => {
e.stopPropagation();
handleEdit();
}}
aria-label="Edit profile"
/>
<CircleButton
icon={Trash2}
onClick={handleDeleteClick}
disabled={deleteProfile.isPending}
aria-label="Delete profile"
/>
</div>
</CardContent>
</Card>
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Profile</DialogTitle>
<DialogDescription>
Are you sure you want to delete "{profile.name}"? This action cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDeleteConfirm}
disabled={deleteProfile.isPending}
>
{deleteProfile.isPending ? 'Deleting...' : 'Delete'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}