Files
quixotic/public/script.ts
Andrey Kondratev 82d2713d15 name fix
2025-08-29 18:26:00 +05:00

404 lines
14 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;
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.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 {
// 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<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';
}
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;
}
// 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 => `
<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('');
// 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<void> {
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;