diff --git a/app/src/components/Generation/FloatingGenerateBox.tsx b/app/src/components/Generation/FloatingGenerateBox.tsx index d09d489f1..1dc2e0d35 100644 --- a/app/src/components/Generation/FloatingGenerateBox.tsx +++ b/app/src/components/Generation/FloatingGenerateBox.tsx @@ -153,10 +153,7 @@ export function FloatingGenerateBox({ | 'kokoro' | 'qwen_custom_voice'; useEffect(() => { - if (selectedProfile?.language) { - form.setValue('language', selectedProfile.language as LanguageCode); - } - // Auto-switch engine to match the profile + // 1. Auto-switch engine to match the profile FIRST const engine = selectedProfile?.default_engine ?? selectedProfile?.preset_engine; if (engine) { form.setValue('engine', engine as EngineValue); @@ -168,6 +165,11 @@ export function FloatingGenerateBox({ form.setValue('engine', 'qwen'); } } + + // 2. Set language AFTER engine has been switched so language dropdown receives valid options + if (selectedProfile?.language) { + form.setValue('language', selectedProfile.language as LanguageCode); + } // Pre-fill effects from profile defaults if ( selectedProfile?.effects_chain && diff --git a/app/src/components/History/HistoryTable.tsx b/app/src/components/History/HistoryTable.tsx index aeeae4ece..391a6e032 100644 --- a/app/src/components/History/HistoryTable.tsx +++ b/app/src/components/History/HistoryTable.tsx @@ -459,7 +459,7 @@ export function HistoryTable() {
diff --git a/app/src/components/MainEditor/MainEditor.tsx b/app/src/components/MainEditor/MainEditor.tsx index 042c593f8..73b6a096c 100644 --- a/app/src/components/MainEditor/MainEditor.tsx +++ b/app/src/components/MainEditor/MainEditor.tsx @@ -1,4 +1,4 @@ -import { Sparkles, Upload } from 'lucide-react'; +import { Search, Sparkles, Upload, X } from 'lucide-react'; import { useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { FloatingGenerateBox } from '@/components/Generation/FloatingGenerateBox'; @@ -12,6 +12,7 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; import { useToast } from '@/components/ui/use-toast'; import { ProfileList } from '@/components/VoiceProfiles/ProfileList'; @@ -30,6 +31,7 @@ export function MainEditor() { const fileInputRef = useRef(null); const [importDialogOpen, setImportDialogOpen] = useState(false); const [selectedFile, setSelectedFile] = useState(null); + const [search, setSearch] = useState(''); const { toast } = useToast(); const handleImportClick = () => { @@ -40,6 +42,7 @@ export function MainEditor() { const file = e.target.files?.[0]; if (file) { if (!file.name.endsWith('.voicebox.zip')) { + e.target.value = ''; toast({ title: t('main.import.invalidTitle'), description: t('main.import.invalidDescription'), @@ -78,12 +81,12 @@ export function MainEditor() { }; return ( -
-
-
+
+
+
-
-
+
+

Voicebox

+ +
+ + setSearch(e.target.value)} + className="h-9 pl-9 pr-8 text-sm rounded-full focus-visible:ring-0 focus-visible:ring-offset-0" + /> + {search && ( + + )} +
- + setSearch('')} />
-
+
diff --git a/app/src/components/VoiceProfiles/ProfileCard.tsx b/app/src/components/VoiceProfiles/ProfileCard.tsx index e9042a571..7b7957af2 100644 --- a/app/src/components/VoiceProfiles/ProfileCard.tsx +++ b/app/src/components/VoiceProfiles/ProfileCard.tsx @@ -89,9 +89,12 @@ export function ProfileCard({ profile, disabled }: ProfileCardProps) { <> void; +} + +export function ProfileList({ search = '', onClearSearch }: ProfileListProps) { const { t } = useTranslation(); const { data: profiles, isLoading, error } = useProfiles(); const setDialogOpen = useUIStore((state) => state.setProfileDialogOpen); @@ -40,6 +45,43 @@ export function ProfileList() { }; }, [selectedProfileId, selectedEngine]); + const allProfiles = useMemo(() => profiles || [], [profiles]); + const isPresetEngine = PRESET_ENGINES.has(selectedEngine); + + /** Whether a profile is supported by the currently selected engine. */ + const isSupported = (p: (typeof allProfiles)[number]) => + isPresetEngine + ? p.voice_type === 'preset' && p.preset_engine === selectedEngine + : p.voice_type !== 'preset'; + + // Sort so supported profiles come first, selected profile at top, then alphabetical by name + const sortedProfiles = useMemo(() => { + return [...allProfiles].sort((a, b) => { + const suppA = isSupported(a) ? 0 : 1; + const suppB = isSupported(b) ? 0 : 1; + if (suppA !== suppB) return suppA - suppB; + + const selA = selectedProfileId === a.id ? 0 : 1; + const selB = selectedProfileId === b.id ? 0 : 1; + if (selA !== selB) return selA - selB; + + return a.name.localeCompare(b.name); + }); + }, [allProfiles, selectedEngine, selectedProfileId]); + + const filteredProfiles = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return sortedProfiles; + return sortedProfiles.filter( + (p) => + p.name.toLowerCase().includes(q) || + p.description?.toLowerCase().includes(q) || + p.language.toLowerCase().includes(q) || + p.preset_engine?.toLowerCase().includes(q) || + p.default_engine?.toLowerCase().includes(q), + ); + }, [sortedProfiles, search]); + if (isLoading) { return null; } @@ -54,21 +96,7 @@ export function ProfileList() { ); } - const allProfiles = profiles || []; - const isPresetEngine = PRESET_ENGINES.has(selectedEngine); - - /** Whether a profile is supported by the currently selected engine. */ - const isSupported = (p: (typeof allProfiles)[number]) => - isPresetEngine - ? p.voice_type === 'preset' && p.preset_engine === selectedEngine - : p.voice_type !== 'preset'; - - // Sort so supported profiles come first - const sortedProfiles = [...allProfiles].sort( - (a, b) => (isSupported(a) ? 0 : 1) - (isSupported(b) ? 0 : 1), - ); - - const hasUnsupported = sortedProfiles.some((p) => !isSupported(p)); + const hasUnsupported = filteredProfiles.some((p) => !isSupported(p)); return (
@@ -84,12 +112,27 @@ export function ProfileList() { + ) : filteredProfiles.length === 0 ? ( + + + +

{t('main.noVoicesFound')}

+

+ {t('profiles.list.noVoicesMatch', { query: search })} +

+ {onClearSearch && ( + + )} +
+
) : ( -
- {sortedProfiles.map((profile) => ( +
+ {filteredProfiles.map((profile) => (
{ if (el) cardRefs.current.set(profile.id, el); else cardRefs.current.delete(profile.id); @@ -99,9 +142,9 @@ export function ProfileList() {
))} {hasUnsupported && ( -
- - {t('profiles.list.unsupportedNote')} +
+ + {t('profiles.list.unsupportedNote')}
)}
@@ -112,3 +155,4 @@ export function ProfileList() {
); } + diff --git a/app/src/components/VoicesTab/VoicesTab.tsx b/app/src/components/VoicesTab/VoicesTab.tsx index 6ab0b0b0e..3823ee0cd 100644 --- a/app/src/components/VoicesTab/VoicesTab.tsx +++ b/app/src/components/VoicesTab/VoicesTab.tsx @@ -1,11 +1,12 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; -import { Mic, Plus, Search, Sparkles } from 'lucide-react'; +import { Mic, Plus, Search, Sparkles, Upload, X } from 'lucide-react'; import { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; - import { MultiSelect } from '@/components/ui/multi-select'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Table, TableBody, @@ -14,11 +15,12 @@ import { TableHeader, TableRow, } from '@/components/ui/table'; +import { useToast } from '@/components/ui/use-toast'; import { ProfileForm } from '@/components/VoiceProfiles/ProfileForm'; import { apiClient } from '@/lib/api/client'; import type { VoiceProfileResponse } from '@/lib/api/types'; import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui'; -import { useProfiles } from '@/lib/hooks/useProfiles'; +import { useImportProfile, useProfiles } from '@/lib/hooks/useProfiles'; import { cn } from '@/lib/utils/cn'; import { usePlayerStore } from '@/stores/playerStore'; import { useServerStore } from '@/stores/serverStore'; @@ -35,19 +37,106 @@ export function VoicesTab() { const scrollRef = useRef(null); const audioUrl = usePlayerStore((state) => state.audioUrl); const isPlayerVisible = !!audioUrl; + const importProfile = useImportProfile(); + const fileInputRef = useRef(null); + const { toast } = useToast(); + const [search, setSearch] = useState(''); + const [typeFilter, setTypeFilter] = useState('all'); + const [languageFilter, setLanguageFilter] = useState('all'); + const [importDialogOpen, setImportDialogOpen] = useState(false); + const [selectedFile, setSelectedFile] = useState(null); + + // Extract unique languages for filter dropdown + const availableLanguages = useMemo(() => { + if (!profiles) return []; + const langs = new Set(); + profiles.forEach((p) => { + if (p.language) langs.add(p.language); + }); + return Array.from(langs).sort(); + }, [profiles]); const filteredProfiles = useMemo(() => { if (!profiles) return []; - if (!search.trim()) return profiles; - const q = search.toLowerCase(); - return profiles.filter( - (p) => - p.name.toLowerCase().includes(q) || - p.description?.toLowerCase().includes(q) || - p.language.toLowerCase().includes(q), - ); - }, [profiles, search]); + return profiles.filter((p) => { + // Type filter + if (typeFilter !== 'all' && p.voice_type !== typeFilter) { + return false; + } + // Language filter + if (languageFilter !== 'all' && p.language !== languageFilter) { + return false; + } + // Search filter + if (search.trim()) { + const q = search.trim().toLowerCase(); + const matchesName = p.name.toLowerCase().includes(q); + const matchesDesc = p.description?.toLowerCase().includes(q) ?? false; + const matchesLang = p.language.toLowerCase().includes(q); + const matchesEngine = + (p.preset_engine?.toLowerCase().includes(q) ?? false) || + (p.default_engine?.toLowerCase().includes(q) ?? false); + + if (!matchesName && !matchesDesc && !matchesLang && !matchesEngine) { + return false; + } + } + return true; + }); + }, [profiles, search, typeFilter, languageFilter]); + + const handleClearFilters = () => { + setSearch(''); + setTypeFilter('all'); + setLanguageFilter('all'); + }; + + const handleImportClick = () => { + fileInputRef.current?.click(); + }; + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) { + if (!file.name.endsWith('.voicebox.zip')) { + e.target.value = ''; + toast({ + title: t('main.import.invalidTitle'), + description: t('main.import.invalidDescription'), + variant: 'destructive', + }); + return; + } + setSelectedFile(file); + setImportDialogOpen(true); + } + }; + + const handleImportConfirm = () => { + if (selectedFile) { + importProfile.mutate(selectedFile, { + onSuccess: () => { + setImportDialogOpen(false); + setSelectedFile(null); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + toast({ + title: t('main.import.successTitle'), + description: t('main.import.successDescription'), + }); + }, + onError: (error) => { + toast({ + title: t('main.import.failedTitle'), + description: error.message, + variant: 'destructive', + }); + }, + }); + } + }; // Auto-select first profile if none selected useEffect(() => { @@ -102,31 +191,102 @@ export function VoicesTab() { ); } + const isFiltered = search || typeFilter !== 'all' || languageFilter !== 'all'; + return (
{/* Left: Table */}
{/* Scroll Mask */} -
+
{/* Fixed Header */} -
-
+
+ {/* Row 1: Title & Actions */} +

{t('voicesTab.title')}

-
-
- +
+ + + +
+
+ + {/* Row 2: Search & Filter Toolbar */} +
+
+ setSearch(e.target.value)} - className="h-10 pl-8 text-sm rounded-full focus-visible:ring-0 focus-visible:ring-offset-0" + className="h-9 pl-9 pr-8 text-sm rounded-full focus-visible:ring-0 focus-visible:ring-offset-0" /> + {search && ( + + )}
- + + {/* Type Filter */} + + + {/* Language Filter */} + {availableLanguages.length > 0 && ( + + )} + + {isFiltered && ( + + )}
@@ -134,36 +294,51 @@ export function VoicesTab() {
- - - - {t('voicesTab.columns.name')} - {t('voicesTab.columns.language')} - {t('voicesTab.columns.generations')} - {t('voicesTab.columns.samples')} - {t('voicesTab.columns.effects')} - {t('voicesTab.columns.channels')} - - - - - {filteredProfiles.map((profile) => ( - setSelectedVoiceId(profile.id)} - channelIds={channelAssignments?.[profile.id] || []} - channels={channels || []} - onChannelChange={(channelIds) => handleChannelChange(profile.id, channelIds)} - /> - ))} - -
+ {filteredProfiles.length === 0 ? ( +
+ +

{t('voicesTab.noVoicesFound')}

+

+ {t('voicesTab.noVoicesFoundDesc')} +

+ {isFiltered && ( + + )} +
+ ) : ( + + + + {t('voicesTab.columns.name')} + {t('voicesTab.columns.language')} + {t('voicesTab.columns.generations')} + {t('voicesTab.columns.samples')} + {t('voicesTab.columns.effects')} + {t('voicesTab.columns.channels')} + + + + + {filteredProfiles.map((profile) => ( + setSelectedVoiceId(profile.id)} + channelIds={channelAssignments?.[profile.id] || []} + channels={channels || []} + onChannelChange={(channelIds) => handleChannelChange(profile.id, channelIds)} + /> + ))} + +
+ )}
@@ -175,6 +350,48 @@ export function VoicesTab() { )} + + { + setImportDialogOpen(open); + if (!open) { + setSelectedFile(null); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + }} + > + + + {t('main.import.dialogTitle')} + + {t('main.import.dialogDescription', { name: selectedFile?.name })} + + + + + + + +
); } diff --git a/app/src/i18n/locales/en/translation.json b/app/src/i18n/locales/en/translation.json index 7f96d9d05..5620fac32 100644 --- a/app/src/i18n/locales/en/translation.json +++ b/app/src/i18n/locales/en/translation.json @@ -173,6 +173,16 @@ "avatarAlt": "{{name}} avatar", "selectChannels": "Select channels…", "channelDefaultLabel": "{{name}} (Default)", + "filterType": "Filter Type", + "filterLanguage": "Filter Language", + "allTypes": "All Types", + "allLanguages": "All Languages", + "typeCloned": "Cloned", + "typePreset": "Preset", + "typeDesigned": "Designed", + "noVoicesFound": "No voices match your search or active filters", + "noVoicesFoundDesc": "Try adjusting your search query or clearing your active filters.", + "clearFilters": "Clear filters", "columns": { "name": "Name", "language": "Language", @@ -406,7 +416,8 @@ "errorLoading": "Error loading profiles: {{message}}", "empty": "No voice profiles yet. Create your first profile to get started.", "createVoice": "Create Voice", - "unsupportedNote": "Only supported voice profiles can be selected for the current model." + "unsupportedNote": "Only supported voice profiles can be selected for the current model.", + "noVoicesMatch": "No profiles match \"{{query}}\"" }, "deleteDialog": { "title": "Delete Profile", @@ -723,6 +734,9 @@ "main": { "importVoice": "Import Voice", "createVoice": "Create Voice", + "searchPlaceholder": "Search voices…", + "noVoicesFound": "No voices found matching your search", + "clearSearch": "Clear search", "import": { "invalidTitle": "Invalid file type", "invalidDescription": "Please select a valid .voicebox.zip file", diff --git a/app/src/index.css b/app/src/index.css index 03b1b2940..46f2f634f 100644 --- a/app/src/index.css +++ b/app/src/index.css @@ -126,6 +126,30 @@ text-orientation: mixed; letter-spacing: 0.1em; } + + .hover-scrollbar { + scrollbar-width: thin; + scrollbar-color: transparent transparent; + transition: scrollbar-color 0.2s ease-in-out; + } + .hover-scrollbar:hover { + scrollbar-color: hsl(var(--muted-foreground) / 0.4) transparent; + } + .hover-scrollbar::-webkit-scrollbar { + display: block; + width: 6px; + height: 6px; + } + .hover-scrollbar::-webkit-scrollbar-track { + background: transparent; + } + .hover-scrollbar::-webkit-scrollbar-thumb { + background: transparent; + border-radius: 9999px; + } + .hover-scrollbar:hover::-webkit-scrollbar-thumb { + background: hsl(var(--muted-foreground) / 0.4); + } } @keyframes fadeInScale {