Files
VoiceStudio/frontend/src/components/MultiLangPicker.jsx
T
5c9b7ca313 chore(format): adopt oxfmt for JS/TS/JSX + CI format gate (#774)
Adds oxfmt (Rust formatter, Prettier-conformant) — the repo had no formatter, so
this is a one-time normalization of the JS/TS/JSX code (257 files; purely
cosmetic — full suite stays 638/638).

Scope is deliberately narrowed in .oxfmtrc.json to JS/TS/JSX only:
- singleQuote:true + jsxSingleQuote:false — preserve the project's existing
  style (single-quoted JS, double-quoted JSX attrs), not oxfmt's double-quote
  default. (Flipping quotes globally also broke a source-string-parsing test;
  preserving them keeps featureCoverage green.)
- Excludes **/*.css (the CSS→Tailwind migration will rewrite those — formatting
  them now is wasted churn), **/*.json (avoids reformatting 20 i18n locale
  files + config), **/*.toml, and src-tauri/** (Rust/Tauri config — out of scope
  for a frontend JS formatter; oxfmt was reformatting Cargo.toml/tauri.conf.json).

Tooling:
- `bun run format` (write) / `bun run format:check` (verify).
- ci.yml: new "Frontend format check (oxfmt)" gate after the oxlint gate.

Verified: format:check clean; oxlint 0 errors; vite build; full suite 638/638;
bun install --frozen-lockfile in sync.

Co-authored-by: mergetest <test@local>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:18:01 +05:30

166 lines
5.4 KiB
React

import React, { useState, useMemo, useRef, useEffect } from 'react';
import { X, Search, Globe, Plus } from 'lucide-react';
import { POPULAR_LANGS } from '../utils/constants';
import { LANG_CODES } from '../utils/languages';
import { useTranslation } from 'react-i18next';
import './MultiLangPicker.css';
/**
* MultiLangPicker — chip-based multi-language selector for batch dubbing.
*
* Shows selected languages as removable badges. Click "+" to open a
* searchable dropdown with Popular + All Languages sections.
*/
export default function MultiLangPicker({
selected = [], // array of { lang: string, code: string }
onChange, // (newSelected) => void
disabled = false,
}) {
const { t } = useTranslation();
const [dropOpen, setDropOpen] = useState(false);
const [query, setQuery] = useState('');
const dropRef = useRef(null);
const inputRef = useRef(null);
// Close dropdown on outside click
useEffect(() => {
if (!dropOpen) return;
const handler = (e) => {
if (dropRef.current && !dropRef.current.contains(e.target)) setDropOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, [dropOpen]);
// Focus search when dropdown opens
useEffect(() => {
if (dropOpen && inputRef.current) inputRef.current.focus();
}, [dropOpen]);
const selectedCodes = useMemo(() => new Set(selected.map((s) => s.code)), [selected]);
const addLang = (lang, code) => {
if (selectedCodes.has(code)) return;
onChange([...selected, { lang, code }]);
setQuery('');
};
const removeLang = (code) => {
onChange(selected.filter((s) => s.code !== code));
};
const filteredLangs = useMemo(() => {
const q = query.toLowerCase().trim();
return LANG_CODES.filter(
(lc) =>
!selectedCodes.has(lc.code) &&
(!q || lc.label.toLowerCase().includes(q) || lc.code.toLowerCase().includes(q)),
);
}, [query, selectedCodes]);
const popularFiltered = useMemo(() => {
const q = query.toLowerCase().trim();
return POPULAR_LANGS.map((lang) => {
const match = LANG_CODES.find((lc) => lc.label.toLowerCase() === lang.toLowerCase());
return match ? { lang, code: match.code } : null;
}).filter(
(item) =>
item &&
!selectedCodes.has(item.code) &&
(!q || item.lang.toLowerCase().includes(q) || item.code.includes(q)),
);
}, [query, selectedCodes]);
return (
<div className="multi-lang" ref={dropRef}>
<div className="multi-lang__chips">
{selected.map((s) => (
<span key={s.code} className="multi-lang__chip">
<Globe size={9} />
<span>{s.code}</span>
{!disabled && (
<button
type="button"
className="multi-lang__chip-x"
onClick={() => removeLang(s.code)}
aria-label={`Remove ${s.lang}`}
>
<X size={8} />
</button>
)}
</span>
))}
{!disabled && (
<button
type="button"
className="multi-lang__add"
onClick={() => setDropOpen(!dropOpen)}
title={t('dub.add_language')}
>
<Plus size={10} />
</button>
)}
</div>
{selected.length > 0 && (
<div className="multi-lang__summary">
{t('dub.languages_selected', { count: selected.length })}
</div>
)}
{dropOpen && (
<div className="multi-lang__drop">
<div className="multi-lang__search">
<Search size={10} />
<input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t('dub.search_languages')}
spellCheck={false}
/>
</div>
<div className="multi-lang__list">
{popularFiltered.length > 0 && (
<>
<div className="multi-lang__section">{t('dub.popular')}</div>
{popularFiltered.map((item) => (
<button
key={item.code}
type="button"
className="multi-lang__option"
onClick={() => addLang(item.lang, item.code)}
>
<span className="multi-lang__option-code">{item.code}</span>
<span>{item.lang}</span>
</button>
))}
</>
)}
<div className="multi-lang__section">{t('dub.all_languages')}</div>
{filteredLangs.slice(0, 50).map((lc) => (
<button
key={lc.code}
type="button"
className="multi-lang__option"
onClick={() => addLang(lc.label, lc.code)}
>
<span className="multi-lang__option-code">{lc.code}</span>
<span>{lc.label}</span>
</button>
))}
{filteredLangs.length > 50 && (
<div className="multi-lang__more">
{t('dub.more_to_narrow', { count: filteredLangs.length - 50 })}
</div>
)}
{filteredLangs.length === 0 && popularFiltered.length === 0 && (
<div className="multi-lang__empty">{t('dub.no_matches')}</div>
)}
</div>
</div>
)}
</div>
);
}