漫画增加推荐页
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { MangaRecommendType } from '@/lib/manga.types';
|
||||
import { suwayomiClient } from '@/lib/suwayomi.client';
|
||||
|
||||
import { getAuthorizedUsername } from '../_utils';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const username = await getAuthorizedUsername(request);
|
||||
if (username instanceof NextResponse) return username;
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const sourceId = searchParams.get('sourceId')?.trim();
|
||||
const page = Number(searchParams.get('page') || '1');
|
||||
const typeParam = searchParams.get('type')?.trim().toUpperCase();
|
||||
const type: MangaRecommendType = typeParam === 'LATEST' ? 'LATEST' : 'POPULAR';
|
||||
|
||||
if (!sourceId) {
|
||||
return NextResponse.json({ mangas: [], hasNextPage: false });
|
||||
}
|
||||
|
||||
const result = await suwayomiClient.getRecommendedManga(sourceId, type, page);
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: (error as Error).message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
+141
-199
@@ -1,16 +1,21 @@
|
||||
'use client';
|
||||
'use client';
|
||||
|
||||
import { Search } from 'lucide-react';
|
||||
import { Flame, Sparkles } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { deleteMangaShelf, getAllMangaShelf, saveMangaShelf } from '@/lib/db.client';
|
||||
import { MangaSearchItem, MangaShelfItem, MangaSource } from '@/lib/manga.types';
|
||||
import {
|
||||
MangaRecommendResult,
|
||||
MangaRecommendType,
|
||||
MangaSearchItem,
|
||||
MangaShelfItem,
|
||||
MangaSource,
|
||||
} from '@/lib/manga.types';
|
||||
|
||||
import CapsuleSwitch from '@/components/CapsuleSwitch';
|
||||
import MangaCard from '@/components/MangaCard';
|
||||
|
||||
const MANGA_SEARCH_STATE_KEY = 'manga_search_state';
|
||||
|
||||
function MangaCardSkeleton({ withButton = false }: { withButton?: boolean }) {
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
@@ -26,177 +31,114 @@ function MangaCardSkeleton({ withButton = false }: { withButton?: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
export default function MangaPage() {
|
||||
export default function MangaRecommendPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const urlQuery = searchParams.get('q')?.trim() || '';
|
||||
const urlSourceId = searchParams.get('sourceId') || '';
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const [sources, setSources] = useState<MangaSource[]>([]);
|
||||
const [sourceId, setSourceId] = useState('');
|
||||
const [results, setResults] = useState<MangaSearchItem[]>([]);
|
||||
const [shelf, setShelf] = useState<Record<string, MangaShelfItem>>({});
|
||||
const [recommendType, setRecommendType] = useState<MangaRecommendType>('POPULAR');
|
||||
const [result, setResult] = useState<MangaRecommendResult>({ mangas: [], hasNextPage: false });
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
const [lastSearchedQuery, setLastSearchedQuery] = useState('');
|
||||
const [lastSearchedSourceId, setLastSearchedSourceId] = useState('');
|
||||
const restoredRef = useRef(false);
|
||||
const [shelf, setShelf] = useState<Record<string, MangaShelfItem>>({});
|
||||
const loadMoreRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const getCacheKey = useCallback((keyword: string, selectedSourceId: string) => {
|
||||
return `manga_search_cache_${selectedSourceId || 'all'}_${keyword.trim()}`;
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const query = searchParams.get('q')?.trim();
|
||||
if (!query) return;
|
||||
|
||||
const getCachedResults = useCallback(
|
||||
(keyword: string, selectedSourceId: string) => {
|
||||
if (typeof window === 'undefined' || !keyword.trim()) return null;
|
||||
try {
|
||||
const cached = sessionStorage.getItem(getCacheKey(keyword, selectedSourceId));
|
||||
return cached ? (JSON.parse(cached) as MangaSearchItem[]) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[getCacheKey]
|
||||
);
|
||||
|
||||
const setCachedResults = useCallback(
|
||||
(keyword: string, selectedSourceId: string, nextResults: MangaSearchItem[]) => {
|
||||
if (typeof window === 'undefined' || !keyword.trim()) return;
|
||||
try {
|
||||
sessionStorage.setItem(getCacheKey(keyword, selectedSourceId), JSON.stringify(nextResults));
|
||||
} catch {
|
||||
// ignore session cache failures
|
||||
}
|
||||
},
|
||||
[getCacheKey]
|
||||
);
|
||||
|
||||
const saveSearchState = useCallback((nextState: { query: string; sourceId: string; results: MangaSearchItem[] }) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
sessionStorage.setItem(MANGA_SEARCH_STATE_KEY, JSON.stringify(nextState));
|
||||
} catch {
|
||||
// ignore session cache failures
|
||||
}
|
||||
}, []);
|
||||
|
||||
const restoreSearchState = useCallback(() => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
const cached = sessionStorage.getItem(MANGA_SEARCH_STATE_KEY);
|
||||
return cached
|
||||
? (JSON.parse(cached) as {
|
||||
query: string;
|
||||
sourceId: string;
|
||||
results: MangaSearchItem[];
|
||||
})
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
router.replace(`/manga/search?${params.toString()}`);
|
||||
}, [router, searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/manga/sources')
|
||||
.then((res) => res.json())
|
||||
.then((data) => setSources(data.sources || []))
|
||||
.then((data) => {
|
||||
const nextSources = data.sources || [];
|
||||
setSources(nextSources);
|
||||
setSourceId((prev) => prev || nextSources[0]?.id || '');
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
getAllMangaShelf().then(setShelf).catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
const performSearch = useCallback(
|
||||
async (keyword: string, selectedSourceId: string) => {
|
||||
const trimmedQuery = keyword.trim();
|
||||
if (!trimmedQuery) return;
|
||||
const fetchRecommend = useCallback(async (nextPage: number, append: boolean) => {
|
||||
if (!sourceId) return;
|
||||
|
||||
if (append) {
|
||||
setLoadingMore(true);
|
||||
} else {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setHasSearched(true);
|
||||
setLastSearchedQuery(trimmedQuery);
|
||||
setLastSearchedSourceId(selectedSourceId);
|
||||
}
|
||||
|
||||
const cached = getCachedResults(trimmedQuery, selectedSourceId);
|
||||
if (cached) {
|
||||
setResults(cached);
|
||||
saveSearchState({ query: trimmedQuery, sourceId: selectedSourceId, results: cached });
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
sourceId,
|
||||
type: recommendType,
|
||||
page: String(nextPage),
|
||||
});
|
||||
const res = await fetch(`/api/manga/recommend?${params.toString()}`);
|
||||
const data = (await res.json()) as MangaRecommendResult & { error?: string };
|
||||
if (!res.ok) throw new Error(data.error || '获取推荐失败');
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({ q: trimmedQuery });
|
||||
if (selectedSourceId) params.set('sourceId', selectedSourceId);
|
||||
const res = await fetch(`/api/manga/search?${params.toString()}`);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || '搜索失败');
|
||||
const nextResults = data.results || [];
|
||||
setResults(nextResults);
|
||||
setCachedResults(trimmedQuery, selectedSourceId, nextResults);
|
||||
saveSearchState({ query: trimmedQuery, sourceId: selectedSourceId, results: nextResults });
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
setResults([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setPage(nextPage);
|
||||
setResult((prev) => ({
|
||||
mangas: append ? [...prev.mangas, ...data.mangas] : data.mangas,
|
||||
hasNextPage: data.hasNextPage,
|
||||
}));
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
if (!append) {
|
||||
setResult({ mangas: [], hasNextPage: false });
|
||||
}
|
||||
},
|
||||
[getCachedResults, saveSearchState, setCachedResults]
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [recommendType, sourceId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!restoredRef.current) {
|
||||
restoredRef.current = true;
|
||||
if (!sourceId) return;
|
||||
void fetchRecommend(1, false);
|
||||
}, [fetchRecommend, sourceId]);
|
||||
|
||||
if (!urlQuery) {
|
||||
const cachedState = restoreSearchState();
|
||||
if (cachedState?.query?.trim()) {
|
||||
setQuery(cachedState.query);
|
||||
setSourceId(cachedState.sourceId || '');
|
||||
setResults(cachedState.results || []);
|
||||
setHasSearched(true);
|
||||
setLastSearchedQuery(cachedState.query);
|
||||
setLastSearchedSourceId(cachedState.sourceId || '');
|
||||
}
|
||||
return;
|
||||
useEffect(() => {
|
||||
const node = loadMoreRef.current;
|
||||
if (!node || loading || loadingMore || !result.hasNextPage) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const entry = entries[0];
|
||||
if (!entry?.isIntersecting || loadingMore || loading || !result.hasNextPage) return;
|
||||
void fetchRecommend(page + 1, true);
|
||||
},
|
||||
{
|
||||
rootMargin: '240px 0px',
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
setQuery(urlQuery);
|
||||
setSourceId(urlSourceId);
|
||||
observer.observe(node);
|
||||
return () => observer.disconnect();
|
||||
}, [fetchRecommend, loading, loadingMore, page, result.hasNextPage]);
|
||||
|
||||
if (!urlQuery) {
|
||||
setResults([]);
|
||||
setHasSearched(false);
|
||||
setLastSearchedQuery('');
|
||||
setLastSearchedSourceId('');
|
||||
setError('');
|
||||
return;
|
||||
}
|
||||
const sourceOptions = useMemo(
|
||||
() =>
|
||||
sources.map((source) => ({
|
||||
label: source.displayName || source.name,
|
||||
value: source.id,
|
||||
})),
|
||||
[sources]
|
||||
);
|
||||
|
||||
void performSearch(urlQuery, urlSourceId);
|
||||
}, [performSearch, restoreSearchState, urlQuery, urlSourceId]);
|
||||
|
||||
const handleSearch = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmedQuery = query.trim();
|
||||
if (!trimmedQuery) return;
|
||||
|
||||
const params = new URLSearchParams({ q: trimmedQuery });
|
||||
if (sourceId) params.set('sourceId', sourceId);
|
||||
router.replace(`/manga?${params.toString()}`);
|
||||
await performSearch(trimmedQuery, sourceId);
|
||||
};
|
||||
|
||||
const returnTo = useMemo(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (lastSearchedQuery) params.set('q', lastSearchedQuery);
|
||||
if (lastSearchedSourceId) params.set('sourceId', lastSearchedSourceId);
|
||||
const queryString = params.toString();
|
||||
return queryString ? `/manga?${queryString}` : '/manga';
|
||||
}, [lastSearchedQuery, lastSearchedSourceId]);
|
||||
const recommendOptions = [
|
||||
{ label: '热门', value: 'POPULAR', icon: <Flame className='h-3.5 w-3.5' /> },
|
||||
{ label: '最新', value: 'LATEST', icon: <Sparkles className='h-3.5 w-3.5' /> },
|
||||
];
|
||||
|
||||
const toggleShelf = async (item: MangaSearchItem) => {
|
||||
const key = `${item.sourceId}+${item.id}`;
|
||||
@@ -226,74 +168,74 @@ export default function MangaPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='mx-auto max-w-6xl'>
|
||||
<form
|
||||
className='mx-auto mb-8 max-w-4xl'
|
||||
onSubmit={handleSearch}
|
||||
>
|
||||
<div className='flex flex-col gap-3 lg:flex-row'>
|
||||
<div className='flex-1'>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder='搜索漫画标题'
|
||||
className='w-full rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3 text-sm outline-none transition focus:border-sky-500 dark:border-gray-700 dark:bg-gray-900'
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={sourceId}
|
||||
onChange={(e) => setSourceId(e.target.value)}
|
||||
className='rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3 text-sm dark:border-gray-700 dark:bg-gray-900 lg:w-56'
|
||||
>
|
||||
<option value=''>全部来源</option>
|
||||
{sources.map((source) => (
|
||||
<option key={source.id} value={source.id}>
|
||||
{source.displayName || source.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button className='inline-flex items-center justify-center gap-2 rounded-2xl bg-sky-600 px-6 py-3 text-sm font-medium text-white transition hover:bg-sky-700 lg:w-32'>
|
||||
<Search className='h-4 w-4' /> 搜索
|
||||
</button>
|
||||
<div className='mx-auto max-w-6xl space-y-6'>
|
||||
<section className='space-y-4 rounded-3xl border border-gray-200/70 bg-white/80 p-4 shadow-sm dark:border-gray-800 dark:bg-gray-950/70 sm:p-5'>
|
||||
<div className='space-y-2'>
|
||||
<div className='text-sm font-medium text-gray-700 dark:text-gray-200'>漫画源</div>
|
||||
{sourceOptions.length > 0 ? (
|
||||
<CapsuleSwitch options={sourceOptions} active={sourceId} onChange={setSourceId} className='max-w-full' />
|
||||
) : (
|
||||
<div className='rounded-2xl bg-gray-100 px-4 py-3 text-sm text-gray-500 dark:bg-gray-900 dark:text-gray-400'>
|
||||
暂无可用漫画源
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className='space-y-2'>
|
||||
<div className='text-sm font-medium text-gray-700 dark:text-gray-200'>推荐类型</div>
|
||||
<CapsuleSwitch
|
||||
options={recommendOptions}
|
||||
active={recommendType}
|
||||
onChange={(value) => setRecommendType(value as MangaRecommendType)}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h2 className='text-lg font-semibold'>搜索结果</h2>
|
||||
<h2 className='text-lg font-semibold'>推荐内容</h2>
|
||||
</div>
|
||||
|
||||
{error && <div className='mb-4 text-sm text-red-500'>{error}</div>}
|
||||
|
||||
{loading ? (
|
||||
<div className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
|
||||
{Array.from({ length: 12 }).map((_, index) => (
|
||||
<MangaCardSkeleton key={index} withButton />
|
||||
))}
|
||||
</div>
|
||||
) : results.length === 0 ? (
|
||||
) : result.mangas.length === 0 ? (
|
||||
<div className='rounded-2xl bg-gray-50 p-10 text-center text-sm text-gray-500 dark:bg-gray-900/50'>
|
||||
{hasSearched ? '没有找到相关漫画' : '请输入关键词开始搜索漫画'}
|
||||
{sourceId ? '当前源暂无推荐内容' : '请先选择漫画源'}
|
||||
</div>
|
||||
) : (
|
||||
<div className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
|
||||
{results.map((item) => {
|
||||
const key = `${item.sourceId}+${item.id}`;
|
||||
return (
|
||||
<div key={key} className='space-y-2'>
|
||||
<MangaCard
|
||||
item={item}
|
||||
href={`/manga/detail?mangaId=${item.id}&sourceId=${item.sourceId}&title=${encodeURIComponent(item.title)}&cover=${encodeURIComponent(item.cover)}&sourceName=${encodeURIComponent(item.sourceName)}&description=${encodeURIComponent(item.description || '')}&author=${encodeURIComponent(item.author || '')}&status=${encodeURIComponent(item.status || '')}&returnTo=${encodeURIComponent(returnTo)}`}
|
||||
subtitle={item.author || item.status || item.description}
|
||||
/>
|
||||
<button
|
||||
onClick={() => toggleShelf(item)}
|
||||
className='w-full rounded-2xl border border-gray-200 px-3 py-2 text-xs font-medium text-gray-700 transition hover:border-sky-500 hover:text-sky-600 dark:border-gray-700 dark:text-gray-200'
|
||||
>
|
||||
{shelf[key] ? '移出书架' : '加入书架'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<>
|
||||
<div className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
|
||||
{result.mangas.map((item) => {
|
||||
const key = `${item.sourceId}+${item.id}`;
|
||||
return (
|
||||
<div key={key} className='space-y-2'>
|
||||
<MangaCard
|
||||
item={item}
|
||||
href={`/manga/detail?mangaId=${item.id}&sourceId=${item.sourceId}&title=${encodeURIComponent(item.title)}&cover=${encodeURIComponent(item.cover)}&sourceName=${encodeURIComponent(item.sourceName)}&description=${encodeURIComponent(item.description || '')}&author=${encodeURIComponent(item.author || '')}&status=${encodeURIComponent(item.status || '')}&returnTo=${encodeURIComponent('/manga')}`}
|
||||
subtitle={item.author || item.status || item.description}
|
||||
badge={recommendType === 'POPULAR' ? '热门' : '最新'}
|
||||
/>
|
||||
<button
|
||||
onClick={() => toggleShelf(item)}
|
||||
className='w-full rounded-2xl border border-gray-200 px-3 py-2 text-xs font-medium text-gray-700 transition hover:border-sky-500 hover:text-sky-600 dark:border-gray-700 dark:text-gray-200'
|
||||
>
|
||||
{shelf[key] ? '移出书架' : '加入书架'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div ref={loadMoreRef} className='mt-6 flex min-h-10 items-center justify-center text-sm text-gray-500 dark:text-gray-400'>
|
||||
{loadingMore ? '正在加载更多...' : result.hasNextPage ? '继续下滑加载更多' : '没有更多了'}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
'use client';
|
||||
|
||||
import { Search } from 'lucide-react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { deleteMangaShelf, getAllMangaShelf, saveMangaShelf } from '@/lib/db.client';
|
||||
import { MangaSearchItem, MangaShelfItem, MangaSource } from '@/lib/manga.types';
|
||||
|
||||
import MangaCard from '@/components/MangaCard';
|
||||
|
||||
const MANGA_SEARCH_STATE_KEY = 'manga_search_state';
|
||||
|
||||
function MangaCardSkeleton({ withButton = false }: { withButton?: boolean }) {
|
||||
return (
|
||||
<div className='space-y-2'>
|
||||
<div className='overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-950'>
|
||||
<div className='aspect-[3/4] w-full animate-pulse bg-gray-200 dark:bg-gray-800' />
|
||||
<div className='space-y-3 p-3'>
|
||||
<div className='h-4 w-3/4 animate-pulse rounded bg-gray-200 dark:bg-gray-800' />
|
||||
<div className='h-3 w-1/2 animate-pulse rounded bg-gray-200 dark:bg-gray-800' />
|
||||
</div>
|
||||
</div>
|
||||
{withButton && <div className='h-9 w-full animate-pulse rounded-2xl bg-gray-200 dark:bg-gray-800' />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MangaSearchPage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const urlQuery = searchParams.get('q')?.trim() || '';
|
||||
const urlSourceId = searchParams.get('sourceId') || '';
|
||||
|
||||
const [query, setQuery] = useState('');
|
||||
const [sources, setSources] = useState<MangaSource[]>([]);
|
||||
const [sourceId, setSourceId] = useState('');
|
||||
const [results, setResults] = useState<MangaSearchItem[]>([]);
|
||||
const [shelf, setShelf] = useState<Record<string, MangaShelfItem>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
const [lastSearchedQuery, setLastSearchedQuery] = useState('');
|
||||
const [lastSearchedSourceId, setLastSearchedSourceId] = useState('');
|
||||
const restoredRef = useRef(false);
|
||||
|
||||
const getCacheKey = useCallback((keyword: string, selectedSourceId: string) => {
|
||||
return `manga_search_cache_${selectedSourceId || 'all'}_${keyword.trim()}`;
|
||||
}, []);
|
||||
|
||||
const getCachedResults = useCallback(
|
||||
(keyword: string, selectedSourceId: string) => {
|
||||
if (typeof window === 'undefined' || !keyword.trim()) return null;
|
||||
try {
|
||||
const cached = sessionStorage.getItem(getCacheKey(keyword, selectedSourceId));
|
||||
return cached ? (JSON.parse(cached) as MangaSearchItem[]) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[getCacheKey]
|
||||
);
|
||||
|
||||
const setCachedResults = useCallback(
|
||||
(keyword: string, selectedSourceId: string, nextResults: MangaSearchItem[]) => {
|
||||
if (typeof window === 'undefined' || !keyword.trim()) return;
|
||||
try {
|
||||
sessionStorage.setItem(getCacheKey(keyword, selectedSourceId), JSON.stringify(nextResults));
|
||||
} catch {
|
||||
// ignore session cache failures
|
||||
}
|
||||
},
|
||||
[getCacheKey]
|
||||
);
|
||||
|
||||
const saveSearchState = useCallback((nextState: { query: string; sourceId: string; results: MangaSearchItem[] }) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
try {
|
||||
sessionStorage.setItem(MANGA_SEARCH_STATE_KEY, JSON.stringify(nextState));
|
||||
} catch {
|
||||
// ignore session cache failures
|
||||
}
|
||||
}, []);
|
||||
|
||||
const restoreSearchState = useCallback(() => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
try {
|
||||
const cached = sessionStorage.getItem(MANGA_SEARCH_STATE_KEY);
|
||||
return cached
|
||||
? (JSON.parse(cached) as {
|
||||
query: string;
|
||||
sourceId: string;
|
||||
results: MangaSearchItem[];
|
||||
})
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/manga/sources')
|
||||
.then((res) => res.json())
|
||||
.then((data) => setSources(data.sources || []))
|
||||
.catch(() => undefined);
|
||||
|
||||
getAllMangaShelf().then(setShelf).catch(() => undefined);
|
||||
}, []);
|
||||
|
||||
const performSearch = useCallback(
|
||||
async (keyword: string, selectedSourceId: string) => {
|
||||
const trimmedQuery = keyword.trim();
|
||||
if (!trimmedQuery) return;
|
||||
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setHasSearched(true);
|
||||
setLastSearchedQuery(trimmedQuery);
|
||||
setLastSearchedSourceId(selectedSourceId);
|
||||
|
||||
const cached = getCachedResults(trimmedQuery, selectedSourceId);
|
||||
if (cached) {
|
||||
setResults(cached);
|
||||
saveSearchState({ query: trimmedQuery, sourceId: selectedSourceId, results: cached });
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({ q: trimmedQuery });
|
||||
if (selectedSourceId) params.set('sourceId', selectedSourceId);
|
||||
const res = await fetch(`/api/manga/search?${params.toString()}`);
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || '搜索失败');
|
||||
const nextResults = data.results || [];
|
||||
setResults(nextResults);
|
||||
setCachedResults(trimmedQuery, selectedSourceId, nextResults);
|
||||
saveSearchState({ query: trimmedQuery, sourceId: selectedSourceId, results: nextResults });
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
setResults([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[getCachedResults, saveSearchState, setCachedResults]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!restoredRef.current) {
|
||||
restoredRef.current = true;
|
||||
|
||||
if (!urlQuery) {
|
||||
const cachedState = restoreSearchState();
|
||||
if (cachedState?.query?.trim()) {
|
||||
setQuery(cachedState.query);
|
||||
setSourceId(cachedState.sourceId || '');
|
||||
setResults(cachedState.results || []);
|
||||
setHasSearched(true);
|
||||
setLastSearchedQuery(cachedState.query);
|
||||
setLastSearchedSourceId(cachedState.sourceId || '');
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setQuery(urlQuery);
|
||||
setSourceId(urlSourceId);
|
||||
|
||||
if (!urlQuery) {
|
||||
setResults([]);
|
||||
setHasSearched(false);
|
||||
setLastSearchedQuery('');
|
||||
setLastSearchedSourceId('');
|
||||
setError('');
|
||||
return;
|
||||
}
|
||||
|
||||
void performSearch(urlQuery, urlSourceId);
|
||||
}, [performSearch, restoreSearchState, urlQuery, urlSourceId]);
|
||||
|
||||
const handleSearch = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmedQuery = query.trim();
|
||||
if (!trimmedQuery) return;
|
||||
|
||||
const params = new URLSearchParams({ q: trimmedQuery });
|
||||
if (sourceId) params.set('sourceId', sourceId);
|
||||
router.replace(`/manga/search?${params.toString()}`);
|
||||
await performSearch(trimmedQuery, sourceId);
|
||||
};
|
||||
|
||||
const returnTo = useMemo(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (lastSearchedQuery) params.set('q', lastSearchedQuery);
|
||||
if (lastSearchedSourceId) params.set('sourceId', lastSearchedSourceId);
|
||||
const queryString = params.toString();
|
||||
return queryString ? `/manga/search?${queryString}` : '/manga/search';
|
||||
}, [lastSearchedQuery, lastSearchedSourceId]);
|
||||
|
||||
const toggleShelf = async (item: MangaSearchItem) => {
|
||||
const key = `${item.sourceId}+${item.id}`;
|
||||
if (shelf[key]) {
|
||||
await deleteMangaShelf(item.sourceId, item.id);
|
||||
setShelf((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const shelfItem: MangaShelfItem = {
|
||||
title: item.title,
|
||||
cover: item.cover,
|
||||
sourceId: item.sourceId,
|
||||
sourceName: item.sourceName,
|
||||
mangaId: item.id,
|
||||
saveTime: Date.now(),
|
||||
description: item.description,
|
||||
author: item.author,
|
||||
status: item.status,
|
||||
};
|
||||
await saveMangaShelf(item.sourceId, item.id, shelfItem);
|
||||
setShelf((prev) => ({ ...prev, [key]: shelfItem }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='mx-auto max-w-6xl'>
|
||||
<form className='mx-auto mb-8 max-w-4xl' onSubmit={handleSearch}>
|
||||
<div className='flex flex-col gap-3 lg:flex-row'>
|
||||
<div className='flex-1'>
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder='搜索漫画标题'
|
||||
className='w-full rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3 text-sm outline-none transition focus:border-sky-500 dark:border-gray-700 dark:bg-gray-900'
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={sourceId}
|
||||
onChange={(e) => setSourceId(e.target.value)}
|
||||
className='rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3 text-sm dark:border-gray-700 dark:bg-gray-900 lg:w-56'
|
||||
>
|
||||
<option value=''>全部来源</option>
|
||||
{sources.map((source) => (
|
||||
<option key={source.id} value={source.id}>
|
||||
{source.displayName || source.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button className='inline-flex items-center justify-center gap-2 rounded-2xl bg-sky-600 px-6 py-3 text-sm font-medium text-white transition hover:bg-sky-700 lg:w-32'>
|
||||
<Search className='h-4 w-4' /> 搜索
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section>
|
||||
<div className='mb-4 flex items-center justify-between'>
|
||||
<h2 className='text-lg font-semibold'>搜索结果</h2>
|
||||
</div>
|
||||
{error && <div className='mb-4 text-sm text-red-500'>{error}</div>}
|
||||
{loading ? (
|
||||
<div className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
|
||||
{Array.from({ length: 12 }).map((_, index) => (
|
||||
<MangaCardSkeleton key={index} withButton />
|
||||
))}
|
||||
</div>
|
||||
) : results.length === 0 ? (
|
||||
<div className='rounded-2xl bg-gray-50 p-10 text-center text-sm text-gray-500 dark:bg-gray-900/50'>
|
||||
{hasSearched ? '没有找到相关漫画' : '请输入关键词开始搜索漫画'}
|
||||
</div>
|
||||
) : (
|
||||
<div className='grid grid-cols-2 gap-4 md:grid-cols-4 xl:grid-cols-6'>
|
||||
{results.map((item) => {
|
||||
const key = `${item.sourceId}+${item.id}`;
|
||||
return (
|
||||
<div key={key} className='space-y-2'>
|
||||
<MangaCard
|
||||
item={item}
|
||||
href={`/manga/detail?mangaId=${item.id}&sourceId=${item.sourceId}&title=${encodeURIComponent(item.title)}&cover=${encodeURIComponent(item.cover)}&sourceName=${encodeURIComponent(item.sourceName)}&description=${encodeURIComponent(item.description || '')}&author=${encodeURIComponent(item.author || '')}&status=${encodeURIComponent(item.status || '')}&returnTo=${encodeURIComponent(returnTo)}`}
|
||||
subtitle={item.author || item.status || item.description}
|
||||
/>
|
||||
<button
|
||||
onClick={() => toggleShelf(item)}
|
||||
className='w-full rounded-2xl border border-gray-200 px-3 py-2 text-xs font-medium text-gray-700 transition hover:border-sky-500 hover:text-sky-600 dark:border-gray-700 dark:text-gray-200'
|
||||
>
|
||||
{shelf[key] ? '移出书架' : '加入书架'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,8 @@ import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
const tabs = [
|
||||
{ href: '/manga', label: '搜索' },
|
||||
{ href: '/manga', label: '推荐' },
|
||||
{ href: '/manga/search', label: '搜索' },
|
||||
{ href: '/manga/shelf', label: '书架' },
|
||||
{ href: '/manga/history', label: '历史' },
|
||||
];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { BookOpen, ChevronLeft, History, Home, List, Search, Settings2 } from 'lucide-react';
|
||||
import { BookOpen, ChevronLeft, Compass, History, List, Search, Settings2 } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
|
||||
@@ -14,13 +14,12 @@ interface MangaLayoutProps {
|
||||
}
|
||||
|
||||
const sectionTabs = [
|
||||
{ href: '/manga', label: '搜索', icon: Search },
|
||||
{ href: '/manga', label: '推荐', icon: Compass },
|
||||
{ href: '/manga/search', label: '搜索', icon: Search },
|
||||
{ href: '/manga/shelf', label: '书架', icon: BookOpen },
|
||||
{ href: '/manga/history', label: '历史', icon: History },
|
||||
];
|
||||
|
||||
const bottomTabs = [{ href: '/', label: '首页', icon: Home }, ...sectionTabs];
|
||||
|
||||
function getMeta(pathname: string, searchParams: ReturnType<typeof useSearchParams>) {
|
||||
if (pathname === '/manga/shelf') {
|
||||
return { title: '漫画书架', subtitle: '集中管理收藏的漫画' };
|
||||
@@ -28,6 +27,9 @@ function getMeta(pathname: string, searchParams: ReturnType<typeof useSearchPara
|
||||
if (pathname === '/manga/history') {
|
||||
return { title: '漫画历史', subtitle: '从上次阅读的位置继续' };
|
||||
}
|
||||
if (pathname === '/manga/search') {
|
||||
return { title: '漫画搜索', subtitle: '按标题和来源搜索漫画' };
|
||||
}
|
||||
if (pathname === '/manga/detail') {
|
||||
return {
|
||||
title: searchParams.get('title') || '漫画详情',
|
||||
@@ -48,7 +50,7 @@ function getMeta(pathname: string, searchParams: ReturnType<typeof useSearchPara
|
||||
backHref: `/manga/detail?mangaId=${encodeURIComponent(mangaId)}&sourceId=${encodeURIComponent(sourceId)}&title=${encodeURIComponent(title)}&cover=${encodeURIComponent(cover)}&sourceName=${encodeURIComponent(sourceName)}&returnTo=${encodeURIComponent(returnTo)}`,
|
||||
};
|
||||
}
|
||||
return { title: '漫画展馆', subtitle: '搜索漫画并加入书架' };
|
||||
return { title: '漫画推荐', subtitle: '按来源查看热门与最新漫画' };
|
||||
}
|
||||
|
||||
export default function MangaLayout({ children }: MangaLayoutProps) {
|
||||
@@ -167,7 +169,7 @@ export default function MangaLayout({ children }: MangaLayoutProps) {
|
||||
style={{ paddingBottom: 'env(safe-area-inset-bottom)' }}
|
||||
>
|
||||
<div className='mx-auto grid max-w-3xl grid-cols-4'>
|
||||
{bottomTabs.map((tab) => {
|
||||
{sectionTabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
const active = isActive(tab.href);
|
||||
return (
|
||||
|
||||
@@ -18,6 +18,13 @@ export interface MangaSearchItem {
|
||||
genre?: string;
|
||||
}
|
||||
|
||||
export type MangaRecommendType = 'POPULAR' | 'LATEST';
|
||||
|
||||
export interface MangaRecommendResult {
|
||||
mangas: MangaSearchItem[];
|
||||
hasNextPage: boolean;
|
||||
}
|
||||
|
||||
export interface MangaChapter {
|
||||
id: string;
|
||||
mangaId: string;
|
||||
|
||||
@@ -4,6 +4,8 @@ import { getConfig } from './config';
|
||||
import {
|
||||
MangaChapter,
|
||||
MangaDetail,
|
||||
MangaRecommendResult,
|
||||
MangaRecommendType,
|
||||
MangaSearchItem,
|
||||
MangaSource,
|
||||
} from './manga.types';
|
||||
@@ -216,6 +218,85 @@ export class SuwayomiClient {
|
||||
return results;
|
||||
}
|
||||
|
||||
async getRecommendedManga(
|
||||
sourceId: string,
|
||||
type: MangaRecommendType = 'POPULAR',
|
||||
page = 1
|
||||
): Promise<MangaRecommendResult> {
|
||||
if (!sourceId) {
|
||||
return { mangas: [], hasNextPage: false };
|
||||
}
|
||||
|
||||
const query = `
|
||||
fragment MANGA_BASE_FIELDS on MangaType {
|
||||
id
|
||||
title
|
||||
thumbnailUrl
|
||||
sourceId
|
||||
description
|
||||
author
|
||||
artist
|
||||
genre
|
||||
status
|
||||
}
|
||||
|
||||
mutation GET_SOURCE_MANGAS_FETCH($input: FetchSourceMangaInput!) {
|
||||
fetchSourceManga(input: $input) {
|
||||
hasNextPage
|
||||
mangas {
|
||||
...MANGA_BASE_FIELDS
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const sources = await this.getSources();
|
||||
const matchedSource = sources.find((item) => item.id === sourceId);
|
||||
|
||||
const data = await this.graphqlRequest<{
|
||||
fetchSourceManga?: {
|
||||
hasNextPage?: boolean;
|
||||
mangas?: Array<{
|
||||
id: string | number;
|
||||
title?: string;
|
||||
thumbnailUrl?: string;
|
||||
sourceId?: string | number;
|
||||
description?: string;
|
||||
author?: string;
|
||||
artist?: string;
|
||||
genre?: string;
|
||||
status?: string;
|
||||
}>;
|
||||
};
|
||||
}>(
|
||||
query,
|
||||
{
|
||||
input: {
|
||||
type,
|
||||
source: sourceId,
|
||||
page,
|
||||
},
|
||||
},
|
||||
'GET_SOURCE_MANGAS_FETCH'
|
||||
);
|
||||
|
||||
return {
|
||||
hasNextPage: Boolean(data.fetchSourceManga?.hasNextPage),
|
||||
mangas: (data.fetchSourceManga?.mangas || []).map((manga) => ({
|
||||
id: String(manga.id),
|
||||
sourceId: String(manga.sourceId || sourceId),
|
||||
sourceName: matchedSource?.displayName || matchedSource?.name || sourceId,
|
||||
title: manga.title || '未命名漫画',
|
||||
cover: buildSuwayomiImageProxyUrl(manga.thumbnailUrl || ''),
|
||||
description: manga.description,
|
||||
author: manga.author,
|
||||
artist: manga.artist,
|
||||
genre: manga.genre,
|
||||
status: manga.status,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async getChapters(mangaId: string): Promise<MangaChapter[]> {
|
||||
const mutation = `
|
||||
mutation GET_MANGA_CHAPTERS_FETCH($input: FetchChaptersInput!) {
|
||||
|
||||
Reference in New Issue
Block a user