// ======================================================================= // Notes Application - UI & Utilities Module (Native ES Module) // ======================================================================= export function escapeHtml(str) { if (str === null || str === undefined) return ''; return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } export function escapeRegex(string) { if (!string) return ''; return String(string).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } export function slugify(text) { if (!text) return 'book'; return text.toString().toLowerCase().trim() .replace(/[^\w\s-]/g, '') .replace(/[\s_-]+/g, '-') .replace(/^-+|-+$/g, ''); } export function shuffleArray(array) { if (!array || !Array.isArray(array)) return []; for (let i = array.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [array[i], array[j]] = [array[j], array[i]]; } return array; } export function cleanPromptTranslation(text) { if (!text || typeof text !== 'string') return ''; // Strip grammatical inflection notes that contain Latin words to prevent spoilers in tests return text.replace(/\s*\([а-яА-ЯёЁ0-9\s.,-]*\bот\s+[a-zA-Z\x27-]+\)/gi, '').trim(); } export function formatTranslationWithSpoiler(text) { if (!text || typeof text !== 'string') return ''; const noteMatch = text.match(/\s*\(([а-яА-ЯёЁ0-9\s.,-]*\bот\s+[a-zA-Z\x27-]+)\)/i); if (!noteMatch) return escapeHtml(text); const cleanText = text.replace(noteMatch[0], '').trim(); const noteText = noteMatch[1]; return `${escapeHtml(cleanText)} ℹ️ форма${escapeHtml(noteText)}`; } // ----------------------------------------------------------------------- // Toast Notifications // ----------------------------------------------------------------------- export function showToast(message, type = 'info') { if (!message) return; let toastContainer = document.getElementById('notes-toast-container'); if (!toastContainer) { toastContainer = document.createElement('div'); toastContainer.id = 'notes-toast-container'; toastContainer.style.cssText = ` position: fixed; bottom: 24px; right: 24px; z-index: 10000; display: flex; flex-direction: column; gap: 8px; pointer-events: none; `; if (document.body) { document.body.appendChild(toastContainer); } else { return; } } const toast = document.createElement('div'); const colors = { success: '#10b981', error: '#ef4444', warning: '#f59e0b', info: '#3b82f6' }; const bg = colors[type] || colors.info; toast.style.cssText = ` background: ${bg}; color: #ffffff; padding: 10px 18px; border-radius: 8px; font-size: 14px; font-weight: 500; box-shadow: 0 4px 14px rgba(0,0,0,0.25); opacity: 0; transform: translateY(12px); transition: all 0.25s ease; pointer-events: auto; max-width: 340px; `; toast.textContent = message; toastContainer.appendChild(toast); requestAnimationFrame(() => { toast.style.opacity = '1'; toast.style.transform = 'translateY(0)'; }); setTimeout(() => { toast.style.opacity = '0'; toast.style.transform = 'translateY(12px)'; setTimeout(() => toast.remove(), 250); }, 3500); } // ----------------------------------------------------------------------- // Theme Management // ----------------------------------------------------------------------- export function initTheme() { const themeToggle = document.getElementById('theme-toggle'); const mobileThemeBtn = document.getElementById('mobile-theme-btn'); let savedTheme = 'dark'; try { savedTheme = localStorage.getItem('notes_theme') || 'dark'; } catch (e) {} document.documentElement.setAttribute('data-theme', savedTheme); function toggle() { const current = document.documentElement.getAttribute('data-theme'); const next = current === 'dark' ? 'light' : 'dark'; document.documentElement.setAttribute('data-theme', next); try { localStorage.setItem('notes_theme', next); } catch (e) {} } if (themeToggle) { themeToggle.addEventListener('click', toggle); } if (mobileThemeBtn) { mobileThemeBtn.addEventListener('click', () => { toggle(); showToast(`Тема переключена`, 'info'); }); } } // ----------------------------------------------------------------------- // Modal Open / Close Helpers (Accepts HTMLElement or string selector/ID) // ----------------------------------------------------------------------- export function openModal(modalElem) { if (!modalElem) return; const el = typeof modalElem === 'string' ? (document.getElementById(modalElem) || document.querySelector(modalElem)) : modalElem; if (el) el.classList.remove('hidden'); } export function closeModal(modalElem) { if (!modalElem) return; const el = typeof modalElem === 'string' ? (document.getElementById(modalElem) || document.querySelector(modalElem)) : modalElem; if (el) el.classList.add('hidden'); } // ----------------------------------------------------------------------- // View Switcher Helper // ----------------------------------------------------------------------- export function switchView(targetViewId) { const allViews = [ 'dictionary-view', 'srs-view', 'reader-view', 'cefr-view', 'grammar-view', 'lesson-view', 'lesson-runner-view' ]; allViews.forEach(id => { const el = document.getElementById(id); if (el) { if (id === targetViewId) { el.classList.remove('hidden'); } else { el.classList.add('hidden'); } } }); } // ----------------------------------------------------------------------- // Header Stat Counters // ----------------------------------------------------------------------- export function updateHeaderStats(total = 0, today = 0, srsCount = 0) { const statTotal = document.getElementById('stat-total'); const statToday = document.getElementById('stat-today'); const srsBadge = document.getElementById('srs-badge'); const tabSrsBadge = document.getElementById('tab-srs-badge'); const drawerSrsBadge = document.getElementById('drawer-srs-badge'); const drawerStatTotal = document.getElementById('drawer-stat-total'); const drawerStatToday = document.getElementById('drawer-stat-today'); const drawerStatDue = document.getElementById('drawer-stat-due'); const formattedToday = today > 0 ? `+${today}` : today; if (statTotal) statTotal.textContent = total; if (statToday) statToday.textContent = formattedToday; if (drawerStatTotal) drawerStatTotal.textContent = total; if (drawerStatToday) drawerStatToday.textContent = formattedToday; if (drawerStatDue) drawerStatDue.textContent = srsCount; if (srsBadge) { srsBadge.textContent = srsCount; srsBadge.style.display = srsCount > 0 ? 'inline-block' : 'none'; } if (tabSrsBadge) { tabSrsBadge.textContent = srsCount; tabSrsBadge.classList.toggle('hidden', srsCount === 0); } if (drawerSrsBadge) { drawerSrsBadge.textContent = srsCount; drawerSrsBadge.classList.toggle('hidden', srsCount === 0); } } // ----------------------------------------------------------------------- // Audio Player Helper (Safe stub / audio stream / Piper TTS) // ----------------------------------------------------------------------- let activeAudio = null; export function playAudio(url, speed = 1.0) { if (!url) return null; try { if (activeAudio) { try { activeAudio.pause(); activeAudio.currentTime = 0; } catch (e) {} } const audio = new Audio(url); if (speed && speed !== 1.0) { audio.playbackRate = speed; } activeAudio = audio; audio.play().catch(e => console.warn('Audio playback not supported or blocked:', e)); return audio; } catch (e) { console.warn('Audio play error:', e); return null; } } export function playTextSpeech(text, lang = 'en', speed = 1.0, voice = null) { if (!text || !text.trim()) return null; const isRu = lang && lang.toLowerCase().startsWith('ru'); let savedVoice = null; try { savedVoice = isRu ? localStorage.getItem('notes_tts_voice_ru') : localStorage.getItem('notes_tts_voice_en'); } catch (e) {} const activeVoice = voice || savedVoice; let url = `/api/tts?text=${encodeURIComponent(text.trim())}&lang=${lang || 'en'}&speed=${speed}`; if (activeVoice) { url += `&voice=${encodeURIComponent(activeVoice)}`; } return playAudio(url, speed); } // ----------------------------------------------------------------------- // Unified UI Object Export (for modules importing `{ ui }` or `ui`) // ----------------------------------------------------------------------- export const ui = { escapeHtml, escapeRegex, slugify, shuffleArray, showToast, initTheme, openModal, closeModal, switchView, updateHeaderStats, playAudio, playTextSpeech, cleanPromptTranslation, formatTranslationWithSpoiler, }; export default ui;