// 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;