mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-14 16:23:43 +08:00
feat: Enhance search history dropdown with absolute positioning and empty state styling; improve video player autoplay functionality
This commit is contained in:
+13
-3
@@ -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
@@ -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}
|
||||
|
||||
@@ -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 />
|
||||
) : (
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
`}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user