Files
quixotic/public/script.ts
Andrey Kondratev d4debf9b63 python
2025-09-09 15:39:28 +05:00

439 lines
15 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 clearButton!: 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.clearButton = document.getElementById('clearButton') 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();
// Auto-focus search input to activate keyboard
setTimeout(() => {
this.searchInput.focus();
}, 100);
}
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();
// Show/hide clear button based on input content
this.updateClearButtonVisibility();
// 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();
}
});
// Clear button functionality
this.clearButton.addEventListener('click', () => {
this.clearSearch();
});
}
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';
// Update clear button visibility
this.updateClearButtonVisibility();
}
private clearSearch(): void {
// Clear the input
this.searchInput.value = '';
// Clear any pending search timeout
if (this.searchTimeout) {
clearTimeout(this.searchTimeout);
}
// Reset to welcome state
this.resetToWelcomeState();
// Focus back to input
this.searchInput.focus();
}
private updateClearButtonVisibility(): void {
const hasText = this.searchInput.value.trim().length > 0;
this.clearButton.style.display = hasText ? 'flex' : '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');
}
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,
performer,
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;