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 searchBtn!: HTMLButtonElement; private loading!: HTMLElement; private results!: HTMLElement; private noResults!: HTMLElement; private welcomePlaceholder!: HTMLElement; 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(); } this.searchInput = document.getElementById('searchInput') as HTMLInputElement; this.searchBtn = document.getElementById('searchBtn') as HTMLButtonElement; 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 { this.searchBtn.addEventListener('click', () => this.search()); this.searchInput.addEventListener('keypress', (e: KeyboardEvent) => { if (e.key === 'Enter') { this.search(); } }); // Reset to welcome state when input is cleared this.searchInput.addEventListener('input', () => { if (this.searchInput.value.trim() === '') { this.resetToWelcomeState(); } }); } 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'; // Enable search button this.searchBtn.disabled = false; } 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'; this.searchBtn.disabled = true; } private hideLoading(): void { this.loading.classList.add('tg-hidden'); this.loading.classList.remove('tg-spinner--visible'); this.searchBtn.disabled = false; } private displayResults(videos: VideoResult[]): void { this.hideLoading(); if (!videos || videos.length === 0) { this.showNoResults(); return; } // 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(''); 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('🎵 CONVERT VIDEO CALLED:', { videoId, title, url }); console.log('🔧 Telegram WebApp available:', !!this.tg); // Find the clicked element by looking for the one that contains this videoId const videoElement = document.querySelector(`[onclick*="${videoId}"]`) as HTMLElement; if (videoElement) { videoElement.classList.add('tg-list-item--converting'); } try { console.log('Sending convert request...'); 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' }) }); console.log('Response status:', response.status); if (!response.ok) { throw new Error(`Conversion failed with status: ${response.status}`); } const data: ConvertResponse = await response.json(); console.log('Response data:', data); if (data.audioUrl) { // MP3 conversion successful! console.log('🎉 MP3 conversion successful:', data.audioUrl); console.log('🔧 About to send to Telegram, tg available:', !!this.tg); if (this.tg) { // Send to Telegram chat const payload = { action: 'send_audio', audioUrl: data.audioUrl, title: title }; console.log('📤 Sending data to Telegram via sendData:', payload); try { this.tg.sendData(JSON.stringify(payload)); console.log('✅ Data sent via Telegram.sendData'); this.showMessage('✓ MP3 готов! Отправляем в чат...', 'success'); // Fallback: also try to send via HTTP request to our server setTimeout(async () => { try { console.log('🔄 Sending fallback notification to server...'); await fetch('/api/telegram-notify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ userId: this.tg?.initDataUnsafe?.user?.id, audioUrl: data.audioUrl, title: title }) }); } catch (fallbackError) { console.log('Fallback notification failed (not critical):', fallbackError); } }, 2000); // Wait 2 seconds before fallback } catch (sendError) { console.error('❌ Failed to send via Telegram.sendData:', sendError); this.showMessage('❌ Ошибка отправки в Telegram', '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;