feat: Add Popular Features component and integrate with search functionality; enhance SearchForm with clear button and initial query handling; update scrollbar styling utility

This commit is contained in:
kuekhaoyang
2025-11-17 16:34:13 +08:00
parent 6a84889c2c
commit 1f55ba2d56
8 changed files with 470 additions and 53 deletions
+34
View File
@@ -0,0 +1,34 @@
import { NextResponse } from 'next/server';
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const tag = searchParams.get('tag') || '热门';
const pageLimit = searchParams.get('page_limit') || '20';
const pageStart = searchParams.get('page_start') || '0';
const type = searchParams.get('type') || 'movie'; // movie or tv
try {
const url = `https://movie.douban.com/j/search_subjects?type=${type}&tag=${encodeURIComponent(tag)}&sort=recommend&page_limit=${pageLimit}&page_start=${pageStart}`;
const response = await fetch(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
'Referer': 'https://movie.douban.com/',
},
next: { revalidate: 3600 }, // Cache for 1 hour
});
if (!response.ok) {
throw new Error(`Douban API returned ${response.status}`);
}
const data = await response.json();
return NextResponse.json(data);
} catch (error) {
console.error('Douban API error:', error);
return NextResponse.json(
{ subjects: [], error: 'Failed to fetch recommendations' },
{ status: 500 }
);
}
}
+10
View File
@@ -103,6 +103,16 @@ body.dark,
background-clip: padding-box;
}
/* Hide scrollbar utility */
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
}
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
/* Custom animations */
@keyframes fade-in {
from {
+44 -47
View File
@@ -11,6 +11,7 @@ import { EmptyState } from '@/components/search/EmptyState';
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 { useSearchCache } from '@/lib/hooks/useSearchCache';
import { useParallelSearch } from '@/lib/hooks/useParallelSearch';
import { useTypeBadges } from '@/lib/hooks/useTypeBadges';
@@ -85,57 +86,53 @@ function HomePage() {
return (
<div className="min-h-screen">
{/* Glass Navbar */}
<nav className="sticky top-4 z-50 mx-4 mt-4 mb-8">
<div className="max-w-7xl mx-auto bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] [-webkit-backdrop-filter:blur(25px)_saturate(180%)] border border-[var(--glass-border)] shadow-[var(--shadow-md)] px-6 py-4 transition-all duration-[var(--transition-fluid)]" style={{ borderRadius: 'var(--radius-2xl)' }}>
<div className="flex items-center justify-between">
<Link
href="/"
className="flex items-center gap-3 hover:opacity-80 transition-opacity cursor-pointer"
onClick={handleReset}
>
<div className="w-10 h-10 relative flex items-center justify-center">
<Image
src="/icon.png"
alt="KVideo"
width={40}
height={40}
className="object-contain"
/>
</div>
<div>
<h1 className="text-2xl font-bold text-[var(--text-color)]">KVideo</h1>
<p className="text-xs text-[var(--text-color-secondary)]"></p>
</div>
</Link>
<ThemeSwitcher />
<nav className="sticky top-0 z-50 pt-4 pb-2">
<div className="max-w-7xl mx-auto px-4">
<div className="bg-[var(--glass-bg)] backdrop-blur-[25px] saturate-[180%] [-webkit-backdrop-filter:blur(25px)_saturate(180%)] border border-[var(--glass-border)] shadow-[var(--shadow-md)] px-6 py-4 transition-all duration-[var(--transition-fluid)]" style={{ borderRadius: 'var(--radius-2xl)' }}>
<div className="flex items-center justify-between">
<Link
href="/"
className="flex items-center gap-3 hover:opacity-80 transition-opacity cursor-pointer"
onClick={handleReset}
>
<div className="w-10 h-10 relative flex items-center justify-center">
<Image
src="/icon.png"
alt="KVideo"
width={40}
height={40}
className="object-contain"
/>
</div>
<div>
<h1 className="text-2xl font-bold text-[var(--text-color)]">KVideo</h1>
<p className="text-xs text-[var(--text-color-secondary)]"></p>
</div>
</Link>
<ThemeSwitcher />
</div>
</div>
</div>
</nav>
{/* Search Form - Separate from navbar */}
<div className="max-w-7xl mx-auto px-4 mt-6 mb-8">
<SearchForm
onSearch={handleSearch}
onClear={handleReset}
isLoading={loading}
initialQuery={query}
currentSource=""
checkedSources={completedSources}
totalSources={totalSources}
checkedVideos={0}
totalVideos={totalVideosFound}
searchStage="searching"
/>
</div>
{/* Main Content */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
{/* Hero Section with Search */}
<div className="text-center mb-12 animate-slide-up">
<h2 className="text-5xl md:text-6xl font-bold text-[var(--text-color)] mb-4">
</h2>
<p className="text-xl text-[var(--text-color-secondary)] mb-8">
· ·
</p>
<SearchForm
onSearch={handleSearch}
isLoading={loading}
initialQuery={query}
currentSource=""
checkedSources={completedSources}
totalSources={totalSources}
checkedVideos={0}
totalVideos={totalVideosFound}
searchStage="searching"
/>
</div>
{/* Results Section */}
{(results.length >= 1 || (!loading && results.length > 0)) && (
<div className="animate-fade-in">
@@ -162,8 +159,8 @@ function HomePage() {
</div>
)}
{/* Empty State - Initial Homepage */}
{!loading && !hasSearched && <EmptyState />}
{/* Popular Features - Homepage */}
{!loading && !hasSearched && <PopularFeatures onSearch={handleSearch} />}
{/* No Results */}
{!loading && hasSearched && results.length === 0 && (
+319
View File
@@ -0,0 +1,319 @@
'use client';
import { useState, useEffect, useRef, useCallback } from 'react';
import { Card } from '@/components/ui/Card';
import { Icons } from '@/components/ui/Icon';
import Image from 'next/image';
import Link from 'next/link';
interface DoubanMovie {
id: string;
title: string;
cover: string;
rate: string;
url: string;
cover_x?: number;
cover_y?: number;
}
interface PopularFeaturesProps {
onSearch?: (query: string) => void;
}
const DEFAULT_TAGS = [
{ id: 'popular', label: '热门', value: '热门' },
{ id: 'latest', label: '最新', value: '最新' },
{ id: 'classic', label: '经典', value: '经典' },
{ id: 'highscore', label: '豆瓣高分', value: '豆瓣高分' },
{ id: 'underrated', label: '冷门佳片', value: '冷门佳片' },
{ id: 'chinese', label: '华语', value: '华语' },
{ id: 'western', label: '欧美', value: '欧美' },
{ id: 'korean', label: '韩国', value: '韩国' },
{ id: 'japanese', label: '日本', value: '日本' },
{ id: 'action', label: '动作', value: '动作' },
{ id: 'comedy', label: '喜剧', value: '喜剧' },
{ id: 'variety', label: '综艺', value: '综艺' },
{ id: 'romance', label: '爱情', value: '爱情' },
{ id: 'scifi', label: '科幻', value: '科幻' },
{ id: 'thriller', label: '悬疑', value: '悬疑' },
{ id: 'horror', label: '恐怖', value: '恐怖' },
{ id: 'healing', label: '治愈', value: '治愈' },
];
const STORAGE_KEY = 'kvideo_custom_tags';
export function PopularFeatures({ onSearch }: PopularFeaturesProps) {
const [selectedTag, setSelectedTag] = useState('popular');
const [tags, setTags] = useState(DEFAULT_TAGS);
const [movies, setMovies] = useState<DoubanMovie[]>([]);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [page, setPage] = useState(0);
const [newTagInput, setNewTagInput] = useState('');
const [showTagManager, setShowTagManager] = useState(false);
const observerRef = useRef<IntersectionObserver | null>(null);
const loadMoreRef = useRef<HTMLDivElement>(null);
const prefetchRef = useRef<HTMLDivElement>(null);
const PAGE_LIMIT = 20;
// Load custom tags from localStorage
useEffect(() => {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
try {
const parsed = JSON.parse(saved);
setTags(parsed);
} catch (e) {
console.error('Failed to parse saved tags', e);
}
}
}, []);
// Save tags to localStorage
const saveTags = (newTags: typeof DEFAULT_TAGS) => {
setTags(newTags);
localStorage.setItem(STORAGE_KEY, JSON.stringify(newTags));
};
const loadMovies = useCallback(async (tag: string, pageStart: number, append = false) => {
if (loading) return;
setLoading(true);
try {
const tagValue = tags.find(t => t.id === tag)?.value || '热门';
const response = await fetch(
`/api/douban/recommend?tag=${encodeURIComponent(tagValue)}&page_limit=${PAGE_LIMIT}&page_start=${pageStart}`
);
if (!response.ok) throw new Error('Failed to fetch');
const data = await response.json();
const newMovies = data.subjects || [];
setMovies(prev => append ? [...prev, ...newMovies] : newMovies);
setHasMore(newMovies.length === PAGE_LIMIT);
} catch (error) {
console.error('Failed to load movies:', error);
setHasMore(false);
} finally {
setLoading(false);
}
}, [loading, tags]);
// Load initial movies when tag changes
useEffect(() => {
setPage(0);
setMovies([]);
setHasMore(true);
loadMovies(selectedTag, 0, false);
}, [selectedTag]);
// Setup intersection observer for infinite scroll with prefetch
useEffect(() => {
if (!prefetchRef.current) return;
const prefetchObserver = new IntersectionObserver(
(entries) => {
const target = entries[0];
if (target.isIntersecting && hasMore && !loading) {
const nextPage = page + 1;
setPage(nextPage);
loadMovies(selectedTag, nextPage * PAGE_LIMIT, true);
}
},
{ threshold: 0.1, rootMargin: '400px' }
);
prefetchObserver.observe(prefetchRef.current);
return () => {
prefetchObserver.disconnect();
};
}, [hasMore, loading, page, selectedTag, loadMovies]);
const handleMovieClick = (movie: DoubanMovie) => {
if (onSearch) {
onSearch(movie.title);
}
};
const addCustomTag = () => {
if (!newTagInput.trim()) return;
const newTag = {
id: `custom_${Date.now()}`,
label: newTagInput.trim(),
value: newTagInput.trim(),
};
saveTags([...tags, newTag]);
setNewTagInput('');
};
const deleteTag = (tagId: string) => {
saveTags(tags.filter(t => t.id !== tagId));
if (selectedTag === tagId) {
setSelectedTag('popular');
}
};
const restoreDefaults = () => {
saveTags(DEFAULT_TAGS);
setSelectedTag('popular');
setShowTagManager(false);
};
const isCustomTag = (tagId: string) => tagId.startsWith('custom_');
return (
<div className="animate-fade-in">
{/* Tag Management UI */}
<div className="mb-6 flex items-center justify-between">
<button
onClick={() => setShowTagManager(!showTagManager)}
className="text-sm text-[var(--text-color-secondary)] hover:text-[var(--accent-color)] transition-colors flex items-center gap-2"
>
<Icons.Tag size={16} />
{showTagManager ? '完成' : '管理标签'}
</button>
{showTagManager && (
<button
onClick={restoreDefaults}
className="text-sm text-[var(--text-color-secondary)] hover:text-[var(--accent-color)] transition-colors flex items-center gap-2"
>
<Icons.RefreshCw size={16} />
</button>
)}
</div>
{/* Add Custom Tag */}
{showTagManager && (
<div className="mb-6 flex gap-2">
<input
type="text"
value={newTagInput}
onChange={(e) => setNewTagInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addCustomTag()}
placeholder="添加自定义标签..."
className="flex-1 bg-[var(--glass-bg)] backdrop-blur-xl border border-[var(--glass-border)] text-[var(--text-color)] px-4 py-2 focus:outline-none focus:border-[var(--accent-color)] transition-colors"
style={{ borderRadius: 'var(--radius-2xl)' }}
/>
<button
onClick={addCustomTag}
className="px-6 py-2 bg-[var(--accent-color)] text-white font-semibold hover:opacity-90 transition-opacity"
style={{ borderRadius: 'var(--radius-2xl)' }}
>
</button>
</div>
)}
{/* Tag Filter */}
<div className="mb-8 flex items-center gap-3 overflow-x-auto pb-3 pt-2 px-1 scrollbar-hide">
{tags.map((tag) => (
<div key={tag.id} className="relative flex-shrink-0">
<button
onClick={() => setSelectedTag(tag.id)}
className={`
px-6 py-2.5 text-sm font-semibold transition-all whitespace-nowrap
${selectedTag === tag.id
? 'bg-[var(--accent-color)] text-white shadow-md scale-105'
: 'bg-[var(--glass-bg)] backdrop-blur-xl text-[var(--text-color)] border border-[var(--glass-border)] hover:border-[var(--accent-color)] hover:scale-105'
}
`}
style={{ borderRadius: 'var(--radius-full)' }}
>
{tag.label}
</button>
{showTagManager && isCustomTag(tag.id) && (
<button
onClick={(e) => {
e.stopPropagation();
deleteTag(tag.id);
}}
className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 text-white flex items-center justify-center hover:bg-red-600 transition-colors"
style={{ borderRadius: 'var(--radius-full)' }}
>
<Icons.X size={14} />
</button>
)}
</div>
))}
</div>
{/* Movies Grid */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4 md:gap-6">
{movies.map((movie) => (
<Link
key={movie.id}
href={`/?q=${encodeURIComponent(movie.title)}`}
onClick={(e) => {
e.preventDefault();
handleMovieClick(movie);
}}
className="group cursor-pointer"
>
<Card hover className="overflow-hidden p-0 h-full">
<div className="relative aspect-[2/3] overflow-hidden bg-[var(--glass-bg)]">
<Image
src={movie.cover}
alt={movie.title}
fill
className="object-cover transition-transform duration-500 group-hover:scale-110"
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 20vw"
/>
{movie.rate && parseFloat(movie.rate) > 0 && (
<div
className="absolute top-2 right-2 bg-black/70 backdrop-blur-sm px-2.5 py-1.5 flex items-center gap-1.5"
style={{ borderRadius: 'var(--radius-full)' }}
>
<Icons.Star size={12} className="text-yellow-400 fill-yellow-400" />
<span className="text-xs font-bold text-white">
{movie.rate}
</span>
</div>
)}
</div>
<div className="p-3">
<h3 className="font-semibold text-sm text-[var(--text-color)] line-clamp-2 group-hover:text-[var(--accent-color)] transition-colors">
{movie.title}
</h3>
</div>
</Card>
</Link>
))}
</div>
{/* Prefetch Trigger - Earlier */}
{hasMore && !loading && <div ref={prefetchRef} className="h-1" />}
{/* Loading Indicator */}
{loading && (
<div className="flex justify-center py-12">
<div className="flex flex-col items-center gap-3">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-[var(--accent-color)] border-t-transparent"></div>
<p className="text-sm text-[var(--text-color-secondary)]">...</p>
</div>
</div>
)}
{/* Intersection Observer Target */}
{hasMore && !loading && <div ref={loadMoreRef} className="h-20" />}
{/* No More Content */}
{!hasMore && movies.length > 0 && (
<div className="text-center py-12">
<p className="text-[var(--text-color-secondary)]"></p>
</div>
)}
{/* Empty State */}
{!loading && movies.length === 0 && (
<div className="text-center py-20">
<Icons.Film size={64} className="text-[var(--text-color-secondary)] mx-auto mb-4" />
<p className="text-[var(--text-color-secondary)]"></p>
</div>
)}
</div>
);
}
+26 -4
View File
@@ -1,6 +1,6 @@
'use client';
import { useState, FormEvent } from 'react';
import { useState, FormEvent, useEffect } from 'react';
import { Input } from '@/components/ui/Input';
import { Button } from '@/components/ui/Button';
import { Icons } from '@/components/ui/Icon';
@@ -8,6 +8,7 @@ import { SearchLoadingAnimation } from '@/components/SearchLoadingAnimation';
interface SearchFormProps {
onSearch: (query: string) => void;
onClear?: () => void;
isLoading: boolean;
initialQuery?: string;
currentSource?: string;
@@ -20,6 +21,7 @@ interface SearchFormProps {
export function SearchForm({
onSearch,
onClear,
isLoading,
initialQuery = '',
currentSource = '',
@@ -31,13 +33,25 @@ export function SearchForm({
}: SearchFormProps) {
const [query, setQuery] = useState(initialQuery);
// Update query when initialQuery changes
useEffect(() => {
setQuery(initialQuery);
}, [initialQuery]);
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (query.trim()) {
if (query.trim() && !isLoading) {
onSearch(query);
}
};
const handleClear = () => {
setQuery('');
if (onClear) {
onClear();
}
};
return (
<form onSubmit={handleSubmit} className="max-w-3xl mx-auto">
<div className="relative group">
@@ -47,11 +61,19 @@ export function SearchForm({
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索电影、电视剧、综艺..."
className="text-lg pr-32"
disabled={isLoading}
/>
{query && (
<button
type="button"
onClick={handleClear}
className="absolute right-32 top-1/2 -translate-y-1/2 p-2 text-[var(--text-color-secondary)] hover:text-[var(--text-color)] transition-colors"
>
<Icons.X size={20} />
</button>
)}
<Button
type="submit"
disabled={isLoading || !query.trim()}
disabled={!query.trim()}
variant="primary"
className="absolute right-2 top-1/2 -translate-y-1/2 px-8"
>
+16
View File
@@ -310,4 +310,20 @@ export const Icons = {
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
),
Star: ({ 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}
>
<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>
),
};
+1 -1
View File
@@ -141,7 +141,7 @@ export function useParallelSearch(
* Perform parallel search with streaming results
*/
const performSearch = useCallback(async (searchQuery: string) => {
if (!searchQuery.trim() || loading) return;
if (!searchQuery.trim()) return;
// Abort any ongoing search
if (abortControllerRef.current) {
+20 -1
View File
@@ -1,7 +1,26 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'img3.doubanio.com',
},
{
protocol: 'https',
hostname: 'img1.doubanio.com',
},
{
protocol: 'https',
hostname: 'img2.doubanio.com',
},
{
protocol: 'https',
hostname: 'img9.doubanio.com',
},
],
},
};
export default nextConfig;