feat: Implement watch history functionality; add WatchHistorySidebar component, enhance search history management, and integrate history tracking in video player

This commit is contained in:
kuekhaoyang
2025-11-17 17:52:47 +08:00
parent ba9853566f
commit 28ef76572e
11 changed files with 684 additions and 40 deletions
+29
View File
@@ -314,4 +314,33 @@ body.dark,
color: white;
}
/* Animations for History Components */
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px) scale(0.95);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideInRight {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
+4
View File
@@ -12,6 +12,7 @@ import { NoResults } from '@/components/search/NoResults';
import { ResultsHeader } from '@/components/search/ResultsHeader';
import { TypeBadges } from '@/components/search/TypeBadges';
import { PopularFeatures } from '@/components/home/PopularFeatures';
import { WatchHistorySidebar } from '@/components/history/WatchHistorySidebar';
import { useSearchCache } from '@/lib/hooks/useSearchCache';
import { useParallelSearch } from '@/lib/hooks/useParallelSearch';
import { useTypeBadges } from '@/lib/hooks/useTypeBadges';
@@ -167,6 +168,9 @@ function HomePage() {
<NoResults onReset={handleReset} />
)}
</main>
{/* Watch History Sidebar */}
<WatchHistorySidebar />
</div>
);
}
+31 -1
View File
@@ -1,6 +1,6 @@
'use client';
import { Suspense } from 'react';
import { Suspense, useEffect } from 'react';
import { useSearchParams, useRouter } from 'next/navigation';
import { Button } from '@/components/ui/Button';
import { ThemeSwitcher } from '@/components/ThemeSwitcher';
@@ -10,11 +10,14 @@ import { VideoMetadata } from '@/components/player/VideoMetadata';
import { EpisodeList } from '@/components/player/EpisodeList';
import { PlayerError } from '@/components/player/PlayerError';
import { useVideoPlayer } from '@/lib/hooks/useVideoPlayer';
import { useHistoryStore } from '@/lib/store/history-store';
import { WatchHistorySidebar } from '@/components/history/WatchHistorySidebar';
import Image from 'next/image';
function PlayerContent() {
const searchParams = useSearchParams();
const router = useRouter();
const { addToHistory } = useHistoryStore();
const videoId = searchParams.get('id');
const source = searchParams.get('source');
@@ -39,6 +42,30 @@ function PlayerContent() {
fetchVideoDetails,
} = useVideoPlayer(videoId, source, episodeParam);
// Add initial history entry when video data is loaded
useEffect(() => {
if (videoData && playUrl && videoId) {
// Map episodes to include index
const mappedEpisodes = videoData.episodes?.map((ep, idx) => ({
name: ep.name || `${idx + 1}`,
url: ep.url,
index: idx,
})) || [];
addToHistory(
videoId,
videoData.vod_name || title || '未知视频',
playUrl,
currentEpisode,
source,
0, // Initial playback position
0, // Will be updated by VideoPlayer
videoData.vod_pic,
mappedEpisodes
);
}
}, [videoData, playUrl, videoId, currentEpisode, source, title, addToHistory]);
const handleEpisodeClick = (episode: any, index: number) => {
setCurrentEpisode(index);
setPlayUrl(episode.url);
@@ -124,6 +151,9 @@ function PlayerContent() {
</div>
)}
</main>
{/* Watch History Sidebar */}
<WatchHistorySidebar />
</div>
);
}
+214
View File
@@ -0,0 +1,214 @@
/**
* Watch History Sidebar Component
* 观看历史侧边栏组件
*/
'use client';
import { useState } from 'react';
import { useHistoryStore } from '@/lib/store/history-store';
import { Icons } from '@/components/ui/Icon';
import { Button } from '@/components/ui/Button';
import Image from 'next/image';
export function WatchHistorySidebar() {
const [isOpen, setIsOpen] = useState(false);
const { viewingHistory, removeFromHistory, clearHistory } = useHistoryStore();
const formatTime = (seconds: number): string => {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
return `${minutes}:${secs.toString().padStart(2, '0')}`;
};
const formatDate = (timestamp: number): string => {
const date = new Date(timestamp);
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) return '今天';
if (days === 1) return '昨天';
if (days < 7) return `${days}天前`;
return date.toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
};
const getVideoUrl = (item: any): string => {
const params = new URLSearchParams({
id: item.videoId.toString(),
source: item.source,
title: item.title,
episode: item.episodeIndex.toString(),
});
return `/player?${params.toString()}`;
};
const handleItemClick = (item: any, event: React.MouseEvent) => {
// Middle mouse or Ctrl/Cmd+click opens in new tab
if (event.button === 1 || event.ctrlKey || event.metaKey) {
event.preventDefault();
window.open(getVideoUrl(item), '_blank');
return;
}
};
return (
<>
{/* Toggle Button */}
<button
onClick={() => setIsOpen(true)}
className="fixed right-6 top-1/2 -translate-y-1/2 z-40 bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] border border-[var(--glass-border)] rounded-[var(--radius-2xl)] shadow-[var(--shadow-md)] p-3 hover:scale-105 transition-all"
aria-label="打开观看历史"
>
<Icons.History size={24} className="text-[var(--text-color)]" />
</button>
{/* Backdrop */}
{isOpen && (
<div
className="fixed inset-0 z-[1999] bg-black/30 backdrop-blur-[5px] opacity-0 animate-[fadeIn_0.3s_ease-out_forwards]"
onClick={() => setIsOpen(false)}
/>
)}
{/* Sidebar */}
<aside
className={`fixed top-0 right-0 bottom-0 w-[90%] max-w-[420px] z-[2000] bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] 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-[400ms] cubic-bezier(0.2,0.8,0.2,1) ${
isOpen ? 'translate-x-0' : 'translate-x-full'
}`}
>
{/* Header */}
<header className="flex items-center justify-between mb-6 pb-4 border-b border-[var(--glass-border)]">
<div className="flex items-center gap-3">
<Icons.History size={24} className="text-[var(--accent-color)]" />
<h2 className="text-xl font-semibold text-[var(--text-color)]">
</h2>
</div>
<button
onClick={() => setIsOpen(false)}
className="p-2 hover:bg-[var(--glass-bg)] rounded-full transition-colors"
aria-label="关闭"
>
<Icons.X size={24} className="text-[var(--text-color-secondary)]" />
</button>
</header>
{/* Content */}
<div className="flex-1 overflow-y-auto -mx-2 px-2">
{viewingHistory.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center py-12">
<Icons.Inbox size={64} className="text-[var(--text-color-secondary)] opacity-50 mb-4" />
<p className="text-[var(--text-color-secondary)] text-lg">
</p>
</div>
) : (
<div className="space-y-3">
{viewingHistory.map((item) => {
const progress = (item.playbackPosition / item.duration) * 100;
const episodeText = item.episodes && item.episodes.length > 0
? item.episodes[item.episodeIndex]?.name || `${item.episodeIndex + 1}`
: '';
return (
<div
key={`${item.videoId}-${item.source}-${item.timestamp}`}
className="group bg-[color-mix(in_srgb,var(--glass-bg)_50%,transparent)] rounded-[var(--radius-2xl)] p-3 hover:bg-[color-mix(in_srgb,var(--accent-color)_10%,transparent)] transition-all border border-transparent hover:border-[var(--glass-border)]"
>
<a
href={getVideoUrl(item)}
onClick={(e) => {
e.preventDefault();
handleItemClick(item, e as any);
if (!e.ctrlKey && !e.metaKey) {
window.location.href = getVideoUrl(item);
}
}}
onAuxClick={(e) => handleItemClick(item, e as any)}
className="block"
>
<div className="flex gap-3">
{/* Poster */}
<div className="relative w-28 h-16 flex-shrink-0 bg-[var(--glass-bg)] rounded-[var(--radius-2xl)] overflow-hidden">
{item.poster ? (
<Image
src={item.poster}
alt={item.title}
fill
className="object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Icons.Film size={32} className="text-[var(--text-color-secondary)] opacity-30" />
</div>
)}
{/* Progress overlay */}
<div className="absolute bottom-0 left-0 right-0 h-1 bg-black/30">
<div
className="h-full bg-[var(--accent-color)]"
style={{ width: `${Math.min(100, progress)}%` }}
/>
</div>
</div>
{/* Info */}
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium text-[var(--text-color)] truncate group-hover:text-[var(--accent-color)] transition-colors mb-1">
{item.title}
</h3>
{episodeText && (
<p className="text-xs text-[var(--text-color-secondary)] mb-1">
{episodeText}
</p>
)}
<div className="flex items-center justify-between text-xs text-[var(--text-color-secondary)]">
<span>{formatTime(item.playbackPosition)} / {formatTime(item.duration)}</span>
<span>{formatDate(item.timestamp)}</span>
</div>
</div>
{/* Delete button */}
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
removeFromHistory(item.videoId, item.source);
}}
className="opacity-0 group-hover:opacity-100 transition-opacity p-2 hover:bg-[var(--glass-bg)] rounded-full self-start"
aria-label="删除"
>
<Icons.Trash size={16} className="text-[var(--text-color-secondary)]" />
</button>
</div>
</a>
</div>
);
})}
</div>
)}
</div>
{/* Footer */}
{viewingHistory.length > 0 && (
<footer className="mt-4 pt-4 border-t border-[var(--glass-border)]">
<Button
variant="secondary"
onClick={clearHistory}
className="w-full flex items-center justify-center gap-2"
>
<Icons.Trash size={18} />
</Button>
</footer>
)}
</aside>
</>
);
}
+77 -1
View File
@@ -1,9 +1,11 @@
'use client';
import { useRef, useState } from 'react';
import { useRef, useState, useEffect } from 'react';
import { useSearchParams } from 'next/navigation';
import { Card } from '@/components/ui/Card';
import { Button } from '@/components/ui/Button';
import { Icons } from '@/components/ui/Icon';
import { useHistoryStore } from '@/lib/store/history-store';
interface VideoPlayerProps {
playUrl: string;
@@ -16,6 +18,80 @@ export function VideoPlayer({ playUrl, videoId, currentEpisode, onBack }: VideoP
const videoRef = useRef<HTMLVideoElement>(null);
const [videoError, setVideoError] = useState<string>('');
const [isVideoLoading, setIsVideoLoading] = useState(false);
const searchParams = useSearchParams();
const { addToHistory } = useHistoryStore();
// Get video metadata from URL params
const source = searchParams.get('source') || '';
const title = searchParams.get('title') || '未知视频';
// Save progress to history periodically
useEffect(() => {
if (!videoRef.current || !videoId || !playUrl) return;
const video = videoRef.current;
let lastSavedTime = 0;
const updateProgress = () => {
if (video && video.duration > 0) {
const position = video.currentTime;
const duration = video.duration;
// Only save if we have meaningful progress (more than 1 second)
// and if at least 5 seconds have passed since last save
if (position > 1 && Math.abs(position - lastSavedTime) >= 5) {
lastSavedTime = position;
console.log(`[Watch History] Saving progress: ${position.toFixed(1)}s / ${duration.toFixed(1)}s`);
addToHistory(
videoId,
title,
playUrl,
currentEpisode,
source,
position,
duration,
undefined, // poster - updated from player page
[] // episodes - updated from player page
);
}
}
};
// Update progress on time update (throttled by the 5 second check)
const handleTimeUpdate = () => updateProgress();
// Also update on pause
const handlePause = () => {
if (video && video.duration > 0) {
console.log('[Watch History] Saving on pause');
updateProgress();
}
};
// Update when leaving the page
const handleBeforeUnload = () => {
if (video && video.duration > 0) {
console.log('[Watch History] Saving before unload');
updateProgress();
}
};
video.addEventListener('timeupdate', handleTimeUpdate);
video.addEventListener('pause', handlePause);
window.addEventListener('beforeunload', handleBeforeUnload);
return () => {
video.removeEventListener('timeupdate', handleTimeUpdate);
video.removeEventListener('pause', handlePause);
window.removeEventListener('beforeunload', handleBeforeUnload);
// Save progress one last time on unmount
if (video && video.duration > 0) {
console.log('[Watch History] Saving on unmount');
updateProgress();
}
};
}, [videoId, playUrl, currentEpisode, source, title, addToHistory]);
const handleVideoError = (e: React.SyntheticEvent<HTMLVideoElement, Event>) => {
const video = e.currentTarget;
+33 -1
View File
@@ -1,10 +1,12 @@
'use client';
import { useState, FormEvent, useEffect } from 'react';
import { useState, FormEvent, useEffect, useRef } from 'react';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { Icons } from '@/components/ui/Icon';
import { SearchLoadingAnimation } from '@/components/SearchLoadingAnimation';
import { SearchHistoryDropdown } from '@/components/search/SearchHistoryDropdown';
import { useSearchHistoryStore } from '@/lib/store/search-history-store';
interface SearchFormProps {
onSearch: (query: string) => void;
@@ -32,6 +34,10 @@ export function SearchForm({
searchStage = 'searching',
}: SearchFormProps) {
const [query, setQuery] = useState(initialQuery);
const [showHistory, setShowHistory] = useState(false);
const [inputRect, setInputRect] = useState<DOMRect | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const { addSearchHistory } = useSearchHistoryStore();
// Update query when initialQuery changes
useEffect(() => {
@@ -41,7 +47,9 @@ export function SearchForm({
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (query.trim() && !isLoading) {
addSearchHistory(query.trim());
onSearch(query);
setShowHistory(false);
}
};
@@ -50,15 +58,31 @@ export function SearchForm({
if (onClear) {
onClear();
}
setShowHistory(false);
};
const handleInputFocus = () => {
if (inputRef.current) {
setInputRect(inputRef.current.getBoundingClientRect());
setShowHistory(true);
}
};
const handleHistorySelect = (selectedQuery: string) => {
setQuery(selectedQuery);
setShowHistory(false);
onSearch(selectedQuery);
};
return (
<form onSubmit={handleSubmit} className="max-w-3xl mx-auto">
<div className="relative group">
<Input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onFocus={handleInputFocus}
placeholder="搜索电影、电视剧、综艺..."
className="text-lg pr-32"
/>
@@ -83,6 +107,14 @@ export function SearchForm({
</span>
</Button>
</div>
{/* Search History Dropdown */}
<SearchHistoryDropdown
isVisible={showHistory && !isLoading}
onSelect={handleHistorySelect}
onClose={() => setShowHistory(false)}
inputRect={inputRect}
/>
{/* Loading Animation */}
{isLoading && (
+120
View File
@@ -0,0 +1,120 @@
/**
* Search History Dropdown Component
* 搜索历史下拉组件
*/
'use client';
import { useEffect, useState, useRef } from 'react';
import { useSearchHistoryStore } from '@/lib/store/search-history-store';
import { Icons } from '@/components/ui/Icon';
interface SearchHistoryDropdownProps {
isVisible: boolean;
onSelect: (query: string) => void;
onClose: () => void;
inputRect: DOMRect | null;
}
export function SearchHistoryDropdown({
isVisible,
onSelect,
onClose,
inputRect,
}: SearchHistoryDropdownProps) {
const { searchHistory, removeSearchHistory, clearSearchHistory } = useSearchHistoryStore();
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
onClose();
}
};
if (isVisible) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isVisible, onClose]);
if (!isVisible || searchHistory.length === 0) return null;
const style = inputRect
? {
position: 'fixed' as const,
top: `${inputRect.bottom + 8}px`,
left: `${inputRect.left}px`,
width: `${inputRect.width}px`,
}
: {};
const handleItemClick = (query: string, event: React.MouseEvent) => {
// Check if middle mouse button (opens in new tab)
if (event.button === 1 || event.ctrlKey || event.metaKey) {
event.preventDefault();
window.open(`/?q=${encodeURIComponent(query)}`, '_blank');
return;
}
onSelect(query);
};
return (
<div
ref={dropdownRef}
style={style}
className="z-[9999] bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] rounded-[var(--radius-2xl)] shadow-[var(--shadow-md)] border border-[var(--glass-border)] p-2 opacity-0 animate-[slideIn_0.2s_ease-out_forwards]"
>
<div className="flex items-center justify-between px-3 py-2 mb-1">
<span className="text-sm font-medium text-[var(--text-color-secondary)]">
</span>
<button
onClick={clearSearchHistory}
className="text-xs text-[var(--text-color-secondary)] hover:text-[var(--accent-color)] transition-colors"
>
</button>
</div>
<div className="max-h-[300px] overflow-y-auto space-y-1">
{searchHistory.map((item) => (
<div
key={item.timestamp}
className="group flex items-center gap-3 px-3 py-2.5 rounded-[var(--radius-2xl)] hover:bg-[color-mix(in_srgb,var(--accent-color)_15%,transparent)] transition-all cursor-pointer"
>
<Icons.Clock
size={16}
className="text-[var(--text-color-secondary)] flex-shrink-0"
/>
<a
href={`/?q=${encodeURIComponent(item.query)}`}
onClick={(e) => {
e.preventDefault();
handleItemClick(item.query, e as any);
}}
onAuxClick={(e) => handleItemClick(item.query, e as any)}
className="flex-1 text-sm text-[var(--text-color)] hover:text-[var(--accent-color)] transition-colors truncate"
title={item.query}
>
{item.query}
</a>
<button
onClick={(e) => {
e.stopPropagation();
removeSearchHistory(item.query);
}}
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-[var(--glass-bg)] rounded-full"
aria-label="删除"
>
<Icons.X size={14} className="text-[var(--text-color-secondary)]" />
</button>
</div>
))}
</div>
</div>
);
}
+46
View File
@@ -326,4 +326,50 @@ export const Icons = {
<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>
</svg>
),
Clock: ({ className = "", size = 24 }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<circle cx="12" cy="12" r="10"/>
<polyline points="12 6 12 12 16 14"/>
</svg>
),
History: ({ className = "", size = 24 }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 -960 960 960"
fill="currentColor"
className={className}
>
<path d="M480-120q-138 0-240.5-91.5T122-440h82q14 104 92.5 172T480-200q117 0 198.5-81.5T760-480q0-117-81.5-198.5T480-760q-69 0-129 32t-101 88h110v80H120v-240h80v94q51-64 124.5-99T480-840q75 0 140.5 28.5t114 77q48.5 48.5 77 114T840-480q0 75-28.5 140.5t-77 114q-48.5 48.5-114 77T480-120Zm112-192L440-464v-216h80v184l128 128-56 56Z"/>
</svg>
),
Trash: ({ className = "", size = 24 }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<polyline points="3 6 5 6 21 6"/>
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/>
</svg>
),
};
+42 -37
View File
@@ -1,44 +1,49 @@
import React from 'react';
import React, { forwardRef } from 'react';
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
}
export function Input({ label, error, className = '', ...props }: InputProps) {
return (
<div className="w-full">
{label && (
<label className="block text-sm font-medium text-[var(--text-color)] mb-2">
{label}
</label>
)}
<input
className={`
w-full px-6 py-4
bg-[var(--glass-bg)]
backdrop-blur-[10px]
saturate-[150%]
[-webkit-backdrop-filter:blur(10px)_saturate(150%)]
border
border-[var(--glass-border)]
rounded-[var(--radius-2xl)]
text-[var(--text-color)]
placeholder:text-[var(--text-color-secondary)]
focus:outline-none
focus:border-[var(--accent-color)]
focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent-color)_30%,transparent)]
transition-all
duration-[var(--transition-fluid)]
${error ? 'border-red-500' : ''}
${className}
`}
{...props}
/>
{error && (
<p className="mt-2 text-sm text-red-400">{error}</p>
)}
</div>
);
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ label, error, className = '', ...props }, ref) => {
return (
<div className="w-full">
{label && (
<label className="block text-sm font-medium text-[var(--text-color)] mb-2">
{label}
</label>
)}
<input
ref={ref}
className={`
w-full px-6 py-4
bg-[var(--glass-bg)]
backdrop-blur-[10px]
saturate-[150%]
[-webkit-backdrop-filter:blur(10px)_saturate(150%)]
border
border-[var(--glass-border)]
rounded-[var(--radius-2xl)]
text-[var(--text-color)]
placeholder:text-[var(--text-color-secondary)]
focus:outline-none
focus:border-[var(--accent-color)]
focus:shadow-[0_0_0_3px_color-mix(in_srgb,var(--accent-color)_30%,transparent)]
transition-all
duration-[var(--transition-fluid)]
${error ? 'border-red-500' : ''}
${className}
`}
{...props}
/>
{error && (
<p className="mt-2 text-sm text-red-400">{error}</p>
)}
</div>
);
}
);
Input.displayName = 'Input';
+70
View File
@@ -0,0 +1,70 @@
/**
* Search History Store
* 搜索历史记录存储
*/
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
const MAX_SEARCH_HISTORY = 20;
export interface SearchHistoryItem {
query: string;
timestamp: number;
}
interface SearchHistoryStore {
searchHistory: SearchHistoryItem[];
addSearchHistory: (query: string) => void;
removeSearchHistory: (query: string) => void;
clearSearchHistory: () => void;
getSearchHistory: () => SearchHistoryItem[];
}
export const useSearchHistoryStore = create<SearchHistoryStore>()(
persist(
(set, get) => ({
searchHistory: [],
addSearchHistory: (query: string) => {
const trimmedQuery = query.trim();
if (!trimmedQuery) return;
set((state) => {
// 移除已存在的相同搜索
const filtered = state.searchHistory.filter(
(item) => item.query !== trimmedQuery
);
// 添加新搜索到顶部
const newHistory = [
{ query: trimmedQuery, timestamp: Date.now() },
...filtered,
].slice(0, MAX_SEARCH_HISTORY);
return { searchHistory: newHistory };
});
},
removeSearchHistory: (query: string) => {
set((state) => ({
searchHistory: state.searchHistory.filter(
(item) => item.query !== query
),
}));
},
clearSearchHistory: () => {
set({ searchHistory: [] });
},
getSearchHistory: () => {
return get().searchHistory;
},
}),
{
name: 'kvideo-search-history',
}
)
);
+18
View File
@@ -3,6 +3,7 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
images: {
remotePatterns: [
// Douban images
{
protocol: 'https',
hostname: 'img3.doubanio.com',
@@ -19,6 +20,23 @@ const nextConfig: NextConfig = {
protocol: 'https',
hostname: 'img9.doubanio.com',
},
// Video source images - allow all subdomains with wildcards
{
protocol: 'http',
hostname: '**.com',
},
{
protocol: 'https',
hostname: '**.com',
},
{
protocol: 'http',
hostname: '**.cn',
},
{
protocol: 'https',
hostname: '**.cn',
},
],
},
};