Implement avatar upload and management for voice profiles

- Added functionality to upload, delete, and retrieve avatar images for voice profiles.
- Introduced new API endpoints for avatar management, including upload and delete operations.
- Enhanced profile forms and components to support avatar image handling, including previews and error handling.
- Updated database schema to include avatar_path for profiles and added necessary migrations.
- Implemented image validation and processing utilities to ensure proper avatar uploads.
This commit is contained in:
Jamie Pine
2026-01-29 19:28:42 -08:00
parent ef3c3a7f8c
commit 89f3127c37
15 changed files with 594 additions and 35 deletions
@@ -112,7 +112,6 @@ export function FloatingGenerateBox({
}
}, [selectedProfileId, profiles, setSelectedProfileId]);
// Auto-resize textarea based on content (only when expanded)
useEffect(() => {
if (!isExpanded) {
@@ -1,6 +1,7 @@
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { GripVertical, Mic, MoreHorizontal, Play, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
@@ -12,6 +13,7 @@ import { Textarea } from '@/components/ui/textarea';
import type { StoryItemDetail } from '@/lib/api/types';
import { cn } from '@/lib/utils/cn';
import { useStoryStore } from '@/stores/storyStore';
import { useServerStore } from '@/stores/serverStore';
interface StoryChatItemProps {
item: StoryItemDetail;
@@ -33,6 +35,10 @@ export function StoryChatItem({
isDragging,
}: StoryChatItemProps) {
const seek = useStoryStore((state) => state.seek);
const serverUrl = useServerStore((state) => state.serverUrl);
const [avatarError, setAvatarError] = useState(false);
const avatarUrl = `${serverUrl}/profiles/${item.profile_id}/avatar`;
// Check if this item is currently playing based on timecode
const itemStartMs = item.start_time_ms;
@@ -72,10 +78,22 @@ export function StoryChatItem({
</button>
)}
{/* Voice Icon */}
{/* Voice Avatar */}
<div className="shrink-0">
<div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center">
<Mic className="h-5 w-5 text-muted-foreground" />
<div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center overflow-hidden">
{!avatarError ? (
<img
src={avatarUrl}
alt={`${item.profile_name} avatar`}
className={cn(
'h-full w-full object-cover transition-all duration-200',
!isCurrentlyPlaying && 'grayscale'
)}
onError={() => setAvatarError(true)}
/>
) : (
<Mic className="h-5 w-5 text-muted-foreground" />
)}
</div>
</div>
@@ -1,5 +1,6 @@
import { Download, Edit, Mic, Trash2 } from 'lucide-react';
import { useState } from 'react';
import { useServerStore } from '@/stores/serverStore';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -23,15 +24,21 @@ interface ProfileCardProps {
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);
};
@@ -67,8 +74,20 @@ export function ProfileCard({ profile }: ProfileCardProps) {
>
<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">
<Mic className="h-3.5 w-3.5 text-muted-foreground" />
<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>
@@ -1,6 +1,6 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { Mic, Monitor, Upload, X } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Edit2, Mic, Monitor, Upload, X } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import * as z from 'zod';
import { Button } from '@/components/ui/button';
@@ -36,14 +36,17 @@ import { useAudioRecording } from '@/lib/hooks/useAudioRecording';
import {
useAddSample,
useCreateProfile,
useDeleteAvatar,
useProfile,
useUpdateProfile,
useUploadAvatar,
} from '@/lib/hooks/useProfiles';
import { useSystemAudioCapture } from '@/lib/hooks/useSystemAudioCapture';
import { useTranscription } from '@/lib/hooks/useTranscription';
import { isTauri } from '@/lib/tauri';
import { formatAudioDuration, getAudioDuration } from '@/lib/utils/audio';
import { type ProfileFormDraft, useUIStore } from '@/stores/uiStore';
import { useServerStore } from '@/stores/serverStore';
import { AudioSampleRecording } from './AudioSampleRecording';
import { AudioSampleSystem } from './AudioSampleSystem';
import { AudioSampleUpload } from './AudioSampleUpload';
@@ -57,6 +60,7 @@ const baseProfileSchema = z.object({
language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]),
sampleFile: z.instanceof(File).optional(),
referenceText: z.string().max(1000).optional(),
avatarFile: z.instanceof(File).optional(),
});
const profileSchema = baseProfileSchema.refine(
@@ -108,13 +112,18 @@ export function ProfileForm() {
const createProfile = useCreateProfile();
const updateProfile = useUpdateProfile();
const addSample = useAddSample();
const uploadAvatar = useUploadAvatar();
const deleteAvatar = useDeleteAvatar();
const transcribe = useTranscription();
const { toast } = useToast();
const [sampleMode, setSampleMode] = useState<'upload' | 'record' | 'system'>('record');
const [audioDuration, setAudioDuration] = useState<number | null>(null);
const [isValidatingAudio, setIsValidatingAudio] = useState(false);
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const avatarInputRef = useRef<HTMLInputElement>(null);
const { isPlaying, playPause, cleanup: cleanupAudio } = useAudioPlayer();
const isCreating = !editingProfileId;
const serverUrl = useServerStore((state) => state.serverUrl);
const form = useForm<ProfileFormValues>({
resolver: zodResolver(profileSchema),
@@ -124,10 +133,12 @@ export function ProfileForm() {
language: 'en',
sampleFile: undefined,
referenceText: '',
avatarFile: undefined,
},
});
const selectedFile = form.watch('sampleFile');
const selectedAvatarFile = form.watch('avatarFile');
// Validate audio duration when file is selected
useEffect(() => {
@@ -244,6 +255,19 @@ export function ProfileForm() {
}
}, [systemRecordingError, toast]);
// Handle avatar preview
useEffect(() => {
if (selectedAvatarFile instanceof File) {
const url = URL.createObjectURL(selectedAvatarFile);
setAvatarPreview(url);
return () => URL.revokeObjectURL(url);
} else if (editingProfile?.avatar_path) {
setAvatarPreview(`${serverUrl}/profiles/${editingProfile.id}/avatar`);
} else {
setAvatarPreview(null);
}
}, [selectedAvatarFile, editingProfile, serverUrl]);
// Restore form state from draft or editing profile
useEffect(() => {
if (editingProfile) {
@@ -253,6 +277,7 @@ export function ProfileForm() {
language: editingProfile.language as LanguageCode,
sampleFile: undefined,
referenceText: undefined,
avatarFile: undefined,
});
} else if (profileFormDraft && open) {
// Restore from draft when opening in create mode
@@ -262,6 +287,7 @@ export function ProfileForm() {
language: profileFormDraft.language as LanguageCode,
referenceText: profileFormDraft.referenceText,
sampleFile: undefined,
avatarFile: undefined,
});
setSampleMode(profileFormDraft.sampleMode);
// Restore the file if we have it saved
@@ -285,8 +311,10 @@ export function ProfileForm() {
language: 'en',
sampleFile: undefined,
referenceText: undefined,
avatarFile: undefined,
});
setSampleMode('record');
setAvatarPreview(null);
}
}, [editingProfile, profileFormDraft, open, form]);
@@ -330,6 +358,52 @@ export function ProfileForm() {
playPause(file);
}
function handleAvatarFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (file) {
if (!file.type.startsWith('image/')) {
toast({
title: 'Invalid file type',
description: 'Please select an image file (PNG, JPG, or WebP)',
variant: 'destructive',
});
return;
}
if (file.size > 5 * 1024 * 1024) {
toast({
title: 'File too large',
description: 'Image must be less than 5MB',
variant: 'destructive',
});
return;
}
form.setValue('avatarFile', file);
}
}
async function handleRemoveAvatar() {
if (editingProfileId && editingProfile?.avatar_path) {
try {
await deleteAvatar.mutateAsync(editingProfileId);
toast({
title: 'Avatar removed',
description: 'Avatar image has been removed successfully.',
});
} catch (error) {
toast({
title: 'Failed to remove avatar',
description: error instanceof Error ? error.message : 'Unknown error',
variant: 'destructive',
});
}
}
form.setValue('avatarFile', undefined);
setAvatarPreview(null);
if (avatarInputRef.current) {
avatarInputRef.current.value = '';
}
}
async function onSubmit(data: ProfileFormValues) {
try {
if (editingProfileId) {
@@ -342,6 +416,23 @@ export function ProfileForm() {
language: data.language,
},
});
// Handle avatar upload/update if file changed
if (data.avatarFile) {
try {
await uploadAvatar.mutateAsync({
profileId: editingProfileId,
file: data.avatarFile,
});
} catch (avatarError) {
toast({
title: 'Avatar upload failed',
description: avatarError instanceof Error ? avatarError.message : 'Failed to upload avatar',
variant: 'destructive',
});
}
}
toast({
title: 'Voice updated',
description: `"${data.name}" has been updated successfully.`,
@@ -418,6 +509,23 @@ export function ProfileForm() {
file: sampleFile,
referenceText: referenceText,
});
// Handle avatar upload if provided
if (data.avatarFile) {
try {
await uploadAvatar.mutateAsync({
profileId: profile.id,
file: data.avatarFile,
});
} catch (avatarError) {
toast({
title: 'Avatar upload failed',
description: avatarError instanceof Error ? avatarError.message : 'Failed to upload avatar',
variant: 'destructive',
});
}
}
toast({
title: 'Profile created',
description: `"${data.name}" has been created with a sample.`,
@@ -670,6 +778,58 @@ export function ProfileForm() {
{/* Right column: Profile info */}
<div className="space-y-4">
{/* Avatar Upload */}
<FormField
control={form.control}
name="avatarFile"
render={() => (
<FormItem>
<FormControl>
<div className="flex justify-center pt-4 pb-2">
<div className="relative group">
<div className="h-24 w-24 rounded-full bg-muted flex items-center justify-center shrink-0 overflow-hidden border-2 border-border">
{avatarPreview ? (
<img
src={avatarPreview}
alt="Avatar preview"
className="h-full w-full object-cover"
/>
) : (
<Mic className="h-10 w-10 text-muted-foreground" />
)}
</div>
<button
type="button"
onClick={() => avatarInputRef.current?.click()}
className="absolute inset-0 rounded-full bg-accent/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center cursor-pointer"
>
<Edit2 className="h-6 w-6 text-accent-foreground" />
</button>
{(avatarPreview || editingProfile?.avatar_path) && (
<button
type="button"
onClick={handleRemoveAvatar}
disabled={deleteAvatar.isPending}
className="absolute bottom-0 right-0 h-6 w-6 rounded-full bg-destructive text-destructive-foreground flex items-center justify-center hover:bg-destructive/90 transition-colors shadow-sm"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
<input
ref={avatarInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={handleAvatarFileChange}
className="hidden"
/>
</div>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="name"
@@ -188,15 +188,7 @@ export function SampleList({ profileId }: SampleListProps) {
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Audio Samples</h3>
<Button type="button" size="sm" onClick={() => setUploadOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Add Sample
</Button>
</div>
<div className="space-y-4 pt-4">
{samples && samples.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center border border-dashed rounded-lg">
<Volume2 className="h-8 w-8 text-muted-foreground/50 mb-2" />
@@ -304,6 +296,11 @@ export function SampleList({ profileId }: SampleListProps) {
</div>
)}
<Button type="button" variant="outline" className="w-full" onClick={() => setUploadOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Add Sample
</Button>
<SampleUpload profileId={profileId} open={uploadOpen} onOpenChange={setUploadOpen} />
</div>
);