fix lint and more features!
This commit is contained in:
@@ -77,7 +77,10 @@ export default [
|
|||||||
"indent": ["error", 4],
|
"indent": ["error", 4],
|
||||||
"quotes": ["error", "single"],
|
"quotes": ["error", "single"],
|
||||||
"semi": ["error", "always"],
|
"semi": ["error", "always"],
|
||||||
"@typescript-eslint/no-unused-vars": "warn",
|
"@typescript-eslint/no-unused-vars": ["warn", {
|
||||||
|
"argsIgnorePattern": "^_",
|
||||||
|
"varsIgnorePattern": "^_"
|
||||||
|
}],
|
||||||
"no-console": "off"
|
"no-console": "off"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,12 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="tg-root">
|
<div class="tg-root">
|
||||||
|
<div class="tg-pull-indicator" id="pullIndicator">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" class="tg-pull-indicator__icon">
|
||||||
|
<path d="M12 5v14M5 12l7 7 7-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
<span class="tg-pull-indicator__text">Обновить</span>
|
||||||
|
</div>
|
||||||
<div class="tg-content">
|
<div class="tg-content">
|
||||||
<div class="tg-placeholder" id="welcomePlaceholder">
|
<div class="tg-placeholder" id="welcomePlaceholder">
|
||||||
<div class="tg-placeholder__icon">🎵</div>
|
<div class="tg-placeholder__icon">🎵</div>
|
||||||
@@ -153,6 +159,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="tg-recent-searches tg-hidden" id="recentSearches">
|
||||||
|
<div class="tg-recent-searches__header">
|
||||||
|
<span class="tg-recent-searches__title">Недавние поиски</span>
|
||||||
|
<button class="tg-recent-searches__clear" id="clearRecentBtn" type="button">Очистить</button>
|
||||||
|
</div>
|
||||||
|
<div class="tg-recent-searches__list" id="recentSearchesList">
|
||||||
|
<!-- Recent searches will be populated here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="tg-list tg-hidden" id="results">
|
<div class="tg-list tg-hidden" id="results">
|
||||||
<!-- Search results will appear here -->
|
<!-- Search results will appear here -->
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
309
public/script.ts
309
public/script.ts
@@ -35,6 +35,7 @@ interface VideoResult {
|
|||||||
|
|
||||||
interface SearchResponse {
|
interface SearchResponse {
|
||||||
videos: VideoResult[];
|
videos: VideoResult[];
|
||||||
|
hasMore?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ConvertResponse {
|
interface ConvertResponse {
|
||||||
@@ -57,6 +58,10 @@ class QuixoticApp {
|
|||||||
private maxRecentSearches: number = 5;
|
private maxRecentSearches: number = 5;
|
||||||
private currentAudio: HTMLAudioElement | null = null;
|
private currentAudio: HTMLAudioElement | null = null;
|
||||||
private currentPlayingItem: HTMLElement | 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() {
|
constructor() {
|
||||||
this.tg = (window as WindowWithTelegram).Telegram?.WebApp;
|
this.tg = (window as WindowWithTelegram).Telegram?.WebApp;
|
||||||
@@ -68,21 +73,21 @@ class QuixoticApp {
|
|||||||
private triggerHaptic(type: 'light' | 'medium' | 'heavy' | 'success' | 'error' = 'light'): void {
|
private triggerHaptic(type: 'light' | 'medium' | 'heavy' | 'success' | 'error' = 'light'): void {
|
||||||
if (this.tg?.HapticFeedback) {
|
if (this.tg?.HapticFeedback) {
|
||||||
switch(type) {
|
switch(type) {
|
||||||
case 'light':
|
case 'light':
|
||||||
this.tg.HapticFeedback.impactOccurred('light');
|
this.tg.HapticFeedback.impactOccurred('light');
|
||||||
break;
|
break;
|
||||||
case 'medium':
|
case 'medium':
|
||||||
this.tg.HapticFeedback.impactOccurred('medium');
|
this.tg.HapticFeedback.impactOccurred('medium');
|
||||||
break;
|
break;
|
||||||
case 'heavy':
|
case 'heavy':
|
||||||
this.tg.HapticFeedback.impactOccurred('heavy');
|
this.tg.HapticFeedback.impactOccurred('heavy');
|
||||||
break;
|
break;
|
||||||
case 'success':
|
case 'success':
|
||||||
this.tg.HapticFeedback.notificationOccurred('success');
|
this.tg.HapticFeedback.notificationOccurred('success');
|
||||||
break;
|
break;
|
||||||
case 'error':
|
case 'error':
|
||||||
this.tg.HapticFeedback.notificationOccurred('error');
|
this.tg.HapticFeedback.notificationOccurred('error');
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,6 +116,55 @@ class QuixoticApp {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to save recent searches:', 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 {
|
private init(): void {
|
||||||
@@ -133,6 +187,9 @@ class QuixoticApp {
|
|||||||
|
|
||||||
// Initialize proper state - only welcome should be visible
|
// Initialize proper state - only welcome should be visible
|
||||||
this.resetToWelcomeState();
|
this.resetToWelcomeState();
|
||||||
|
|
||||||
|
// Show recent searches initially if available
|
||||||
|
this.updateRecentSearchesUI();
|
||||||
}
|
}
|
||||||
|
|
||||||
private bindEvents(): void {
|
private bindEvents(): void {
|
||||||
@@ -153,6 +210,9 @@ class QuixoticApp {
|
|||||||
clearButton?.classList.add('tg-hidden');
|
clearButton?.classList.add('tg-hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update recent searches visibility
|
||||||
|
this.updateRecentSearchesUI();
|
||||||
|
|
||||||
// If input is empty, reset to welcome state immediately
|
// If input is empty, reset to welcome state immediately
|
||||||
if (query === '') {
|
if (query === '') {
|
||||||
this.resetToWelcomeState();
|
this.resetToWelcomeState();
|
||||||
@@ -189,6 +249,13 @@ class QuixoticApp {
|
|||||||
clearButton.classList.add('tg-hidden');
|
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
|
// Setup pull-to-refresh
|
||||||
this.setupPullToRefresh();
|
this.setupPullToRefresh();
|
||||||
}
|
}
|
||||||
@@ -265,6 +332,11 @@ class QuixoticApp {
|
|||||||
const query = this.searchInput.value.trim();
|
const query = this.searchInput.value.trim();
|
||||||
if (!query) return;
|
if (!query) return;
|
||||||
|
|
||||||
|
// Reset pagination state for new search
|
||||||
|
this.currentPage = 1;
|
||||||
|
this.hasMoreResults = true;
|
||||||
|
this.isLoadingMore = false;
|
||||||
|
|
||||||
// Save to recent searches
|
// Save to recent searches
|
||||||
this.saveSearch(query);
|
this.saveSearch(query);
|
||||||
|
|
||||||
@@ -278,6 +350,7 @@ class QuixoticApp {
|
|||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
query,
|
query,
|
||||||
|
page: 1,
|
||||||
userId: this.tg?.initDataUnsafe?.user?.id || 'demo'
|
userId: this.tg?.initDataUnsafe?.user?.id || 'demo'
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
@@ -288,6 +361,9 @@ class QuixoticApp {
|
|||||||
|
|
||||||
const data: SearchResponse = await response.json();
|
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)
|
// Ensure minimum loading time for better UX (prevents flashing)
|
||||||
const elapsedTime = Date.now() - this.loadingStartTime;
|
const elapsedTime = Date.now() - this.loadingStartTime;
|
||||||
const remainingTime = Math.max(0, this.minLoadingDuration - elapsedTime);
|
const remainingTime = Math.max(0, this.minLoadingDuration - elapsedTime);
|
||||||
@@ -328,6 +404,12 @@ class QuixoticApp {
|
|||||||
this.welcomePlaceholder.classList.add('tg-hidden');
|
this.welcomePlaceholder.classList.add('tg-hidden');
|
||||||
this.welcomePlaceholder.style.display = 'none';
|
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
|
// Show skeleton screens instead of spinner for better UX
|
||||||
const skeletonList = document.getElementById('skeletonList');
|
const skeletonList = document.getElementById('skeletonList');
|
||||||
if (skeletonList) {
|
if (skeletonList) {
|
||||||
@@ -375,7 +457,7 @@ class QuixoticApp {
|
|||||||
// Use DocumentFragment for better performance
|
// Use DocumentFragment for better performance
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
|
|
||||||
videos.forEach((video, index) => {
|
videos.forEach((video) => {
|
||||||
const item = document.createElement('div');
|
const item = document.createElement('div');
|
||||||
item.className = 'tg-list-item';
|
item.className = 'tg-list-item';
|
||||||
item.dataset.videoId = video.id;
|
item.dataset.videoId = video.id;
|
||||||
@@ -475,6 +557,201 @@ class QuixoticApp {
|
|||||||
|
|
||||||
this.results.classList.remove('tg-hidden');
|
this.results.classList.remove('tg-hidden');
|
||||||
this.results.classList.add('tg-list--visible');
|
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 {
|
private showNoResults(): void {
|
||||||
|
|||||||
218
public/style.css
218
public/style.css
@@ -22,13 +22,24 @@
|
|||||||
--tg-spacing-xl: 20px;
|
--tg-spacing-xl: 20px;
|
||||||
--tg-spacing-xxl: 24px;
|
--tg-spacing-xxl: 24px;
|
||||||
|
|
||||||
/* Typography */
|
/* Typography - Refined type scale (Major Third - 1.25) */
|
||||||
--tg-font-size-xs: 12px;
|
--tg-font-size-xs: 12px;
|
||||||
--tg-font-size-sm: 14px;
|
--tg-font-size-sm: 14px;
|
||||||
--tg-font-size-md: 16px;
|
--tg-font-size-md: 16px; /* base */
|
||||||
--tg-font-size-lg: 17px;
|
--tg-font-size-lg: 18px; /* 16 * 1.125 ≈ 18 */
|
||||||
--tg-font-size-xl: 20px;
|
--tg-font-size-xl: 22px; /* 18 * 1.222 ≈ 22 */
|
||||||
--tg-font-size-xxl: 28px;
|
--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-tight: 1.2;
|
||||||
--tg-line-height-normal: 1.4;
|
--tg-line-height-normal: 1.4;
|
||||||
@@ -78,6 +89,47 @@ body {
|
|||||||
transition: none;
|
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 */
|
/* Form components */
|
||||||
.tg-form {
|
.tg-form {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -154,7 +206,7 @@ body {
|
|||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--tg-border-radius);
|
border-radius: var(--tg-border-radius);
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-weight: 500;
|
font-weight: var(--tg-font-weight-medium);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
outline: none;
|
outline: none;
|
||||||
@@ -211,7 +263,8 @@ body {
|
|||||||
|
|
||||||
.tg-placeholder__title {
|
.tg-placeholder__title {
|
||||||
font-size: var(--tg-font-size-xl);
|
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);
|
color: var(--tg-color-text);
|
||||||
margin-bottom: var(--tg-spacing-sm);
|
margin-bottom: var(--tg-spacing-sm);
|
||||||
}
|
}
|
||||||
@@ -258,7 +311,7 @@ body {
|
|||||||
.tg-spinner__text {
|
.tg-spinner__text {
|
||||||
font-size: var(--tg-font-size-sm);
|
font-size: var(--tg-font-size-sm);
|
||||||
color: var(--tg-color-hint);
|
color: var(--tg-color-hint);
|
||||||
font-weight: 500;
|
font-weight: var(--tg-font-weight-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes tg-spin {
|
@keyframes tg-spin {
|
||||||
@@ -357,7 +410,32 @@ body {
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
position: relative;
|
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 */
|
/* Hover effects for desktop */
|
||||||
@@ -444,7 +522,7 @@ body {
|
|||||||
font-size: var(--tg-font-size-xs);
|
font-size: var(--tg-font-size-xs);
|
||||||
padding: 2px 4px;
|
padding: 2px 4px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
font-weight: 500;
|
font-weight: var(--tg-font-weight-medium);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tg-list-item__info {
|
.tg-list-item__info {
|
||||||
@@ -454,12 +532,14 @@ body {
|
|||||||
|
|
||||||
.tg-list-item__title {
|
.tg-list-item__title {
|
||||||
font-size: var(--tg-font-size-md);
|
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);
|
color: var(--tg-color-text);
|
||||||
line-height: var(--tg-line-height-tight);
|
line-height: 1.3; /* Улучшенный line-height для многострочных заголовков */
|
||||||
margin-bottom: var(--tg-spacing-xs);
|
margin-bottom: var(--tg-spacing-xs);
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 2;
|
-webkit-line-clamp: 2;
|
||||||
|
line-clamp: 2;
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -578,7 +658,7 @@ body {
|
|||||||
padding: var(--tg-spacing-md) var(--tg-spacing-lg);
|
padding: var(--tg-spacing-md) var(--tg-spacing-lg);
|
||||||
border-radius: var(--tg-border-radius);
|
border-radius: var(--tg-border-radius);
|
||||||
font-size: var(--tg-font-size-sm);
|
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;
|
animation: tg-slide-down 0.3s ease-out;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
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 */
|
/* Utility classes */
|
||||||
.tg-hidden {
|
.tg-hidden {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
@@ -676,6 +867,7 @@ body {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
font-size: var(--tg-font-size-sm);
|
font-size: var(--tg-font-size-sm);
|
||||||
-webkit-line-clamp: 1;
|
-webkit-line-clamp: 1;
|
||||||
|
line-clamp: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tg-list-item__subtitle {
|
.tg-list-item__subtitle {
|
||||||
|
|||||||
@@ -87,12 +87,17 @@ app.get('/', (req: Request, res: Response) => {
|
|||||||
// Search videos
|
// Search videos
|
||||||
app.post('/api/search', async (req: Request, res: Response) => {
|
app.post('/api/search', async (req: Request, res: Response) => {
|
||||||
try {
|
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) {
|
if (!query || query.trim().length === 0) {
|
||||||
return res.status(400).json({ error: 'Query is required' });
|
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
|
// Save search history
|
||||||
if (userId && userId !== 'demo') {
|
if (userId && userId !== 'demo') {
|
||||||
try {
|
try {
|
||||||
@@ -105,8 +110,12 @@ app.post('/api/search', async (req: Request, res: Response) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const videos = await soundcloud.searchTracks(query.trim());
|
const videos = await soundcloud.searchTracks(query.trim(), resultsPerPage, offset);
|
||||||
res.json({ videos });
|
|
||||||
|
// Return hasMore flag based on results
|
||||||
|
const hasMore = videos.length === resultsPerPage;
|
||||||
|
|
||||||
|
res.json({ videos, hasMore });
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Search error:', error);
|
logger.error('Search error:', error);
|
||||||
|
|||||||
@@ -67,14 +67,15 @@ export class SoundCloudService {
|
|||||||
return originalUrl;
|
return originalUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
async searchTracks(query: string, maxResults: number = 10): Promise<TrackResult[]> {
|
async searchTracks(query: string, maxResults: number = 10, offset: number = 0): Promise<TrackResult[]> {
|
||||||
try {
|
try {
|
||||||
logger.soundcloud('Searching', query);
|
logger.soundcloud('Searching', `${query} (offset: ${offset})`);
|
||||||
|
|
||||||
// Search for tracks on SoundCloud
|
// Search for tracks on SoundCloud
|
||||||
const searchResult = await scdl.search({
|
const searchResult = await scdl.search({
|
||||||
query: query,
|
query: query,
|
||||||
limit: maxResults,
|
limit: maxResults,
|
||||||
|
offset: offset,
|
||||||
resourceType: 'tracks'
|
resourceType: 'tracks'
|
||||||
}) as any;
|
}) as any;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user