RBJin's picture
Upload 20 files
81cb6e0 verified
import { useState, useCallback } from 'react';
import { Paper, SectionType } from '../types';
import { searchArxiv, fetchPaperSections } from '../utils/api';
import { isFavorited, addFavorite, removeFavorite, getCachedSections, setCachedSections } from '../utils/storage';
interface Props {
onViewDetail: (paper: Paper) => void;
onRefreshFavorites: () => void;
}
const SECTION_OPTIONS: { key: SectionType; label: string; labelZh: string }[] = [
{ key: 'abstract', label: 'Abstract', labelZh: '摘要' },
{ key: 'introduction', label: 'Introduction', labelZh: '引言' },
{ key: 'relatedWork', label: 'Related Work', labelZh: '相关工作' },
{ key: 'methods', label: 'Methods', labelZh: '方法' },
];
export default function SearchView({ onViewDetail, onRefreshFavorites }: Props) {
const [query, setQuery] = useState('');
const [papers, setPapers] = useState<Paper[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [total, setTotal] = useState(0);
const [page, setPage] = useState(0);
const [selectedSections, setSelectedSections] = useState<SectionType[]>(['abstract']);
const [batchLoading, setBatchLoading] = useState(false);
const [batchProgress, setBatchProgress] = useState({ done: 0, total: 0 });
const pageSize = 10;
const doSearch = useCallback(
async (startPage: number = 0) => {
if (!query.trim()) return;
setLoading(true);
setError('');
try {
const { papers: results, total: t } = await searchArxiv(
query.trim(),
startPage * pageSize,
pageSize
);
if (startPage === 0) {
setPapers(results);
} else {
setPapers((prev) => [...prev, ...results]);
}
setTotal(t);
setPage(startPage);
} catch (e) {
setError(e instanceof Error ? e.message : 'Search failed');
} finally {
setLoading(false);
}
},
[query]
);
const loadMore = () => {
doSearch(page + 1);
};
const loadSectionsForPaper = async (paper: Paper): Promise<Paper> => {
const needsFullText = selectedSections.some((s) => s !== 'abstract');
if (!needsFullText) return paper;
if (paper.sectionsLoaded) return paper;
// Check cache
const cached = getCachedSections(paper.id);
if (cached) {
return {
...paper,
introduction: cached.introduction,
relatedWork: cached.relatedWork,
methods: cached.methods,
references: cached.references,
sectionsLoaded: true,
};
}
try {
const sections = await fetchPaperSections(paper.id);
setCachedSections(paper.id, sections);
return {
...paper,
...sections,
sectionsLoaded: true,
};
} catch (e) {
return {
...paper,
sectionsError: e instanceof Error ? e.message : 'Failed to load',
sectionsLoaded: false,
};
}
};
const batchLoadSections = async () => {
const needsFullText = selectedSections.some((s) => s !== 'abstract');
if (!needsFullText) return;
setBatchLoading(true);
const toLoad = papers.filter((p) => !p.sectionsLoaded);
setBatchProgress({ done: 0, total: toLoad.length });
for (let i = 0; i < toLoad.length; i++) {
const updated = await loadSectionsForPaper(toLoad[i]);
setPapers((prev) => prev.map((p) => (p.id === updated.id ? updated : p)));
setBatchProgress({ done: i + 1, total: toLoad.length });
}
setBatchLoading(false);
};
const toggleSection = (section: SectionType) => {
setSelectedSections((prev) =>
prev.includes(section) ? prev.filter((s) => s !== section) : [...prev, section]
);
};
const toggleFavorite = (paper: Paper) => {
if (isFavorited(paper.id)) {
removeFavorite(paper.id);
} else {
addFavorite(paper.id, paper.title, paper.authors);
}
onRefreshFavorites();
setPapers((prev) => [...prev]); // trigger re-render
};
const viewDetail = async (paper: Paper) => {
let p = paper;
if (!p.sectionsLoaded && selectedSections.some((s) => s !== 'abstract')) {
p = { ...p, sectionsLoading: true };
setPapers((prev) => prev.map((x) => (x.id === p.id ? p : x)));
p = await loadSectionsForPaper(p);
p.sectionsLoading = false;
setPapers((prev) => prev.map((x) => (x.id === p.id ? p : x)));
}
onViewDetail(p);
};
return (
<div className="max-w-6xl mx-auto">
{/* Search Bar */}
<div className="bg-white rounded-2xl shadow-sm border border-gray-100 p-6 mb-6">
<div className="flex gap-3 mb-4">
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && doSearch(0)}
placeholder="Search ArXiv papers by keyword..."
className="flex-1 px-4 py-3 border border-gray-200 rounded-xl focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-800 placeholder-gray-400"
/>
<button
onClick={() => doSearch(0)}
disabled={loading || !query.trim()}
className="px-8 py-3 bg-indigo-600 text-white rounded-xl hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed font-medium transition-colors"
>
{loading ? (
<span className="flex items-center gap-2">
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
搜索中...
</span>
) : (
'🔍 搜索 Search'
)}
</button>
</div>
{/* Section Filters */}
<div className="flex flex-wrap items-center gap-3">
<span className="text-sm text-gray-500 font-medium">获取章节 Sections:</span>
{SECTION_OPTIONS.map((opt) => (
<label
key={opt.key}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg cursor-pointer text-sm transition-colors ${
selectedSections.includes(opt.key)
? 'bg-indigo-50 text-indigo-700 border border-indigo-200'
: 'bg-gray-50 text-gray-500 border border-gray-100 hover:bg-gray-100'
}`}
>
<input
type="checkbox"
checked={selectedSections.includes(opt.key)}
onChange={() => toggleSection(opt.key)}
className="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
{opt.label} {opt.labelZh}
</label>
))}
{papers.length > 0 && selectedSections.some((s) => s !== 'abstract') && (
<button
onClick={batchLoadSections}
disabled={batchLoading}
className="ml-auto px-4 py-1.5 text-sm bg-amber-50 text-amber-700 border border-amber-200 rounded-lg hover:bg-amber-100 disabled:opacity-50 transition-colors"
>
{batchLoading
? `加载中 Loading... (${batchProgress.done}/${batchProgress.total})`
: '📥 批量加载全文 Batch Load Sections'}
</button>
)}
</div>
</div>
{/* Results Info */}
{total > 0 && (
<div className="mb-4 text-sm text-gray-500">
共找到 Found <span className="font-semibold text-gray-700">{total.toLocaleString()}</span> 篇文章,
已加载 loaded <span className="font-semibold text-gray-700">{papers.length}</span>
</div>
)}
{error && (
<div className="mb-4 p-4 bg-red-50 text-red-700 rounded-xl border border-red-100">
⚠️ {error}
</div>
)}
{/* Paper List */}
<div className="space-y-4">
{papers.map((paper) => (
<PaperCard
key={paper.id}
paper={paper}
selectedSections={selectedSections}
onViewDetail={() => viewDetail(paper)}
onToggleFavorite={() => toggleFavorite(paper)}
isFav={isFavorited(paper.id)}
/>
))}
</div>
{/* Load More */}
{papers.length < total && (
<div className="mt-6 text-center">
<button
onClick={loadMore}
disabled={loading}
className="px-8 py-3 bg-white border border-gray-200 rounded-xl hover:bg-gray-50 text-gray-700 font-medium transition-colors disabled:opacity-50"
>
{loading ? '加载中 Loading...' : `加载更多 Load More (${papers.length}/${total})`}
</button>
</div>
)}
{!loading && papers.length === 0 && query && (
<div className="text-center py-20 text-gray-400">
<div className="text-5xl mb-4">📄</div>
<p>未找到结果 No results found</p>
<p className="text-sm mt-1">请尝试其他关键词 Try different keywords</p>
</div>
)}
{!query && papers.length === 0 && (
<div className="text-center py-20 text-gray-400">
<div className="text-5xl mb-4">🔬</div>
<p className="text-lg font-medium">ArXiv Research Explorer</p>
<p className="text-sm mt-2">输入关键词搜索学术论文</p>
<p className="text-sm">Enter keywords to search academic papers</p>
</div>
)}
</div>
);
}
function PaperCard({
paper,
selectedSections,
onViewDetail,
onToggleFavorite,
isFav,
}: {
paper: Paper;
selectedSections: SectionType[];
onViewDetail: () => void;
onToggleFavorite: () => void;
isFav: boolean;
}) {
const date = paper.published ? new Date(paper.published).toLocaleDateString() : '';
const previewText =
paper.abstract.length > 300 ? paper.abstract.substring(0, 300) + '...' : paper.abstract;
return (
<div className="bg-white rounded-xl border border-gray-100 shadow-sm hover:shadow-md transition-shadow p-5">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<h3
className="text-lg font-semibold text-gray-800 hover:text-indigo-600 cursor-pointer leading-snug"
onClick={onViewDetail}
>
{paper.title}
</h3>
<div className="mt-1.5 flex flex-wrap items-center gap-2 text-sm text-gray-500">
<span>{paper.authors.slice(0, 3).join(', ')}{paper.authors.length > 3 ? ` +${paper.authors.length - 3}` : ''}</span>
<span>·</span>
<span>{date}</span>
<span>·</span>
<span className="text-xs bg-gray-100 px-2 py-0.5 rounded">{paper.id}</span>
{paper.categories.slice(0, 2).map((c) => (
<span key={c} className="text-xs bg-blue-50 text-blue-600 px-2 py-0.5 rounded">
{c}
</span>
))}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={onToggleFavorite}
className={`p-2 rounded-lg transition-colors ${
isFav
? 'bg-amber-50 text-amber-500 hover:bg-amber-100'
: 'bg-gray-50 text-gray-400 hover:bg-gray-100 hover:text-amber-500'
}`}
title={isFav ? '取消收藏 Unfavorite' : '收藏 Favorite'}
>
{isFav ? '★' : '☆'}
</button>
</div>
</div>
{selectedSections.includes('abstract') && (
<p className="mt-3 text-sm text-gray-600 leading-relaxed">{previewText}</p>
)}
<div className="mt-3 flex items-center gap-3">
<button
onClick={onViewDetail}
className="px-4 py-1.5 text-sm bg-indigo-50 text-indigo-600 rounded-lg hover:bg-indigo-100 transition-colors font-medium"
>
📖 查看详情 View Details
</button>
<a
href={paper.pdfLink}
target="_blank"
rel="noopener noreferrer"
className="px-4 py-1.5 text-sm bg-emerald-50 text-emerald-600 rounded-lg hover:bg-emerald-100 transition-colors font-medium"
>
📄 PDF
</a>
<a
href={`https://arxiv.org/abs/${paper.id}`}
target="_blank"
rel="noopener noreferrer"
className="px-4 py-1.5 text-sm bg-gray-50 text-gray-600 rounded-lg hover:bg-gray-100 transition-colors font-medium"
>
🔗 ArXiv
</a>
{paper.sectionsLoading && (
<span className="text-sm text-gray-400 flex items-center gap-1">
<svg className="animate-spin h-3 w-3" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
加载章节中...
</span>
)}
{paper.sectionsLoaded && (
<span className="text-sm text-emerald-500">✓ 已加载 Loaded</span>
)}
{paper.sectionsError && (
<span className="text-sm text-red-400">⚠ {paper.sectionsError}</span>
)}
</div>
</div>
);
}