interface TelegramWebApp { ready(): void; expand(): void; sendData(data: string): void; MainButton: { show(): void; hide(): void; }; initDataUnsafe?: { user?: { id: number; }; }; } interface WindowWithTelegram extends Window { Telegram?: { WebApp: TelegramWebApp; }; } interface VideoResult { id: string; title: string; channel: string; thumbnail: string; duration: number; url: string; } interface SearchResponse { videos: VideoResult[]; } interface ConvertResponse { audioUrl?: string; title: string; } class QuixoticApp { private tg?: TelegramWebApp; private searchInput!: HTMLInputElement; private loading!: HTMLElement; private results!: HTMLElement; private noResults!: HTMLElement; private welcomePlaceholder!: HTMLElement; private searchTimeout?: NodeJS.Timeout; private currentVideos: VideoResult[] = []; constructor() { this.tg = (window as WindowWithTelegram).Telegram?.WebApp; this.init(); this.bindEvents(); } private init(): void { if (this.tg) { this.tg.ready(); this.tg.expand(); this.tg.MainButton.hide(); // Debug Telegram user info console.log('🔧 WebApp ready, user:', this.tg.initDataUnsafe?.user?.id); } else { console.log('❌ Telegram WebApp not available'); } this.searchInput = document.getElementById('searchInput') as HTMLInputElement; this.loading = document.getElementById('loading') as HTMLElement; this.results = document.getElementById('results') as HTMLElement; this.noResults = document.getElementById('noResults') as HTMLElement; this.welcomePlaceholder = document.getElementById('welcomePlaceholder') as HTMLElement; // Initialize proper state - only welcome should be visible this.resetToWelcomeState(); } private bindEvents(): void { // Auto search with debounce timeout this.searchInput.addEventListener('input', () => { // Clear previous timeout if (this.searchTimeout) { clearTimeout(this.searchTimeout); } const query = this.searchInput.value.trim(); // If input is empty, reset to welcome state immediately if (query === '') { this.resetToWelcomeState(); return; } // Set new timeout for search (500ms delay) this.searchTimeout = setTimeout(() => { this.search(); }, 500); }); // Still handle Enter key for immediate search this.searchInput.addEventListener('keypress', (e: KeyboardEvent) => { if (e.key === 'Enter') { // Clear timeout and search immediately if (this.searchTimeout) { clearTimeout(this.searchTimeout); } this.search(); } }); } private resetToWelcomeState(): void { // Show only welcome placeholder this.welcomePlaceholder.classList.remove('tg-hidden'); this.welcomePlaceholder.style.display = ''; // Hide all other states this.loading.classList.add('tg-hidden'); this.loading.classList.remove('tg-spinner--visible'); this.results.classList.add('tg-hidden'); this.results.classList.remove('tg-list--visible'); this.noResults.classList.add('tg-hidden'); this.noResults.style.display = 'none'; } private async search(): Promise { const query = this.searchInput.value.trim(); if (!query) return; this.showLoading(); try { const response = await fetch('/api/search', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ query, userId: this.tg?.initDataUnsafe?.user?.id || 'demo' }) }); if (!response.ok) { throw new Error('Search failed'); } const data: SearchResponse = await response.json(); this.displayResults(data.videos); } catch (error) { console.error('Search error:', error); this.showNoResults(); } } private showLoading(): void { // Clear any existing status messages const existingMessage = document.querySelector('.tg-status-message'); if (existingMessage) { existingMessage.remove(); } // Hide welcome immediately when loading starts this.welcomePlaceholder.classList.add('tg-hidden'); this.welcomePlaceholder.style.display = 'none'; // Show loading spinner this.loading.classList.remove('tg-hidden'); this.loading.classList.add('tg-spinner--visible'); // Hide other elements this.results.classList.add('tg-hidden'); this.results.classList.remove('tg-list--visible'); this.noResults.classList.add('tg-hidden'); this.noResults.style.display = 'none'; } private hideLoading(): void { this.loading.classList.add('tg-hidden'); this.loading.classList.remove('tg-spinner--visible'); } private displayResults(videos: VideoResult[]): void { this.hideLoading(); if (!videos || videos.length === 0) { this.showNoResults(); return; } // Store current videos for metadata access this.currentVideos = videos; // Hide welcome and no results this.welcomePlaceholder.classList.add('tg-hidden'); this.welcomePlaceholder.style.display = 'none'; this.noResults.classList.add('tg-hidden'); this.noResults.style.display = 'none'; this.results.innerHTML = videos.map(video => `
${this.escapeHtml(video.title)}
${this.formatDuration(video.duration)}
${this.escapeHtml(video.title)}
${this.escapeHtml(video.channel)}
`).join(''); // Add touch event listeners to prevent sticky states this.results.querySelectorAll('.tg-list-item').forEach(item => { const element = item as HTMLElement; // Reset visual state on touch end element.addEventListener('touchend', () => { setTimeout(() => { element.blur(); element.style.background = ''; element.style.transform = ''; }, 100); }); // Also handle mouse leave for desktop element.addEventListener('mouseleave', () => { element.style.background = ''; element.style.transform = ''; }); }); this.results.classList.remove('tg-hidden'); this.results.classList.add('tg-list--visible'); } private showNoResults(): void { this.hideLoading(); this.welcomePlaceholder.classList.add('tg-hidden'); this.welcomePlaceholder.style.display = 'none'; this.results.classList.add('tg-hidden'); this.results.classList.remove('tg-list--visible'); this.noResults.classList.remove('tg-hidden'); this.noResults.style.display = ''; } public async convertVideo(videoId: string, title: string, url: string): Promise { console.log('🎵 Converting:', title); // Find video metadata from current search results const video = this.currentVideos.find(v => v.id.toString() === videoId); const performer = video?.channel || 'Unknown Artist'; const thumbnail = video?.thumbnail || ''; // Find the clicked element by looking for the one that contains this videoId const videoElement = document.querySelector(`[onclick*="${videoId}"]`) as HTMLElement; if (videoElement) { // Force blur to reset any active/focus states videoElement.blur(); // Remove any stuck hover/active classes on touch devices videoElement.classList.remove('tg-list-item--active'); videoElement.classList.add('tg-list-item--converting'); } try { const response = await fetch('/api/convert', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ videoId, title, url, userId: this.tg?.initDataUnsafe?.user?.id || 'demo' }) }); if (!response.ok) { throw new Error(`Conversion failed with status: ${response.status}`); } const data: ConvertResponse = await response.json(); if (data.audioUrl) { if (this.tg) { const userId = this.tg?.initDataUnsafe?.user?.id; if (!userId) { this.showMessage('❌ Ошибка: не удается определить пользователя', 'error'); return; } // Send via direct API try { const directResponse = await fetch('/api/telegram-send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId: userId, audioUrl: data.audioUrl, title: title, performer: performer, thumbnail: thumbnail }) }); if (directResponse.ok) { this.showMessage('✅ MP3 отправлен в чат!', 'success'); } else { this.showMessage('❌ Ошибка отправки в Telegram', 'error'); } } catch { this.showMessage('❌ Ошибка соединения с ботом', 'error'); } } else { // For testing in browser - download file const link = document.createElement('a'); link.href = data.audioUrl; link.download = `${title}.mp3`; document.body.appendChild(link); link.click(); document.body.removeChild(link); this.showMessage('✓ MP3 скачан!', 'success'); } } else { throw new Error('No audio URL received'); } } catch (error: any) { console.error('Conversion error:', error); // Show specific error message let errorMsg = 'Ошибка конвертации. Попробуйте другое видео.'; if (error.message.includes('No audio URL')) { errorMsg = 'Не удалось получить аудио файл.'; } else if (error.message.includes('410')) { errorMsg = 'Видео недоступно для скачивания.'; } else if (error.message.includes('403')) { errorMsg = 'Видео заблокировано для скачивания.'; } this.showMessage(`❌ ${errorMsg}`, 'error'); } finally { if (videoElement) { videoElement.classList.remove('tg-list-item--converting'); } } } private formatDuration(seconds: number): string { if (!seconds) return ''; const mins = Math.floor(seconds / 60); const secs = seconds % 60; return `${mins}:${secs.toString().padStart(2, '0')}`; } private showMessage(message: string, type: string = 'info'): void { // Remove existing message if any const existingMessage = document.querySelector('.tg-status-message'); if (existingMessage) { existingMessage.remove(); } // Create message element const messageEl = document.createElement('div'); messageEl.className = `tg-status-message tg-status-message--${type}`; messageEl.textContent = message; // Add to body (fixed position, won't affect layout) document.body.appendChild(messageEl); // Auto-remove after 5 seconds setTimeout(() => { if (messageEl.parentNode) { messageEl.remove(); } }, 5000); } private escapeHtml(text: string): string { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } } const app = new QuixoticApp(); (window as any).app = app;