mirror of
https://github.com/KuekHaoYang/KVideo.git
synced 2026-08-15 00:33:44 +08:00
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:
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user