Files
notes-qpid-pro/app/static/js/modules/ui.js
T

292 lines
9.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// =======================================================================
// 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, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
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)} <span class="grammar-hint-badge" title="Нажмите, чтобы показать форму" onclick="this.classList.toggle('active')"><span class="badge-label">ℹ️ форма</span><span class="badge-body">${escapeHtml(noteText)}</span></span>`;
}
// -----------------------------------------------------------------------
// 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;