mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-17 01:33:43 +08:00
feat: Implement watch history functionality; add WatchHistorySidebar component, enhance search history management, and integrate history tracking in video player
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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';
|
||||
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user