// web/components.jsx — общие UI-примитивы, совместимы со стилями из styles.css
// (это упрощённый аналог tweaks-panel.jsx + components.jsx из дизайна).
const Icon = ({ name, size = 14, stroke = 1.6, className = "ic", style }) => {
const paths = ICONS[name];
if (!paths) return null;
return (
);
};
const ICONS = {
folder: <>>,
archive: <>>,
settings: <>>,
refresh: <>>,
play: <>>,
check: <>>,
x: <>>,
link: <>>,
alert: <>>,
loader: <>>,
image: <>>,
film: <>>,
layers: <>>,
edit: <>>,
chevronDown: <>>,
split: <>>,
merge: <>>,
inbox: <>>,
chevron: <>>,
trash: <>>,
download: <>>,
mic: <>>,
};
// Spinner — раздаётся анимированно из CSS-keyframe ниже.
const Spinner = ({ size = 14 }) => (
);
// Toast — простой alert внизу страницы; берётся из window.toast(message, kind).
const Toast = ({ toasts }) => (
{toasts.map((t, i) => (
{t.message}
))}
);
// Контекст тостов: один общий стейт + единственный рендер на корне.
// Раньше useToasts заводил локальный стейт в каждом экране — Toast в App не
// видел эти сообщения и они «уходили в воздух». Теперь useToasts возвращает
// push-функцию из контекста.
const ToastContext = React.createContext(null);
function ToastProvider({ children }) {
const [toasts, setToasts] = React.useState([]);
const show = React.useCallback((message, kind = 'info') => {
const id = Date.now() + Math.random();
setToasts((ts) => [...ts, { id, message, kind }]);
setTimeout(() => setToasts((ts) => ts.filter((t) => t.id !== id)), 5000);
}, []);
return (
{children}
);
}
// useToasts — возвращает [toasts, show]; toasts оставлен для совместимости
// с прежним API (всегда пустой — рендером занимается ToastProvider).
function useToasts() {
const show = React.useContext(ToastContext);
// Запасной вариант, если кто-то использовал хук вне Provider.
if (!show) {
return [[], (m) => console.warn("toast (no provider):", m)];
}
return [[], show];
}
// useAsync — обёртка над async-вызовом с loading/error/data состояниями.
function useAsync(fn, deps) {
const [state, setState] = React.useState({ loading: true, error: null, data: null });
const reload = React.useCallback(() => {
// Держим старые данные во время рефетча — чтобы экран не мигал пустотой
// при перезагрузке после сохранения правок (фаза 2, жалоба Лены на мигание).
setState((s) => ({ loading: true, error: null, data: s.data }));
fn()
.then((data) => setState({ loading: false, error: null, data }))
.catch((error) => setState({ loading: false, error, data: null }));
}, deps);
React.useEffect(() => { reload(); }, [reload]);
return [state, reload];
}
// PlaceholderStub — компактная плашка «функция в работе» для пустых экранов.
function PlaceholderStub({ title, body, hint }) {
return (
{title}
{body}
{hint && (
{hint}
)}
);
}
// MicButton — голосовой ввод через встроенный Web Speech API (Chrome/Edge),
// язык ru-RU. onInsert(text) вызывается на каждый ФИНАЛЬНЫЙ кусок речи —
// родитель сам дописывает его в поле (и сохраняет). Бесплатно, без ключей; в
// Chrome звук уходит на серверы Google. В Firefox API нет — кнопка прячется.
// Кнопка-тумблер: клик старт/стоп; пока слушает — оранжевая, над ней «живой»
// текст-превью того, что распознаётся (правка 2026-06-08).
function MicButton({ onInsert, size = 14, title }) {
const SR = (typeof window !== "undefined") &&
(window.SpeechRecognition || window.webkitSpeechRecognition);
const [active, setActive] = React.useState(false);
const [interim, setInterim] = React.useState("");
const recRef = React.useRef(null);
const [_, toast] = useToasts();
React.useEffect(() => () => {
if (recRef.current) { try { recRef.current.stop(); } catch (e) {} }
}, []);
if (!SR) return null; // браузер без поддержки (Firefox)
const toggle = () => {
if (active) {
if (recRef.current) { try { recRef.current.stop(); } catch (e) {} }
return;
}
let rec;
try { rec = new SR(); } catch (e) { return; }
rec.lang = "ru-RU";
rec.continuous = true;
rec.interimResults = true;
rec.onresult = (e) => {
let fin = "", inter = "";
for (let i = e.resultIndex; i < e.results.length; i++) {
const r = e.results[i];
if (r.isFinal) fin += r[0].transcript;
else inter += r[0].transcript;
}
if (fin.trim()) onInsert && onInsert(fin.trim());
setInterim(inter);
};
rec.onerror = (e) => {
const err = e && e.error;
if (err === "not-allowed" || err === "service-not-allowed") {
toast("Нет доступа к микрофону — разреши в браузере", "error");
} else if (err && err !== "no-speech" && err !== "aborted") {
toast("Распознавание речи: " + err, "warn");
}
};
rec.onend = () => { setActive(false); setInterim(""); recRef.current = null; };
recRef.current = rec;
setActive(true);
try { rec.start(); } catch (e) { setActive(false); recRef.current = null; }
};
return (
{active && (
{interim || "слушаю…"}
)}
);
}
window.PlaceholderStub = PlaceholderStub;
window.MicButton = MicButton;
window.Icon = Icon;
window.Spinner = Spinner;
window.Toast = Toast;
window.ToastProvider = ToastProvider;
window.useToasts = useToasts;
window.useAsync = useAsync;