import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { Link, useLocation, useNavigate } from 'react-router-dom' import { ArrowLeft, Film, LoaderCircle, Menu, Search, Star, X } from 'lucide-react' import { imageURL } from '../api/client' import { mediaAPI } from '../api/library' import type { Media, PlayProfile, User } from '../types' import { favouriteMediaLink } from '../utils/mediaNavigation' import { resolveHeaderBack } from './layoutNavigation' import { LayoutThemeToggle } from './LayoutThemeToggle' import { LayoutUserMenu } from './LayoutUserMenu' import type { useLayoutProfiles } from './useLayoutProfiles' import type { ThemeMode, useThemeMode } from './useThemeMode' type LayoutProfileState = ReturnType type LayoutThemeState = ReturnType type LayoutPermissionState = { can: (key: string) => boolean isAdmin: boolean } type LayoutHeaderProps = { permissions: LayoutPermissionState theme: LayoutThemeState onOpenMobileDrawer: () => void user: User | null | undefined activeProfileId: string | null profile: LayoutProfileState onLogout: () => void showSidebar?: boolean hideSearch?: boolean pathname?: string } export function LayoutHeader({ permissions, theme, onOpenMobileDrawer, user, activeProfileId, profile, onLogout, showSidebar, hideSearch, pathname = '', }: LayoutHeaderProps) { const navigate = useNavigate() const headerBack = resolveHeaderBack(pathname) return (
{/* Left: back / menu / brand */}
{headerBack ? ( ) : null} MeBox MeBox
{/* Middle: Search Box */}
{!hideSearch && }
{/* Right: Actions (Theme Toggle & User Menu) */} profile.setIsProfileOpen((open) => !open)} onCloseProfile={() => profile.setIsProfileOpen(false)} onUseDefaultProfile={profile.useDefaultProfile} onSwitchProfile={profile.switchProfile} onLogout={onLogout} />
) } const SEARCH_PAGE_SIZE = 8 function LayoutHeaderSearch() { const [query, setQuery] = useState('') const [isOpen, setIsOpen] = useState(false) const [loading, setLoading] = useState(false) const [loadingMore, setLoadingMore] = useState(false) const [results, setResults] = useState([]) const [hasMore, setHasMore] = useState(false) const containerRef = useRef(null) const inputRef = useRef(null) const isOpenRef = useRef(false) const suppressInputRef = useRef(false) // 递增序号守卫:快速连续输入时丢弃过期响应 const searchSeqRef = useRef(0) const pageRef = useRef(0) const loadingMoreRef = useRef(false) const resultsRef = useRef([]) const hasMoreRef = useRef(false) const navigate = useNavigate() const location = useLocation() const locationKey = `${location.pathname}${location.search}` const prevLocationKeyRef = useRef(locationKey) const setSearchOpen = useCallback((open: boolean) => { isOpenRef.current = open setIsOpen(open) }, []) const dismissSearch = useCallback((clearQuery: boolean) => { suppressInputRef.current = true setSearchOpen(false) if (clearQuery) { searchSeqRef.current += 1 pageRef.current = 0 loadingMoreRef.current = false resultsRef.current = [] hasMoreRef.current = false setQuery('') setResults([]) setLoading(false) setLoadingMore(false) setHasMore(false) } inputRef.current?.blur() window.setTimeout(() => { suppressInputRef.current = false }, 0) }, [setSearchOpen]) const dismissSearchRef = useRef(dismissSearch) useEffect(() => { dismissSearchRef.current = dismissSearch }, [dismissSearch]) useEffect(() => { const trimmed = query.trim() if (!trimmed) { searchSeqRef.current += 1 pageRef.current = 0 loadingMoreRef.current = false resultsRef.current = [] hasMoreRef.current = false setResults([]) setLoading(false) setLoadingMore(false) setHasMore(false) isOpenRef.current = false setIsOpen(false) return } const seq = ++searchSeqRef.current pageRef.current = 0 loadingMoreRef.current = false resultsRef.current = [] hasMoreRef.current = false setResults([]) setHasMore(false) setLoading(true) setLoadingMore(false) const timer = setTimeout(async () => { try { const res = await mediaAPI.searchPage(trimmed, 1, SEARCH_PAGE_SIZE, { groupSeries: true }) if (seq !== searchSeqRef.current) return const items = res.items || [] const total = res.total ?? items.length const more = items.length < total pageRef.current = 1 resultsRef.current = items hasMoreRef.current = more setResults(items) setHasMore(more) if (isOpenRef.current && !suppressInputRef.current) setIsOpen(true) } catch { // 请求失败时保持空结果,用户继续输入或滚动时会重新请求 } finally { if (seq === searchSeqRef.current) setLoading(false) } }, 250) return () => clearTimeout(timer) }, [query]) useLayoutEffect(() => { if (prevLocationKeyRef.current === locationKey) return prevLocationKeyRef.current = locationKey dismissSearchRef.current(true) }, [locationKey]) useEffect(() => { function handlePointerDown(e: PointerEvent) { if (!isOpenRef.current) return const root = containerRef.current if (root && e.composedPath().includes(root)) return dismissSearchRef.current(false) } document.addEventListener('pointerdown', handlePointerDown, true) return () => document.removeEventListener('pointerdown', handlePointerDown, true) }, []) const loadMore = async () => { const trimmed = query.trim() if (!trimmed || loading || loadingMoreRef.current || !hasMoreRef.current) return const seq = searchSeqRef.current const nextPage = pageRef.current + 1 loadingMoreRef.current = true setLoadingMore(true) try { const res = await mediaAPI.searchPage(trimmed, nextPage, SEARCH_PAGE_SIZE, { groupSeries: true }) if (seq !== searchSeqRef.current) return const incoming = res.items || [] const currentResults = resultsRef.current const known = new Set(currentResults.map((item) => item.id)) const nextResults = [...currentResults, ...incoming.filter((item) => !known.has(item.id))] const total = res.total ?? nextResults.length const more = nextPage * SEARCH_PAGE_SIZE < total pageRef.current = nextPage resultsRef.current = nextResults hasMoreRef.current = more setResults(nextResults) setHasMore(more) } catch { // 请求失败时保留当前结果,继续滚动可重试 } finally { if (seq === searchSeqRef.current) { loadingMoreRef.current = false setLoadingMore(false) } } } const handleSelect = (item: Media) => { if (suppressInputRef.current) return const to = favouriteMediaLink(item) dismissSearch(true) navigate(to) } const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Escape') { dismissSearch(false) } else if (e.key === 'Enter' && results.length > 0) { handleSelect(results[0]) } } return (
{ if (suppressInputRef.current) return const nextQuery = e.target.value setQuery(nextQuery) if (nextQuery.trim() && document.activeElement === inputRef.current) { setSearchOpen(true) } else if (!nextQuery.trim()) { setSearchOpen(false) } }} onFocus={() => { if (suppressInputRef.current) return if (query.trim()) setSearchOpen(true) }} onKeyDown={handleKeyDown} placeholder="搜索媒体…" className="w-full h-9 sm:h-10 pl-8 sm:pl-10 pr-8 sm:pr-9 rounded-xl sm:rounded-2xl border border-[var(--app-border)] bg-[var(--app-panel)] text-xs sm:text-sm text-[var(--app-text)] placeholder:text-[var(--app-muted)] shadow-sm outline-none transition-all duration-200 focus:border-brand-500 focus:ring-2 focus:ring-brand-500/20 focus:bg-[var(--app-panel-elevated)]" /> {loading ? ( ) : query ? ( ) : null}
{/* Search Dropdown Results */} {isOpen && query.trim() && typeof document !== 'undefined' ? createPortal( ) } type LayoutHeaderActionsProps = { permissions: LayoutPermissionState themeMode: ThemeMode onThemeChange: (mode: ThemeMode) => void user: User | null | undefined isProfileOpen: boolean profiles: PlayProfile[] activeProfileId: string | null activeProfile: PlayProfile | null onToggleProfile: () => void onCloseProfile: () => void onUseDefaultProfile: () => void onSwitchProfile: (profile: PlayProfile) => void onLogout: () => void } function LayoutHeaderActions({ themeMode, onThemeChange, user, isProfileOpen, profiles, activeProfileId, activeProfile, onToggleProfile, onCloseProfile, onUseDefaultProfile, onSwitchProfile, onLogout, }: LayoutHeaderActionsProps) { return (
) }