mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-15 04:40:40 -07:00
- 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
170 lines
5.7 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|