mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-19 14:50:38 -07:00
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:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user