diff --git a/app/src/components/History/HistoryTable.tsx b/app/src/components/History/HistoryTable.tsx index 404a1914..7d7f9a98 100644 --- a/app/src/components/History/HistoryTable.tsx +++ b/app/src/components/History/HistoryTable.tsx @@ -1,9 +1,11 @@ import { useQueryClient } from '@tanstack/react-query'; import { AnimatePresence, motion } from 'framer-motion'; import { + AlignCenter, + AudioLines, + AudioWaveform, Download, FileArchive, - GalleryVerticalEnd, Loader2, MoreHorizontal, Play, @@ -30,10 +32,17 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; import { Textarea } from '@/components/ui/textarea'; import { useToast } from '@/components/ui/use-toast'; import { apiClient } from '@/lib/api/client'; -import type { EffectConfig, HistoryResponse } from '@/lib/api/types'; +import type { EffectConfig, GenerationVersionResponse, HistoryResponse } from '@/lib/api/types'; import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui'; import { useDeleteGeneration, @@ -67,6 +76,10 @@ export function HistoryTable() { ); const [effectsDialogOpen, setEffectsDialogOpen] = useState(false); const [effectsTargetId, setEffectsTargetId] = useState(null); + const [effectsTargetVersions, setEffectsTargetVersions] = useState( + [], + ); + const [effectsSourceVersionId, setEffectsSourceVersionId] = useState(null); const [effectsChain, setEffectsChain] = useState([]); const [applyingEffects, setApplyingEffects] = useState(false); const [expandedVersionsId, setExpandedVersionsId] = useState(null); @@ -253,7 +266,13 @@ export function HistoryTable() { }; const handleApplyEffects = (generationId: string) => { + const gen = allHistory.find((g) => g.id === generationId); + const versions = gen?.versions ?? []; setEffectsTargetId(generationId); + setEffectsTargetVersions(versions); + // Default to clean/original version (no effects chain) + const cleanVersion = versions.find((v) => !v.effects_chain || v.effects_chain.length === 0); + setEffectsSourceVersionId(cleanVersion?.id ?? null); setEffectsChain([]); setEffectsDialogOpen(true); }; @@ -264,6 +283,7 @@ export function HistoryTable() { try { const newVersion = await apiClient.applyEffectsToGeneration(effectsTargetId, { effects_chain: effectsChain, + source_version_id: effectsSourceVersionId ?? undefined, set_as_default: true, }); queryClient.invalidateQueries({ queryKey: ['history'] }); @@ -476,11 +496,41 @@ export function HistoryTable() { onMouseDown={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()} > + + {hasVersions && ( + + )} + {isFailed ? ( - {hasVersions && ( - - )} )} @@ -587,33 +606,49 @@ export function HistoryTable() { >
- {gen.versions.map((v) => ( - - ))} + {gen.versions.map((v) => { + // Show source provenance when effects were applied to a non-clean version + const sourceVersion = v.source_version_id + ? gen.versions?.find((sv) => sv.id === v.source_version_id) + : null; + const showSource = + sourceVersion && + sourceVersion.effects_chain && + sourceVersion.effects_chain.length > 0; + + return ( + + ); + })}
@@ -710,6 +745,31 @@ export function HistoryTable() { created. + {effectsTargetVersions.length > 1 && ( +
+ + +
+ )}
diff --git a/app/src/components/Sidebar.tsx b/app/src/components/Sidebar.tsx index d312ea6f..9ddb3c98 100644 --- a/app/src/components/Sidebar.tsx +++ b/app/src/components/Sidebar.tsx @@ -1,5 +1,5 @@ import { Link, useMatchRoute } from '@tanstack/react-router'; -import { BookOpen, Box, Mic, Server, Speaker, Volume2, Wand2 } from 'lucide-react'; +import { AudioLines, Box, Mic, Server, Speaker, Volume2, Wand2 } from 'lucide-react'; import voiceboxLogo from '@/assets/voicebox-logo.png'; import { cn } from '@/lib/utils/cn'; import { usePlayerStore } from '@/stores/playerStore'; @@ -11,10 +11,10 @@ interface SidebarProps { const tabs = [ { id: 'main', path: '/', icon: Volume2, label: 'Generate' }, - { id: 'stories', path: '/stories', icon: BookOpen, label: 'Stories' }, + { id: 'stories', path: '/stories', icon: AudioLines, label: 'Stories' }, { id: 'voices', path: '/voices', icon: Mic, label: 'Voices' }, - { id: 'audio', path: '/audio', icon: Speaker, label: 'Audio' }, { id: 'effects', path: '/effects', icon: Wand2, label: 'Effects' }, + { id: 'audio', path: '/audio', icon: Speaker, label: 'Audio' }, { id: 'models', path: '/models', icon: Box, label: 'Models' }, { id: 'server', path: '/server', icon: Server, label: 'Server' }, ]; diff --git a/app/src/components/VoiceProfiles/ProfileCard.tsx b/app/src/components/VoiceProfiles/ProfileCard.tsx index 77bb7c61..3675b765 100644 --- a/app/src/components/VoiceProfiles/ProfileCard.tsx +++ b/app/src/components/VoiceProfiles/ProfileCard.tsx @@ -1,4 +1,4 @@ -import { Download, Edit, Mic, Sparkles, Trash2 } from 'lucide-react'; +import { Download, Edit, Sparkles, Trash2 } from 'lucide-react'; import { useState } from 'react'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -15,7 +15,6 @@ import { 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 { @@ -24,19 +23,16 @@ 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); }; @@ -89,22 +85,7 @@ export function ProfileCard({ profile }: ProfileCardProps) { onKeyDown={handleKeyDown} > - -
- {avatarUrl && !avatarError ? ( - {`${profile.name} setAvatarError(true)} - /> - ) : ( - - )} -
+ {profile.name}
@@ -117,7 +98,7 @@ export function ProfileCard({ profile }: ProfileCardProps) { {profile.language} {profile.effects_chain && profile.effects_chain.length > 0 && ( - + )}
diff --git a/app/src/components/VoicesTab/VoiceInspector.tsx b/app/src/components/VoicesTab/VoiceInspector.tsx new file mode 100644 index 00000000..062726be --- /dev/null +++ b/app/src/components/VoicesTab/VoiceInspector.tsx @@ -0,0 +1,340 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { Edit2, Mic, X } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import * as z from 'zod'; +import { EffectsChainEditor } from '@/components/Effects/EffectsChainEditor'; +import { Button } from '@/components/ui/button'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { Textarea } from '@/components/ui/textarea'; +import { useToast } from '@/components/ui/use-toast'; +import { SampleList } from '@/components/VoiceProfiles/SampleList'; +import { apiClient } from '@/lib/api/client'; +import type { EffectConfig } from '@/lib/api/types'; +import { LANGUAGE_CODES, LANGUAGE_OPTIONS, type LanguageCode } from '@/lib/constants/languages'; +import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui'; +import { + useDeleteAvatar, + useProfile, + useUpdateProfile, + useUploadAvatar, +} from '@/lib/hooks/useProfiles'; +import { cn } from '@/lib/utils/cn'; +import { usePlayerStore } from '@/stores/playerStore'; +import { useServerStore } from '@/stores/serverStore'; + +const profileSchema = z.object({ + name: z.string().min(1, 'Name is required').max(100), + description: z.string().max(500).optional(), + language: z.enum(LANGUAGE_CODES as [LanguageCode, ...LanguageCode[]]), +}); + +type ProfileFormValues = z.infer; + +interface VoiceInspectorProps { + profileId: string; +} + +export function VoiceInspector({ profileId }: VoiceInspectorProps) { + const { data: profile } = useProfile(profileId); + const audioUrl = usePlayerStore((state) => state.audioUrl); + const isPlayerVisible = !!audioUrl; + const updateProfile = useUpdateProfile(); + const uploadAvatar = useUploadAvatar(); + const deleteAvatar = useDeleteAvatar(); + const serverUrl = useServerStore((state) => state.serverUrl); + const { toast } = useToast(); + + const [avatarPreview, setAvatarPreview] = useState(null); + const [avatarError, setAvatarError] = useState(false); + const avatarInputRef = useRef(null); + + const [effectsChain, setEffectsChain] = useState([]); + const [effectsDirty, setEffectsDirty] = useState(false); + + const form = useForm({ + resolver: zodResolver(profileSchema), + defaultValues: { + name: '', + description: '', + language: 'en', + }, + }); + + // Populate form when profile loads + useEffect(() => { + if (profile) { + form.reset({ + name: profile.name, + description: profile.description || '', + language: profile.language as LanguageCode, + }); + setEffectsChain(profile.effects_chain ?? []); + setEffectsDirty(false); + } + }, [profile, form]); + + // Avatar preview + useEffect(() => { + if (profile?.avatar_path) { + setAvatarPreview(`${serverUrl}/profiles/${profile.id}/avatar`); + } else { + setAvatarPreview(null); + } + setAvatarError(false); + }, [profile, serverUrl]); + + function handleAvatarFileChange(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + if (!file) return; + if (!file.type.startsWith('image/')) { + toast({ + title: 'Invalid file type', + description: 'Please select 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; + } + // Upload immediately + uploadAvatar.mutate( + { profileId, file }, + { + onSuccess: () => { + setAvatarPreview(URL.createObjectURL(file)); + toast({ title: 'Avatar updated' }); + }, + onError: (err) => { + toast({ + title: 'Avatar upload failed', + description: err instanceof Error ? err.message : 'Unknown error', + variant: 'destructive', + }); + }, + }, + ); + } + + async function handleRemoveAvatar() { + if (profile?.avatar_path) { + try { + await deleteAvatar.mutateAsync(profileId); + toast({ title: 'Avatar removed' }); + } catch (err) { + toast({ + title: 'Failed to remove avatar', + description: err instanceof Error ? err.message : 'Unknown error', + variant: 'destructive', + }); + } + } + setAvatarPreview(null); + if (avatarInputRef.current) avatarInputRef.current.value = ''; + } + + async function onSubmit(data: ProfileFormValues) { + try { + await updateProfile.mutateAsync({ + profileId, + data: { + name: data.name, + description: data.description, + language: data.language, + }, + }); + + if (effectsDirty) { + try { + await apiClient.updateProfileEffects( + profileId, + effectsChain.length > 0 ? effectsChain : null, + ); + setEffectsDirty(false); + } catch (fxError) { + toast({ + title: 'Effects update failed', + description: fxError instanceof Error ? fxError.message : 'Failed to save effects', + variant: 'destructive', + }); + return; + } + } + + toast({ title: 'Voice updated', description: `"${data.name}" saved.` }); + } catch (error) { + toast({ + title: 'Error', + description: error instanceof Error ? error.message : 'Failed to save profile', + variant: 'destructive', + }); + } + } + + if (!profile) { + return ( +
+ Loading... +
+ ); + } + + const isDirty = form.formState.isDirty || effectsDirty; + + return ( +
+
+
+ + {/* Avatar */} +
+
+
+ {avatarPreview && !avatarError ? ( + {profile.name} setAvatarError(true)} + /> + ) : ( + + )} +
+ + {avatarPreview && ( + + )} +
+ +
+ + {/* Fields */} +
+ ( + + Name + + + + + + )} + /> + + ( + + Description + +