fix lint and more features!
This commit is contained in:
309
public/script.ts
309
public/script.ts
@@ -35,6 +35,7 @@ interface VideoResult {
|
||||
|
||||
interface SearchResponse {
|
||||
videos: VideoResult[];
|
||||
hasMore?: boolean;
|
||||
}
|
||||
|
||||
interface ConvertResponse {
|
||||
@@ -57,6 +58,10 @@ class QuixoticApp {
|
||||
private maxRecentSearches: number = 5;
|
||||
private currentAudio: HTMLAudioElement | null = null;
|
||||
private currentPlayingItem: HTMLElement | null = null;
|
||||
private currentPage: number = 1;
|
||||
private hasMoreResults: boolean = false;
|
||||
private isLoadingMore: boolean = false;
|
||||
private scrollObserver: IntersectionObserver | null = null;
|
||||
|
||||
constructor() {
|
||||
this.tg = (window as WindowWithTelegram).Telegram?.WebApp;
|
||||
@@ -68,21 +73,21 @@ class QuixoticApp {
|
||||
private triggerHaptic(type: 'light' | 'medium' | 'heavy' | 'success' | 'error' = 'light'): void {
|
||||
if (this.tg?.HapticFeedback) {
|
||||
switch(type) {
|
||||
case 'light':
|
||||
this.tg.HapticFeedback.impactOccurred('light');
|
||||
break;
|
||||
case 'medium':
|
||||
this.tg.HapticFeedback.impactOccurred('medium');
|
||||
break;
|
||||
case 'heavy':
|
||||
this.tg.HapticFeedback.impactOccurred('heavy');
|
||||
break;
|
||||
case 'success':
|
||||
this.tg.HapticFeedback.notificationOccurred('success');
|
||||
break;
|
||||
case 'error':
|
||||
this.tg.HapticFeedback.notificationOccurred('error');
|
||||
break;
|
||||
case 'light':
|
||||
this.tg.HapticFeedback.impactOccurred('light');
|
||||
break;
|
||||
case 'medium':
|
||||
this.tg.HapticFeedback.impactOccurred('medium');
|
||||
break;
|
||||
case 'heavy':
|
||||
this.tg.HapticFeedback.impactOccurred('heavy');
|
||||
break;
|
||||
case 'success':
|
||||
this.tg.HapticFeedback.notificationOccurred('success');
|
||||
break;
|
||||
case 'error':
|
||||
this.tg.HapticFeedback.notificationOccurred('error');
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,6 +116,55 @@ class QuixoticApp {
|
||||
} catch (e) {
|
||||
console.error('Failed to save recent searches:', e);
|
||||
}
|
||||
// Update UI
|
||||
this.updateRecentSearchesUI();
|
||||
}
|
||||
|
||||
private updateRecentSearchesUI(): void {
|
||||
const container = document.getElementById('recentSearches');
|
||||
const list = document.getElementById('recentSearchesList');
|
||||
|
||||
if (!container || !list) return;
|
||||
|
||||
// Hide if no searches or if we have a current query
|
||||
if (this.recentSearches.length === 0 || this.searchInput.value.trim() !== '') {
|
||||
container.classList.add('tg-hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show and populate
|
||||
container.classList.remove('tg-hidden');
|
||||
list.innerHTML = '';
|
||||
|
||||
this.recentSearches.forEach(search => {
|
||||
const button = document.createElement('button');
|
||||
button.className = 'tg-recent-search-item';
|
||||
button.innerHTML = `
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.5" fill="none"/>
|
||||
<path d="M8 4v4l3 2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span>${this.escapeHtml(search)}</span>
|
||||
`;
|
||||
|
||||
button.addEventListener('click', () => {
|
||||
this.triggerHaptic('light');
|
||||
this.searchInput.value = search;
|
||||
this.search();
|
||||
});
|
||||
|
||||
list.appendChild(button);
|
||||
});
|
||||
}
|
||||
|
||||
private clearRecentSearches(): void {
|
||||
this.recentSearches = [];
|
||||
try {
|
||||
localStorage.removeItem('recentSearches');
|
||||
} catch (e) {
|
||||
console.error('Failed to clear recent searches:', e);
|
||||
}
|
||||
this.updateRecentSearchesUI();
|
||||
}
|
||||
|
||||
private init(): void {
|
||||
@@ -133,6 +187,9 @@ class QuixoticApp {
|
||||
|
||||
// Initialize proper state - only welcome should be visible
|
||||
this.resetToWelcomeState();
|
||||
|
||||
// Show recent searches initially if available
|
||||
this.updateRecentSearchesUI();
|
||||
}
|
||||
|
||||
private bindEvents(): void {
|
||||
@@ -153,6 +210,9 @@ class QuixoticApp {
|
||||
clearButton?.classList.add('tg-hidden');
|
||||
}
|
||||
|
||||
// Update recent searches visibility
|
||||
this.updateRecentSearchesUI();
|
||||
|
||||
// If input is empty, reset to welcome state immediately
|
||||
if (query === '') {
|
||||
this.resetToWelcomeState();
|
||||
@@ -189,6 +249,13 @@ class QuixoticApp {
|
||||
clearButton.classList.add('tg-hidden');
|
||||
});
|
||||
|
||||
// Clear recent searches button
|
||||
const clearRecentBtn = document.getElementById('clearRecentBtn');
|
||||
clearRecentBtn?.addEventListener('click', () => {
|
||||
this.triggerHaptic('light');
|
||||
this.clearRecentSearches();
|
||||
});
|
||||
|
||||
// Setup pull-to-refresh
|
||||
this.setupPullToRefresh();
|
||||
}
|
||||
@@ -265,6 +332,11 @@ class QuixoticApp {
|
||||
const query = this.searchInput.value.trim();
|
||||
if (!query) return;
|
||||
|
||||
// Reset pagination state for new search
|
||||
this.currentPage = 1;
|
||||
this.hasMoreResults = true;
|
||||
this.isLoadingMore = false;
|
||||
|
||||
// Save to recent searches
|
||||
this.saveSearch(query);
|
||||
|
||||
@@ -278,6 +350,7 @@ class QuixoticApp {
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
page: 1,
|
||||
userId: this.tg?.initDataUnsafe?.user?.id || 'demo'
|
||||
})
|
||||
});
|
||||
@@ -288,6 +361,9 @@ class QuixoticApp {
|
||||
|
||||
const data: SearchResponse = await response.json();
|
||||
|
||||
// Set hasMoreResults based on API response
|
||||
this.hasMoreResults = data.hasMore !== false;
|
||||
|
||||
// Ensure minimum loading time for better UX (prevents flashing)
|
||||
const elapsedTime = Date.now() - this.loadingStartTime;
|
||||
const remainingTime = Math.max(0, this.minLoadingDuration - elapsedTime);
|
||||
@@ -328,6 +404,12 @@ class QuixoticApp {
|
||||
this.welcomePlaceholder.classList.add('tg-hidden');
|
||||
this.welcomePlaceholder.style.display = 'none';
|
||||
|
||||
// Hide recent searches
|
||||
const recentSearches = document.getElementById('recentSearches');
|
||||
if (recentSearches) {
|
||||
recentSearches.classList.add('tg-hidden');
|
||||
}
|
||||
|
||||
// Show skeleton screens instead of spinner for better UX
|
||||
const skeletonList = document.getElementById('skeletonList');
|
||||
if (skeletonList) {
|
||||
@@ -375,7 +457,7 @@ class QuixoticApp {
|
||||
// Use DocumentFragment for better performance
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
videos.forEach((video, index) => {
|
||||
videos.forEach((video) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'tg-list-item';
|
||||
item.dataset.videoId = video.id;
|
||||
@@ -475,6 +557,201 @@ class QuixoticApp {
|
||||
|
||||
this.results.classList.remove('tg-hidden');
|
||||
this.results.classList.add('tg-list--visible');
|
||||
|
||||
// Setup infinite scroll observer
|
||||
this.setupInfiniteScroll();
|
||||
}
|
||||
|
||||
private setupInfiniteScroll(): void {
|
||||
// Remove existing sentinel if any
|
||||
const existingSentinel = document.getElementById('scroll-sentinel');
|
||||
if (existingSentinel) {
|
||||
existingSentinel.remove();
|
||||
}
|
||||
|
||||
// Disconnect existing observer
|
||||
if (this.scrollObserver) {
|
||||
this.scrollObserver.disconnect();
|
||||
}
|
||||
|
||||
// Create and append sentinel element
|
||||
const sentinel = document.createElement('div');
|
||||
sentinel.id = 'scroll-sentinel';
|
||||
this.results.appendChild(sentinel);
|
||||
|
||||
// Create intersection observer
|
||||
this.scrollObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting && !this.isLoadingMore && this.hasMoreResults) {
|
||||
this.loadMoreResults();
|
||||
}
|
||||
});
|
||||
}, { rootMargin: '100px' });
|
||||
|
||||
this.scrollObserver.observe(sentinel);
|
||||
}
|
||||
|
||||
private async loadMoreResults(): Promise<void> {
|
||||
if (this.isLoadingMore || !this.hasMoreResults) return;
|
||||
|
||||
this.isLoadingMore = true;
|
||||
this.currentPage++;
|
||||
|
||||
// Show loading indicator at bottom
|
||||
const loadingIndicator = document.createElement('div');
|
||||
loadingIndicator.className = 'tg-loading-more';
|
||||
loadingIndicator.id = 'loading-more-indicator';
|
||||
loadingIndicator.innerHTML = `
|
||||
<div class="tg-spinner__icon"></div>
|
||||
<span>Загрузка...</span>
|
||||
`;
|
||||
|
||||
// Insert before sentinel
|
||||
const sentinel = document.getElementById('scroll-sentinel');
|
||||
if (sentinel) {
|
||||
this.results.insertBefore(loadingIndicator, sentinel);
|
||||
}
|
||||
|
||||
try {
|
||||
const query = this.searchInput.value.trim();
|
||||
const response = await fetch('/api/search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
page: this.currentPage,
|
||||
userId: this.tg?.initDataUnsafe?.user?.id || 'demo'
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Load more failed');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this.hasMoreResults = data.hasMore !== false && data.videos && data.videos.length > 0;
|
||||
|
||||
if (data.videos && data.videos.length > 0) {
|
||||
this.appendResults(data.videos);
|
||||
this.triggerHaptic('light');
|
||||
} else {
|
||||
this.hasMoreResults = false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Load more error:', error);
|
||||
this.hasMoreResults = false;
|
||||
} finally {
|
||||
loadingIndicator.remove();
|
||||
this.isLoadingMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
private appendResults(videos: VideoResult[]): void {
|
||||
// Add new videos to current list
|
||||
this.currentVideos.push(...videos);
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
const sentinel = document.getElementById('scroll-sentinel');
|
||||
|
||||
videos.forEach((video) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'tg-list-item';
|
||||
item.dataset.videoId = video.id;
|
||||
|
||||
item.innerHTML = `
|
||||
<div class='tg-list-item__content'>
|
||||
<div class='tg-list-item__media'>
|
||||
<img class='tg-list-item__thumbnail'
|
||||
src='data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" width="80" height="60"%3E%3Crect fill="%23f1f1f1" width="80" height="60"/%3E%3C/svg%3E'
|
||||
data-src='${video.thumbnail}'
|
||||
alt='${this.escapeHtml(video.title)}'
|
||||
loading='lazy'>
|
||||
<div class='tg-list-item__duration'>${this.formatDuration(video.duration)}</div>
|
||||
<button class='tg-list-item__play-btn' data-video-id='${video.id}' aria-label='Play preview'>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" fill="rgba(0,0,0,0.6)"/>
|
||||
<path d="M10 8l6 4-6 4V8z" fill="white"/>
|
||||
</svg>
|
||||
</button>
|
||||
</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 class='tg-audio-player tg-hidden' data-video-id='${video.id}'>
|
||||
<div class='tg-audio-player__progress'>
|
||||
<div class='tg-audio-player__progress-bar'>
|
||||
<div class='tg-audio-player__progress-fill'></div>
|
||||
</div>
|
||||
<div class='tg-audio-player__time'>
|
||||
<span class='tg-audio-player__current'>0:00</span>
|
||||
<span class='tg-audio-player__duration'>${this.formatDuration(video.duration)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Handle play button click
|
||||
const playBtn = item.querySelector('.tg-list-item__play-btn') as HTMLElement;
|
||||
playBtn?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.triggerHaptic('light');
|
||||
this.toggleAudioPreview(video, item);
|
||||
});
|
||||
|
||||
// Use event listener for download
|
||||
item.addEventListener('click', (e) => {
|
||||
if ((e.target as HTMLElement).closest('.tg-list-item__play-btn')) {
|
||||
return;
|
||||
}
|
||||
this.triggerHaptic('medium');
|
||||
this.convertVideo(video.id, video.title, video.url);
|
||||
});
|
||||
|
||||
// Reset visual state on touch end
|
||||
item.addEventListener('touchend', () => {
|
||||
setTimeout(() => {
|
||||
item.blur();
|
||||
item.style.background = '';
|
||||
item.style.transform = '';
|
||||
}, 100);
|
||||
}, { passive: true });
|
||||
|
||||
// Also handle mouse leave for desktop
|
||||
item.addEventListener('mouseleave', () => {
|
||||
item.style.background = '';
|
||||
item.style.transform = '';
|
||||
});
|
||||
|
||||
fragment.appendChild(item);
|
||||
});
|
||||
|
||||
// Insert before sentinel
|
||||
if (sentinel) {
|
||||
this.results.insertBefore(fragment, sentinel);
|
||||
} else {
|
||||
this.results.appendChild(fragment);
|
||||
}
|
||||
|
||||
// Lazy load new images
|
||||
const imageObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const img = entry.target as HTMLImageElement;
|
||||
const src = img.getAttribute('data-src');
|
||||
if (src) {
|
||||
img.src = src;
|
||||
img.removeAttribute('data-src');
|
||||
imageObserver.unobserve(img);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, { rootMargin: '50px' });
|
||||
|
||||
this.results.querySelectorAll('img[data-src]').forEach(img => {
|
||||
imageObserver.observe(img);
|
||||
});
|
||||
}
|
||||
|
||||
private showNoResults(): void {
|
||||
|
||||
Reference in New Issue
Block a user