From 99d399b304275eeb52d7204a61eeef62ae292a74 Mon Sep 17 00:00:00 2001 From: devangkantharia Date: Sun, 9 Aug 2026 16:43:21 +0530 Subject: [PATCH 1/4] feat(ui): add responsive search & filter to Voice Library and Main Editor (fixes #966) --- app/src/components/History/HistoryTable.tsx | 2 +- app/src/components/MainEditor/MainEditor.tsx | 39 ++- .../components/VoiceProfiles/ProfileCard.tsx | 9 +- .../components/VoiceProfiles/ProfileList.tsx | 92 ++++-- app/src/components/VoicesTab/VoicesTab.tsx | 304 +++++++++++++++--- app/src/i18n/locales/en/translation.json | 13 + app/src/index.css | 24 ++ 7 files changed, 396 insertions(+), 87 deletions(-) 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..837f439cb 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 = () => { @@ -78,12 +80,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')}

+

+ No profiles match "{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..621f55369 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,105 @@ 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.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')) { + 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 +190,101 @@ 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 +292,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 +348,37 @@ export function VoicesTab() { )} + + + + + {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..b3abe0711 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", @@ -723,6 +733,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 { From ab6fc04e00ba1d62be2f5371fc951af3188f0009 Mon Sep 17 00:00:00 2001 From: devangkantharia Date: Sun, 9 Aug 2026 16:52:42 +0530 Subject: [PATCH 2/4] fix(generation): set engine before language when selecting voice profiles and add Hindi to qwen engine --- app/src/components/Generation/FloatingGenerateBox.tsx | 10 ++++++---- app/src/lib/constants/languages.ts | 6 +++--- 2 files changed, 9 insertions(+), 7 deletions(-) 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/lib/constants/languages.ts b/app/src/lib/constants/languages.ts index e28c519bc..42ecdd07b 100644 --- a/app/src/lib/constants/languages.ts +++ b/app/src/lib/constants/languages.ts @@ -39,7 +39,7 @@ export type LanguageCode = keyof typeof ALL_LANGUAGES; /** Per-engine supported language codes. */ export const ENGINE_LANGUAGES: Record = { - qwen: ['zh', 'en', 'ja', 'ko', 'de', 'fr', 'ru', 'pt', 'es', 'it'], + qwen: ['zh', 'en', 'ja', 'ko', 'de', 'fr', 'ru', 'pt', 'es', 'it', 'hi'], luxtts: ['en'], chatterbox: [ 'ar', @@ -67,9 +67,9 @@ export const ENGINE_LANGUAGES: Record = { 'zh', ], chatterbox_turbo: ['en'], - tada: ['en', 'ar', 'zh', 'de', 'es', 'fr', 'it', 'ja', 'pl', 'pt'], + tada: ['en', 'ar', 'zh', 'de', 'es', 'fr', 'it', 'ja', 'pl', 'pt', 'hi'], kokoro: ['en', 'es', 'fr', 'hi', 'it', 'pt', 'ja', 'zh'], - qwen_custom_voice: ['zh', 'en', 'ja', 'ko', 'de', 'fr', 'ru', 'pt', 'es', 'it'], + qwen_custom_voice: ['zh', 'en', 'ja', 'ko', 'de', 'fr', 'ru', 'pt', 'es', 'it', 'hi'], } as const; /** Helper: get language options for a given engine. */ From ead81124e9dfc9838f7f0a2f0e0a194df5c99cb8 Mon Sep 17 00:00:00 2001 From: devangkantharia Date: Sun, 9 Aug 2026 17:26:52 +0530 Subject: [PATCH 3/4] fix(ui): address CodeRabbit review feedback for accessibility, localization, and dialog cleanup --- app/src/components/MainEditor/MainEditor.tsx | 1 + app/src/components/VoiceProfiles/ProfileList.tsx | 2 +- app/src/components/VoicesTab/VoicesTab.tsx | 16 ++++++++++++++-- app/src/i18n/locales/en/translation.json | 3 ++- app/src/lib/constants/languages.ts | 6 +++--- 5 files changed, 21 insertions(+), 7 deletions(-) diff --git a/app/src/components/MainEditor/MainEditor.tsx b/app/src/components/MainEditor/MainEditor.tsx index 837f439cb..df563adee 100644 --- a/app/src/components/MainEditor/MainEditor.tsx +++ b/app/src/components/MainEditor/MainEditor.tsx @@ -118,6 +118,7 @@ export function MainEditor() {