From 712c25a8816f43bdcfc88b1db54af5465e561dfe Mon Sep 17 00:00:00 2001 From: Andrey Kondratev <81143241+cockroach-eater@users.noreply.github.com> Date: Mon, 10 Nov 2025 14:15:40 +0500 Subject: [PATCH] fix lint and more features! --- eslint.config.mjs | 7 +- public/index.html | 16 +++ public/script.ts | 309 +++++++++++++++++++++++++++++++++++++++++++--- public/style.css | 218 ++++++++++++++++++++++++++++++-- src/server.ts | 15 ++- src/soundcloud.ts | 5 +- 6 files changed, 534 insertions(+), 36 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 1508b68..9acb597 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -77,8 +77,11 @@ export default [ "indent": ["error", 4], "quotes": ["error", "single"], "semi": ["error", "always"], - "@typescript-eslint/no-unused-vars": "warn", + "@typescript-eslint/no-unused-vars": ["warn", { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_" + }], "no-console": "off" } } -]; \ No newline at end of file +]; diff --git a/public/index.html b/public/index.html index dac105a..daf96e7 100644 --- a/public/index.html +++ b/public/index.html @@ -68,6 +68,12 @@
+
+ + + + Обновить +
🎵
@@ -153,6 +159,16 @@
+
+
+ Недавние поиски + +
+
+ +
+
+
diff --git a/public/script.ts b/public/script.ts index 34fcbad..2eba831 100644 --- a/public/script.ts +++ b/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 = ` + + + + + ${this.escapeHtml(search)} + `; + + 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 { + 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 = ` +
+ Загрузка... + `; + + // 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 = ` +
+
+ ${this.escapeHtml(video.title)} +
${this.formatDuration(video.duration)}
+ +
+
+
${this.escapeHtml(video.title)}
+
${this.escapeHtml(video.channel)}
+
+
+
+
+
+
+
+
+ 0:00 + ${this.formatDuration(video.duration)} +
+
+
+ `; + + // 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 { diff --git a/public/style.css b/public/style.css index 6c28f39..71b8dc2 100644 --- a/public/style.css +++ b/public/style.css @@ -22,13 +22,24 @@ --tg-spacing-xl: 20px; --tg-spacing-xxl: 24px; - /* Typography */ + /* Typography - Refined type scale (Major Third - 1.25) */ --tg-font-size-xs: 12px; --tg-font-size-sm: 14px; - --tg-font-size-md: 16px; - --tg-font-size-lg: 17px; - --tg-font-size-xl: 20px; - --tg-font-size-xxl: 28px; + --tg-font-size-md: 16px; /* base */ + --tg-font-size-lg: 18px; /* 16 * 1.125 ≈ 18 */ + --tg-font-size-xl: 22px; /* 18 * 1.222 ≈ 22 */ + --tg-font-size-xxl: 28px; /* 22 * 1.273 ≈ 28 */ + + /* Font weights */ + --tg-font-weight-regular: 400; + --tg-font-weight-medium: 500; + --tg-font-weight-semibold: 600; + --tg-font-weight-bold: 700; + + /* Letter spacing for improved readability */ + --tg-letter-spacing-tight: -0.01em; + --tg-letter-spacing-normal: 0; + --tg-letter-spacing-wide: 0.01em; --tg-line-height-tight: 1.2; --tg-line-height-normal: 1.4; @@ -78,6 +89,47 @@ body { transition: none; } +/* Pull-to-refresh indicator */ +.tg-pull-indicator { + position: fixed; + top: -60px; + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + align-items: center; + gap: var(--tg-spacing-xs); + opacity: 0; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + z-index: 50; + pointer-events: none; +} + +.tg-content.tg-pulling .tg-pull-indicator { + opacity: 1; + top: 20px; +} + +.tg-pull-indicator__icon { + color: var(--tg-color-button); + transition: transform 0.3s ease; +} + +.tg-content.tg-pulling .tg-pull-indicator__icon { + animation: pullRotate 0.6s ease-in-out infinite; +} + +.tg-pull-indicator__text { + font-size: var(--tg-font-size-sm); + color: var(--tg-color-hint); + font-weight: var(--tg-font-weight-medium); +} + +@keyframes pullRotate { + 0%, 100% { transform: rotate(0deg); } + 50% { transform: rotate(180deg); } +} + /* Form components */ .tg-form { position: fixed; @@ -154,7 +206,7 @@ body { border: none; border-radius: var(--tg-border-radius); font-family: inherit; - font-weight: 500; + font-weight: var(--tg-font-weight-medium); cursor: pointer; transition: all 0.2s ease; outline: none; @@ -211,7 +263,8 @@ body { .tg-placeholder__title { font-size: var(--tg-font-size-xl); - font-weight: 600; + font-weight: var(--tg-font-weight-semibold); + letter-spacing: var(--tg-letter-spacing-tight); color: var(--tg-color-text); margin-bottom: var(--tg-spacing-sm); } @@ -258,7 +311,7 @@ body { .tg-spinner__text { font-size: var(--tg-font-size-sm); color: var(--tg-color-hint); - font-weight: 500; + font-weight: var(--tg-font-weight-medium); } @keyframes tg-spin { @@ -357,7 +410,32 @@ body { user-select: none; -webkit-tap-highlight-color: transparent; position: relative; - opacity: 1; + opacity: 0; + animation: fadeInUp 0.4s cubic-bezier(0.4, 0, 0.2, 1) forwards; +} + +/* Staggered animation delays for first 20 items */ +.tg-list-item:nth-child(1) { animation-delay: 0.05s; } +.tg-list-item:nth-child(2) { animation-delay: 0.08s; } +.tg-list-item:nth-child(3) { animation-delay: 0.11s; } +.tg-list-item:nth-child(4) { animation-delay: 0.14s; } +.tg-list-item:nth-child(5) { animation-delay: 0.17s; } +.tg-list-item:nth-child(6) { animation-delay: 0.20s; } +.tg-list-item:nth-child(7) { animation-delay: 0.23s; } +.tg-list-item:nth-child(8) { animation-delay: 0.26s; } +.tg-list-item:nth-child(9) { animation-delay: 0.29s; } +.tg-list-item:nth-child(10) { animation-delay: 0.32s; } +.tg-list-item:nth-child(n+11) { animation-delay: 0.35s; } + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } } /* Hover effects for desktop */ @@ -444,7 +522,7 @@ body { font-size: var(--tg-font-size-xs); padding: 2px 4px; border-radius: 4px; - font-weight: 500; + font-weight: var(--tg-font-weight-medium); } .tg-list-item__info { @@ -454,12 +532,14 @@ body { .tg-list-item__title { font-size: var(--tg-font-size-md); - font-weight: 500; + font-weight: var(--tg-font-weight-medium); + letter-spacing: var(--tg-letter-spacing-tight); color: var(--tg-color-text); - line-height: var(--tg-line-height-tight); + line-height: 1.3; /* Улучшенный line-height для многострочных заголовков */ margin-bottom: var(--tg-spacing-xs); display: -webkit-box; -webkit-line-clamp: 2; + line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } @@ -578,7 +658,7 @@ body { padding: var(--tg-spacing-md) var(--tg-spacing-lg); border-radius: var(--tg-border-radius); font-size: var(--tg-font-size-sm); - font-weight: 500; + font-weight: var(--tg-font-weight-medium); animation: tg-slide-down 0.3s ease-out; display: flex; align-items: center; @@ -616,6 +696,117 @@ body { } } +/* Recent Searches */ +.tg-recent-searches { + margin-bottom: var(--tg-spacing-lg); + animation: tg-fade-in 0.3s ease-out; +} + +.tg-recent-searches__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--tg-spacing-sm); + font-size: var(--tg-font-size-sm); + color: var(--tg-color-hint); + padding: 0 var(--tg-spacing-xs); +} + +.tg-recent-searches__title { + font-weight: var(--tg-font-weight-medium); +} + +.tg-recent-searches__clear { + background: none; + border: none; + color: var(--tg-color-link); + cursor: pointer; + padding: 4px 8px; + font-size: var(--tg-font-size-sm); + font-weight: var(--tg-font-weight-medium); + border-radius: 4px; + transition: background 0.2s ease; + -webkit-tap-highlight-color: transparent; +} + +.tg-recent-searches__clear:hover { + background: var(--tg-color-secondary-bg); +} + +.tg-recent-searches__clear:active { + transform: scale(0.95); +} + +.tg-recent-searches__list { + display: flex; + flex-direction: column; + gap: var(--tg-spacing-xs); +} + +.tg-recent-search-item { + display: flex; + align-items: center; + gap: var(--tg-spacing-sm); + padding: var(--tg-spacing-sm) var(--tg-spacing-md); + background: var(--tg-color-section-bg); + border: none; + border-radius: var(--tg-border-radius); + width: 100%; + text-align: left; + cursor: pointer; + transition: all 0.2s ease; + font-size: var(--tg-font-size-md); + color: var(--tg-color-text); + -webkit-tap-highlight-color: transparent; +} + +.tg-recent-search-item svg { + flex-shrink: 0; + color: var(--tg-color-hint); +} + +.tg-recent-search-item span { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +@media (hover: hover) and (pointer: fine) { + .tg-recent-search-item:hover { + background: var(--tg-color-secondary-bg); + transform: translateX(2px); + } +} + +.tg-recent-search-item:active { + background: var(--tg-color-secondary-bg); + transform: scale(0.98); +} + +/* Loading More indicator */ +.tg-loading-more { + display: flex; + align-items: center; + justify-content: center; + gap: var(--tg-spacing-sm); + padding: var(--tg-spacing-lg); + color: var(--tg-color-hint); + font-size: var(--tg-font-size-sm); +} + +.tg-loading-more .tg-spinner__icon { + width: 20px; + height: 20px; + border-width: 2px; + margin: 0; +} + +#scroll-sentinel { + height: 1px; + visibility: hidden; +} + /* Utility classes */ .tg-hidden { display: none !important; @@ -676,6 +867,7 @@ body { text-align: left; font-size: var(--tg-font-size-sm); -webkit-line-clamp: 1; + line-clamp: 1; } .tg-list-item__subtitle { diff --git a/src/server.ts b/src/server.ts index b6087ec..2f98af4 100644 --- a/src/server.ts +++ b/src/server.ts @@ -87,12 +87,17 @@ app.get('/', (req: Request, res: Response) => { // Search videos app.post('/api/search', async (req: Request, res: Response) => { try { - const { query, userId }: { query?: string; userId?: string } = req.body; + const { query, userId, page }: { query?: string; userId?: string; page?: number } = req.body; if (!query || query.trim().length === 0) { return res.status(400).json({ error: 'Query is required' }); } + // Calculate offset based on page number (10 results per page) + const currentPage = page || 1; + const resultsPerPage = 10; + const offset = (currentPage - 1) * resultsPerPage; + // Save search history if (userId && userId !== 'demo') { try { @@ -105,8 +110,12 @@ app.post('/api/search', async (req: Request, res: Response) => { } } - const videos = await soundcloud.searchTracks(query.trim()); - res.json({ videos }); + const videos = await soundcloud.searchTracks(query.trim(), resultsPerPage, offset); + + // Return hasMore flag based on results + const hasMore = videos.length === resultsPerPage; + + res.json({ videos, hasMore }); } catch (error) { logger.error('Search error:', error); diff --git a/src/soundcloud.ts b/src/soundcloud.ts index c3e4698..951a46c 100644 --- a/src/soundcloud.ts +++ b/src/soundcloud.ts @@ -67,14 +67,15 @@ export class SoundCloudService { return originalUrl; } - async searchTracks(query: string, maxResults: number = 10): Promise { + async searchTracks(query: string, maxResults: number = 10, offset: number = 0): Promise { try { - logger.soundcloud('Searching', query); + logger.soundcloud('Searching', `${query} (offset: ${offset})`); // Search for tracks on SoundCloud const searchResult = await scdl.search({ query: query, limit: maxResults, + offset: offset, resourceType: 'tracks' }) as any;