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 @@
+
+
+
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.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;