Files
VoiceStudio/frontend/src/components/SearchableSelect.jsx
T
debpalashandClaude Opus 4.7 67328d04fe refactor: split backend into api/core/services/schemas, harden security + fd pressure, add searchable language picker, fix segment fragmentation
Backend:
- Split monolithic main.py into backend/{api/routers,core,schemas,services}
- core/db.py: allowlist-gated migrations, db_conn context manager (kills SQL injection on ALTER)
- core/tasks.py: lock-guarded listener add/remove/push, snapshot-before-iterate
- services/ffmpeg_utils.py: run_ffmpeg helper with concurrency semaphore, EAGAIN retry, guaranteed reap
- services/segmentation.py: Bengali/CJK/Arabic punctuation, ultra-short tier, stitch_adjacent_shorts,
  bounded-loop merge; public clean_up_segments API
- services/model_manager.py: robust lock.locked() handling
- api/routers/dub_core.py: job_id traversal guard, thread-safe _active_procs, timeouts on ffmpeg/demucs,
  POST /dub/cleanup-segments endpoint
- api/routers/dub_export.py: guarded SSE listener remove, ffmpeg timeouts via run_ffmpeg
- api/routers/exports.py: destination_path validation, safe source resolver, subprocess list-form
- api/routers/generation.py: contextlib.suppress on tempfile cleanup, db_conn usage, safe output-path helper
- api/routers/system.py: try/finally tmp cleanup, subprocess timeouts
- schemas/requests.py: TranslateSegment.id int->str to match hex segment IDs
- main.py: threading.Lock around crash log writes

Frontend:
- components/SearchableSelect.jsx: popover combobox with search, keyboard nav, popular+recent pins, 200-item cap
- App.jsx: wire SearchableSelect for dub language / ISO code / voice-gen language; Clean Up segments button;
  fix blob URL leak (object-shaped prev in setter, unmount cleanup via ref)
- components/WaveformTimeline.jsx: explicit <video> detach instead of innerHTML='' to release decoder
- index.css: ss-* combobox styles matching Gruvbox theme

Tests:
- tests/test_segmentation.py (26 cases), test_dub_transcribe.py, test_dub_export_unique.py, conftest.py

Chore:
- .gitignore: exclude omnivoice.zip, /research/ reference clones
- Remove tracked stray root test scripts + crash_log.txt

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 17:20:19 +05:30

215 lines
6.8 KiB
React

