329 lines
11 KiB
TypeScript
329 lines
11 KiB
TypeScript
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<void> {
|
||
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 => `
|
||
<div class='tg-list-item' onclick='app.convertVideo("${video.id}", "${this.escapeHtml(video.title)}", "${this.escapeHtml(video.url)}")'>
|
||
<div class='tg-list-item__content'>
|
||
<div class='tg-list-item__media'>
|
||
<img class='tg-list-item__thumbnail'
|
||
src='${video.thumbnail}'
|
||
alt='${this.escapeHtml(video.title)}'
|
||
loading='lazy'>
|
||
<div class='tg-list-item__duration'>${this.formatDuration(video.duration)}</div>
|
||
</div>
|
||
<div class='tg-list-item__info'>
|
||
<div class='tg-list-item__title'>${this.escapeHtml(video.title)}</div>
|
||
<div class='tg-list-item__subtitle'>${this.escapeHtml(video.channel)}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`).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<void> {
|
||
console.log('Convert video called:', { videoId, title, url });
|
||
const videoElement = (event as any)?.currentTarget 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);
|
||
|
||
if (this.tg) {
|
||
// Send to Telegram chat
|
||
const payload = {
|
||
action: 'send_audio',
|
||
audioUrl: data.audioUrl,
|
||
title: title
|
||
};
|
||
console.log('📤 Sending data to Telegram:', payload);
|
||
this.tg.sendData(JSON.stringify(payload));
|
||
this.showMessage('✓ MP3 готов! Отправляем в чат...', 'success');
|
||
} 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; |