Files
quixotic/public/script.ts
Andrey Kondratev 3dee6021fc fixes
2025-08-28 17:49:08 +05:00

275 lines
9.2 KiB
TypeScript
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.
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;
}
private bindEvents(): void {
this.searchBtn.addEventListener('click', () => this.search());
this.searchInput.addEventListener('keypress', (e: KeyboardEvent) => {
if (e.key === 'Enter') {
this.search();
}
});
}
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 {
this.welcomePlaceholder.classList.add('tg-hidden');
this.loading.classList.remove('tg-hidden');
this.loading.classList.add('tg-spinner--visible');
this.results.classList.remove('tg-list--visible');
this.noResults.classList.add('tg-hidden');
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;
}
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.add('tg-list--visible');
this.noResults.classList.add('tg-hidden');
}
private showNoResults(): void {
this.hideLoading();
this.results.classList.remove('tg-list--visible');
this.noResults.classList.remove('tg-hidden');
}
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
this.tg.sendData(JSON.stringify({
action: 'send_audio',
audioUrl: data.audioUrl,
title: title
}));
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;