import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Search, ChevronDown, Check, Star, Clock } from 'lucide-react';
const MAX_DISPLAY = 200;
const readRecents = (key) => {
if (!key || typeof window === 'undefined') return [];
try {
const raw = window.localStorage.getItem(key);
return raw ? JSON.parse(raw) : [];
} catch { return []; }
};
const writeRecents = (key, list) => {
if (!key || typeof window === 'undefined') return;
try { window.localStorage.setItem(key, JSON.stringify(list.slice(0, 8))); } catch {}
};
const normalize = (s) => (s || '').toString().toLowerCase();
export default function SearchableSelect({
value,
onChange,
options,
placeholder = 'Select…',
popular = [],
recentsKey = '',
renderLabel,
renderOption,
disabled = false,
buttonStyle,
buttonClassName = 'input-base',
size = 'md',
}) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [highlight, setHighlight] = useState(0);
const [recents, setRecents] = useState(() => readRecents(recentsKey));
const wrapRef = useRef(null);
const listRef = useRef(null);
const inputRef = useRef(null);
const getVal = useCallback((o) => (typeof o === 'string' ? o : o?.value), []);
const getLabel = useCallback((o) => {
if (renderLabel) return renderLabel(o);
if (typeof o === 'string') return o;
return o?.label ?? o?.value ?? '';
}, [renderLabel]);
const byVal = useMemo(() => {
const m = new Map();
for (const o of options) m.set(getVal(o), o);
return m;
}, [options, getVal]);
const currentLabel = useMemo(() => {
const o = byVal.get(value);
return o ? getLabel(o) : (value || placeholder);
}, [byVal, value, getLabel, placeholder]);
const filtered = useMemo(() => {
const q = normalize(query);
if (!q) return options;
return options.filter(o => normalize(getLabel(o)).includes(q) || normalize(getVal(o)).includes(q));
}, [options, query, getLabel, getVal]);
const pinned = useMemo(() => {
if (query) return [];
const out = [];
const seen = new Set();
for (const v of recents) {
const o = byVal.get(v);
if (o && !seen.has(v)) { out.push({ o, kind: 'recent' }); seen.add(v); }
if (out.length >= 5) break;
}
for (const v of popular) {
if (seen.has(v)) continue;
const o = byVal.get(v);
if (o) { out.push({ o, kind: 'popular' }); seen.add(v); }
if (out.length >= 12) break;
}
return out;
}, [query, recents, popular, byVal]);
const displayed = useMemo(() => filtered.slice(0, MAX_DISPLAY), [filtered]);
const flatItems = useMemo(() => {
const list = [];
for (const p of pinned) list.push({ o: p.o, kind: p.kind });
for (const o of displayed) list.push({ o, kind: 'main' });
return list;
}, [pinned, displayed]);
useEffect(() => {
if (!open) return;
const onDoc = (e) => {
if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false);
};
document.addEventListener('mousedown', onDoc);
return () => document.removeEventListener('mousedown', onDoc);
}, [open]);
useEffect(() => {
if (open) {
setHighlight(0);
setTimeout(() => inputRef.current?.focus(), 0);
} else {
setQuery('');
}
}, [open]);
useEffect(() => {
if (!open || !listRef.current) return;
const el = listRef.current.querySelector(`[data-idx="${highlight}"]`);
if (el) el.scrollIntoView({ block: 'nearest' });
}, [highlight, open]);
const commit = (o) => {
const v = getVal(o);
onChange?.(v);
if (recentsKey) {
const next = [v, ...recents.filter(r => r !== v)].slice(0, 8);
setRecents(next);
writeRecents(recentsKey, next);
}
setOpen(false);
};
const onKey = (e) => {
if (e.key === 'ArrowDown') { e.preventDefault(); setHighlight(h => Math.min(flatItems.length - 1, h + 1)); }
else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlight(h => Math.max(0, h - 1)); }
else if (e.key === 'Enter') {
e.preventDefault();
const item = flatItems[highlight];
if (item) commit(item.o);
} else if (e.key === 'Escape') { e.preventDefault(); setOpen(false); }
};
const sizeCls = size === 'sm' ? 'ss-sm' : 'ss-md';
return (
<div ref={wrapRef} className={`ss-wrap ${sizeCls}`}>
<button
type="button"
className={`${buttonClassName} ss-trigger`}
style={buttonStyle}
onClick={() => !disabled && setOpen(o => !o)}
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={open}
title={currentLabel}
>
<span className="ss-trigger-label">{currentLabel}</span>
<ChevronDown size={12} className="ss-chev" />
</button>
{open && (
<div className="ss-pop" role="listbox">
<div className="ss-search">
<Search size={12} className="ss-search-icon" />
<input
ref={inputRef}
className="ss-search-input"
placeholder="Search…"
value={query}
onChange={e => { setQuery(e.target.value); setHighlight(0); }}
onKeyDown={onKey}
/>
</div>
<div ref={listRef} className="ss-list">
{flatItems.length === 0 && (
<div className="ss-empty">No matches</div>
)}
{pinned.length > 0 && (
<div className="ss-group-label">
{recents.length ? <><Clock size={9}/> Recent & Popular</> : <><Star size={9}/> Popular</>}
</div>
)}
{flatItems.map((it, idx) => {
const v = getVal(it.o);
const selected = v === value;
const highlighted = idx === highlight;
return (
<div
key={`${it.kind}-${v}-${idx}`}
data-idx={idx}
className={`ss-option ${highlighted ? 'ss-hl' : ''} ${selected ? 'ss-sel' : ''}`}
onMouseEnter={() => setHighlight(idx)}
onMouseDown={(e) => { e.preventDefault(); commit(it.o); }}
role="option"
aria-selected={selected}
>
{it.kind === 'recent' && <Clock size={9} className="ss-kind-icon"/>}
{it.kind === 'popular' && <Star size={9} className="ss-kind-icon"/>}
<span className="ss-option-label">
{renderOption ? renderOption(it.o) : getLabel(it.o)}
</span>
{selected && <Check size={10} className="ss-check"/>}
</div>
);
})}
{!query && filtered.length > MAX_DISPLAY && (
<div className="ss-more">Showing {MAX_DISPLAY} of {filtered.length}. Type to search</div>
)}
</div>
</div>
)}
</div>
);
}