feat: Enhance search history dropdown with absolute positioning and empty state styling; improve video player autoplay functionality

This commit is contained in:
kuekhaoyang
2025-11-19 12:22:20 +08:00
parent fad6041df0
commit 3a756dd06f
10 changed files with 187 additions and 159 deletions
+13 -3
View File
@@ -585,10 +585,10 @@ nav {
SEARCH HISTORY DROPDOWN - LIQUID GLASS
=========================================== */
/* Search History Dropdown - Fixed positioning, Liquid Glass effect */
/* Search History Dropdown - Absolute positioning under search bar, Liquid Glass effect */
.search-history-dropdown {
position: fixed;
z-index: 9999;
/* Removed position: fixed - now using absolute from component */
max-height: 400px;
overflow-y: auto;
/* Liquid Glass effect - Core aesthetic */
@@ -749,6 +749,16 @@ nav {
background: color-mix(in srgb, var(--accent-color) 50%, transparent);
}
/* Empty state styling */
.search-history-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
text-align: center;
}
/* Accessibility - Focus visible states */
.search-history-item:focus-visible {
outline: 2px solid var(--accent-color);
+11 -3
View File
@@ -105,9 +105,14 @@ function HomePage() {
return (
<div className="min-h-screen">
{/* Glass Navbar */}
<nav className="sticky top-0 z-50 pt-4 pb-2">
<nav className="sticky top-0 z-50 pt-4 pb-2" style={{
transform: 'translate3d(0, 0, 0)',
willChange: 'transform'
}}>
<div className="max-w-7xl mx-auto px-4">
<div className="bg-[var(--glass-bg)] backdrop-blur-[12px] saturate-[120%] [-webkit-backdrop-filter:blur(12px)_saturate(120%)] border border-[var(--glass-border)] shadow-[var(--shadow-md)] px-6 py-4 rounded-[var(--radius-2xl)]">
<div className="bg-[var(--glass-bg)] backdrop-blur-[12px] saturate-[120%] [-webkit-backdrop-filter:blur(12px)_saturate(120%)] border border-[var(--glass-border)] shadow-[var(--shadow-md)] px-6 py-4 rounded-[var(--radius-2xl)]" style={{
transform: 'translate3d(0, 0, 0)'
}}>
<div className="flex items-center justify-between">
<Link
href="/"
@@ -147,7 +152,10 @@ function HomePage() {
</nav>
{/* Search Form - Separate from navbar */}
<div className="max-w-7xl mx-auto px-4 mt-6 mb-8">
<div className="max-w-7xl mx-auto px-4 mt-6 mb-8 relative" style={{
transform: 'translate3d(0, 0, 0)',
zIndex: 1000
}}>
<SearchForm
onSearch={handleSearch}
onClear={handleReset}
+6 -2
View File
@@ -105,7 +105,8 @@ export function WatchHistorySidebar() {
aria-labelledby="history-sidebar-title"
aria-hidden={!isOpen}
style={{
transform: isOpen ? 'translateX(0)' : 'translateX(100%)'
transform: isOpen ? 'translate3d(0, 0, 0)' : 'translate3d(100%, 0, 0)',
willChange: isOpen ? 'transform' : 'auto'
}}
className={`fixed top-0 right-0 bottom-0 w-[85%] sm:w-[90%] max-w-[420px] z-[2000] bg-[var(--glass-bg)] backdrop-blur-[12px] saturate-[120%] border-l border-[var(--glass-border)] rounded-tl-[var(--radius-2xl)] rounded-bl-[var(--radius-2xl)] p-6 flex flex-col shadow-[0_8px_32px_rgba(0,0,0,0.2)] transition-transform duration-250 ease-out`}
>
@@ -130,7 +131,10 @@ export function WatchHistorySidebar() {
</header>
{/* Content */}
<div className="flex-1 overflow-y-auto -mx-2 px-2">
<div className="flex-1 overflow-y-auto -mx-2 px-2" style={{
transform: 'translate3d(0, 0, 0)',
WebkitOverflowScrolling: 'touch'
}}>
{viewingHistory.length === 0 ? (
<HistoryEmptyState />
) : (
+6
View File
@@ -147,6 +147,12 @@ export function DesktopVideoPlayer({
if (initialTime > 0) {
videoRef.current.currentTime = initialTime;
}
// Auto-play the video when loaded
videoRef.current.play().catch(err => {
console.warn('Autoplay was prevented:', err);
// Autoplay may be blocked by browser policy, which is fine
});
};
const handleVideoError = () => {
+8 -4
View File
@@ -262,10 +262,8 @@ export function MobileVideoPlayer({
} catch (error) {
console.warn('Play/pause error:', error);
} finally {
// Small delay to prevent rapid toggling
setTimeout(() => {
isTogglingRef.current = false;
}, 100);
// Immediately release the toggle lock
isTogglingRef.current = false;
}
};
@@ -290,6 +288,12 @@ export function MobileVideoPlayer({
if (initialTime > 0) {
videoRef.current.currentTime = initialTime;
}
// Auto-play the video when loaded
videoRef.current.play().catch(err => {
console.warn('Autoplay was prevented:', err);
// Show a toast or indication that user needs to tap to play
});
};
const handleVideoError = () => {
+17 -6
View File
@@ -46,6 +46,10 @@ export function SearchForm({
} = useSearchHistory((selectedQuery) => {
setQuery(selectedQuery);
onSearch(selectedQuery);
// Blur the input after selecting from history
setTimeout(() => {
inputRef.current?.blur();
}, 100);
});
// Update query when initialQuery changes
@@ -60,6 +64,8 @@ export function SearchForm({
addSearch(query.trim());
onSearch(query);
hideDropdown();
// Blur the input to remove focus
inputRef.current?.blur();
}
};
@@ -72,12 +78,17 @@ export function SearchForm({
};
const handleInputFocus = () => {
if (query.trim() === '') {
showDropdown();
}
// Always show dropdown when focused, regardless of content
showDropdown();
};
const handleInputBlur = () => {
const handleInputBlur = (e: React.FocusEvent<HTMLInputElement>) => {
// Check if the new focus target is within the dropdown
const relatedTarget = e.relatedTarget as HTMLElement;
if (relatedTarget && relatedTarget.closest('.search-history-dropdown')) {
// Don't hide dropdown if focus moved to dropdown
return;
}
hideDropdown();
};
@@ -108,7 +119,7 @@ export function SearchForm({
return (
<form onSubmit={handleSubmit} className="max-w-3xl mx-auto">
<div className="relative group">
<div className="relative group" style={{ isolation: 'isolate' }}>
<Input
ref={inputRef}
type="text"
@@ -125,7 +136,7 @@ export function SearchForm({
aria-autocomplete="list"
/>
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
<div className="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1 z-10">
{query && (
<button
type="button"
+79 -94
View File
@@ -31,41 +31,6 @@ export function SearchHistoryDropdown({
}: SearchHistoryDropdownProps) {
const dropdownRef = useRef<HTMLDivElement>(null);
// Position dropdown below input field
useEffect(() => {
if (!isOpen || !triggerRef.current || !dropdownRef.current) return;
const updatePosition = () => {
if (!triggerRef.current || !dropdownRef.current) return;
const inputRect = triggerRef.current.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const dropdownMaxHeight = 400;
const spaceBelow = viewportHeight - inputRect.bottom;
// Position below input
dropdownRef.current.style.top = `${inputRect.bottom + 8}px`;
dropdownRef.current.style.left = `${inputRect.left}px`;
dropdownRef.current.style.width = `${inputRect.width}px`;
// Adjust max height if not enough space
if (spaceBelow < dropdownMaxHeight) {
dropdownRef.current.style.maxHeight = `${spaceBelow - 20}px`;
} else {
dropdownRef.current.style.maxHeight = `${dropdownMaxHeight}px`;
}
};
updatePosition();
window.addEventListener('resize', updatePosition);
window.addEventListener('scroll', updatePosition, { passive: true });
return () => {
window.removeEventListener('resize', updatePosition);
window.removeEventListener('scroll', updatePosition);
};
}, [isOpen, triggerRef]);
// Scroll highlighted item into view
useEffect(() => {
if (highlightedIndex === -1 || !dropdownRef.current) return;
@@ -82,85 +47,105 @@ export function SearchHistoryDropdown({
}
}, [highlightedIndex]);
if (!isOpen || searchHistory.length === 0) {
if (!isOpen) {
return null;
}
return (
<div
ref={dropdownRef}
className="search-history-dropdown"
className="search-history-dropdown absolute top-full left-0 right-0 mt-2 z-[9999]"
role="listbox"
aria-label="搜索历史"
onMouseDown={(e) => {
// Prevent blur when clicking inside dropdown
e.preventDefault();
}}
>
{/* Header with clear all button */}
<div className="search-history-header">
<div className="flex items-center gap-2">
<Icons.Clock size={16} className="text-[var(--text-color-secondary)]" />
<span className="text-sm font-medium text-[var(--text-color-secondary)]">
</span>
{searchHistory.length === 0 ? (
// Empty state
<div className="search-history-empty">
<Icons.Clock size={32} className="text-[var(--text-color-secondary)] mx-auto mb-2 opacity-50" />
<span className="text-sm text-[var(--text-color-secondary)]"></span>
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onClearAll();
}}
className="text-xs text-[var(--accent-color)] hover:underline transition-all"
aria-label="清除所有历史"
>
</button>
</div>
{/* Divider */}
<div className="search-history-divider" />
{/* History items */}
<div className="search-history-list">
{searchHistory.map((item, index) => (
<div
key={`${item.query}-${item.timestamp}`}
data-index={index}
role="option"
aria-selected={index === highlightedIndex}
className={`search-history-item ${
index === highlightedIndex ? 'highlighted' : ''
}`}
onClick={() => onSelectItem(item.query)}
onMouseEnter={() => {
// Visual feedback on hover
}}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<Icons.Search
size={16}
className="flex-shrink-0 text-[var(--text-color-secondary)]"
/>
<span className="text-[var(--text-color)] truncate flex-1">
{item.query}
) : (
<>
{/* Header with clear all button */}
<div className="search-history-header">
<div className="flex items-center gap-2">
<Icons.Clock size={16} className="text-[var(--text-color-secondary)]" />
<span className="text-sm font-medium text-[var(--text-color-secondary)]">
</span>
{item.resultCount !== undefined && (
<span className="text-xs text-[var(--text-color-secondary)] flex-shrink-0">
{item.resultCount}
</span>
)}
</div>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemoveItem(item.query);
onClearAll();
}}
className="search-history-remove"
aria-label={`删除 "${item.query}"`}
className="text-xs text-[var(--accent-color)] hover:underline transition-all"
aria-label="清除所有历史"
>
<Icons.X size={14} />
</button>
</div>
))}
</div>
{/* Divider */}
<div className="search-history-divider" />
{/* History items */}
<div className="search-history-list">
{searchHistory.map((item, index) => (
<div
key={`${item.query}-${item.timestamp}`}
data-index={index}
role="option"
aria-selected={index === highlightedIndex}
className={`search-history-item ${
index === highlightedIndex ? 'highlighted' : ''
}`}
onClick={(e) => {
e.preventDefault();
onSelectItem(item.query);
}}
onMouseEnter={() => {
// Visual feedback on hover
}}
tabIndex={0}
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<Icons.Search
size={16}
className="flex-shrink-0 text-[var(--text-color-secondary)]"
/>
<span className="text-[var(--text-color)] truncate flex-1">
{item.query}
</span>
{item.resultCount !== undefined && (
<span className="text-xs text-[var(--text-color-secondary)] flex-shrink-0">
{item.resultCount}
</span>
)}
</div>
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onRemoveItem(item.query);
}}
className="search-history-remove"
aria-label={`删除 "${item.query}"`}
tabIndex={0}
>
<Icons.X size={14} />
</button>
</div>
))}
</div>
</>
)}
</div>
);
}
+41 -40
View File
@@ -101,52 +101,53 @@ const VideoCard = memo(({
<Icons.Film size={64} className="text-[var(--text-color-secondary)] opacity-20" />
</div>
{/* Source Badge - Top Left */}
{video.sourceName && (
<div className="absolute top-2 left-2 z-10">
<Badge variant="primary" className="text-xs bg-[var(--accent-color)]">
{video.sourceName}
</Badge>
</div>
)}
{/* Latency Badge - Top Right */}
{video.latency !== undefined && (
<div className="absolute top-2 right-2 z-10">
<LatencyBadge latency={video.latency} />
</div>
)}
{/* Overlay - Show on hover (desktop) or when active (mobile) */}
<div
className={`absolute inset-0 bg-black/60 transition-opacity duration-300 ${
isActive ? 'opacity-100 lg:opacity-0 lg:group-hover:opacity-100' : 'opacity-0 lg:group-hover:opacity-100'
}`}
style={{
willChange: 'opacity',
}}
>
<div className="absolute bottom-0 left-0 right-0 p-3">
{/* Mobile indicator when active */}
{isActive && (
<div className="lg:hidden text-white/90 text-xs mb-2 font-medium">
</div>
)}
{video.type_name && (
<Badge variant="secondary" className="text-xs mb-2">
{video.type_name}
{/* Badge Container - Top, spans full width with proper spacing */}
<div className="absolute top-2 left-2 right-2 z-10 flex items-start justify-between gap-1">
{/* Source Badge - Left */}
{video.sourceName && (
<Badge variant="primary" className="text-[10px] px-1.5 py-0.5 bg-[var(--accent-color)] flex-shrink-0 max-w-[50%] truncate">
{video.sourceName}
</Badge>
)}
{video.vod_year && (
<div className="flex items-center gap-1 text-white/80 text-xs">
<Icons.Calendar size={12} />
<span>{video.vod_year}</span>
{/* Latency Badge - Right */}
{video.latency !== undefined && (
<div className="flex-shrink-0">
<LatencyBadge latency={video.latency} className="text-[10px] px-1.5 py-0.5" />
</div>
)}
</div>
{/* Overlay - Show on hover (desktop) or when active (mobile) */}
<div
className={`absolute inset-0 bg-black/60 transition-opacity duration-300 ${
isActive ? 'opacity-100 lg:opacity-0 lg:group-hover:opacity-100' : 'opacity-0 lg:group-hover:opacity-100'
}`}
style={{
willChange: 'opacity',
}}
>
<div className="absolute bottom-0 left-0 right-0 p-3">
{/* Mobile indicator when active */}
{isActive && (
<div className="lg:hidden text-white/90 text-xs mb-2 font-medium">
</div>
)}
{video.type_name && (
<Badge variant="secondary" className="text-xs mb-2">
{video.type_name}
</Badge>
)}
{video.vod_year && (
<div className="flex items-center gap-1 text-white/80 text-xs">
<Icons.Calendar size={12} />
<span>{video.vod_year}</span>
</div>
)}
</div>
</div>
</div>
</div>
{/* Info - Fixed height section */}
<div className="p-3 flex-1 flex flex-col">
+2 -2
View File
@@ -19,9 +19,9 @@ export const LatencyBadge = memo(function LatencyBadge({ latency, className = ''
<span
className={`
inline-flex items-center justify-center
px-2 py-0.5 md:px-3 md:py-1
px-1.5 py-0.5
rounded-[var(--radius-full)]
text-[10px] md:text-xs font-mono font-semibold
text-[10px] font-mono font-semibold
border
${className}
`}
+4 -5
View File
@@ -44,11 +44,10 @@ export function useSearchHistory(
if (dropdownTimeoutRef.current) {
clearTimeout(dropdownTimeoutRef.current);
}
if (recentSearches.length > 0) {
setIsDropdownOpen(true);
setHighlightedIndex(-1);
}
}, [recentSearches.length]);
// Show dropdown even if there's no history (for consistent UX)
setIsDropdownOpen(true);
setHighlightedIndex(-1);
}, []);
const hideDropdown = useCallback(() => {
// Delay hiding to allow click events to fire