diff --git a/src/app/api/manga/recommend/route.ts b/src/app/api/manga/recommend/route.ts
new file mode 100644
index 0000000..ccb174a
--- /dev/null
+++ b/src/app/api/manga/recommend/route.ts
@@ -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 });
+ }
+}
diff --git a/src/app/manga/page.tsx b/src/app/manga/page.tsx
index fe75d4f..8ea8836 100644
--- a/src/app/manga/page.tsx
+++ b/src/app/manga/page.tsx
@@ -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 (
@@ -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
([]);
const [sourceId, setSourceId] = useState('');
- const [results, setResults] = useState([]);
- const [shelf, setShelf] = useState>({});
+ const [recommendType, setRecommendType] = useState('POPULAR');
+ const [result, setResult] = useState({ 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>({});
+ const loadMoreRef = useRef(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: },
+ { label: '最新', value: 'LATEST', icon: },
+ ];
const toggleShelf = async (item: MangaSearchItem) => {
const key = `${item.sourceId}+${item.id}`;
@@ -226,74 +168,74 @@ export default function MangaPage() {
};
return (
-