From f289607c364a23589cc0405bf6aec825bcc867c7 Mon Sep 17 00:00:00 2001 From: mtvpls Date: Fri, 17 Apr 2026 21:01:44 +0800 Subject: [PATCH] =?UTF-8?q?=E6=BC=AB=E7=94=BB=E5=A2=9E=E5=8A=A0=E6=8E=A8?= =?UTF-8?q?=E8=8D=90=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/manga/recommend/route.ts | 30 +++ src/app/manga/page.tsx | 340 +++++++++++---------------- src/app/manga/search/page.tsx | 298 +++++++++++++++++++++++ src/components/MangaSectionNav.tsx | 3 +- src/components/manga/MangaLayout.tsx | 14 +- src/lib/manga.types.ts | 7 + src/lib/suwayomi.client.ts | 81 +++++++ 7 files changed, 567 insertions(+), 206 deletions(-) create mode 100644 src/app/api/manga/recommend/route.ts create mode 100644 src/app/manga/search/page.tsx 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 ( -
-
-
-
- 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' - /> -
- - +
+
+
+
漫画源
+ {sourceOptions.length > 0 ? ( + + ) : ( +
+ 暂无可用漫画源 +
+ )}
- + +
+
推荐类型
+ setRecommendType(value as MangaRecommendType)} + /> +
+
-

搜索结果

+

推荐内容

+ {error &&
{error}
} + {loading ? (
{Array.from({ length: 12 }).map((_, index) => ( ))}
- ) : results.length === 0 ? ( + ) : result.mangas.length === 0 ? (
- {hasSearched ? '没有找到相关漫画' : '请输入关键词开始搜索漫画'} + {sourceId ? '当前源暂无推荐内容' : '请先选择漫画源'}
) : ( -
- {results.map((item) => { - const key = `${item.sourceId}+${item.id}`; - return ( -
- - -
- ); - })} -
+ <> +
+ {result.mangas.map((item) => { + const key = `${item.sourceId}+${item.id}`; + return ( +
+ + +
+ ); + })} +
+ +
+ {loadingMore ? '正在加载更多...' : result.hasNextPage ? '继续下滑加载更多' : '没有更多了'} +
+ )}
diff --git a/src/app/manga/search/page.tsx b/src/app/manga/search/page.tsx new file mode 100644 index 0000000..8804b7c --- /dev/null +++ b/src/app/manga/search/page.tsx @@ -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 ( +
+
+
+
+
+
+
+
+ {withButton &&
} +
+ ); +} + +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([]); + const [sourceId, setSourceId] = useState(''); + const [results, setResults] = useState([]); + const [shelf, setShelf] = useState>({}); + 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 ( +
+
+
+
+ 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' + /> +
+ + +
+
+ +
+
+

搜索结果

+
+ {error &&
{error}
} + {loading ? ( +
+ {Array.from({ length: 12 }).map((_, index) => ( + + ))} +
+ ) : results.length === 0 ? ( +
+ {hasSearched ? '没有找到相关漫画' : '请输入关键词开始搜索漫画'} +
+ ) : ( +
+ {results.map((item) => { + const key = `${item.sourceId}+${item.id}`; + return ( +
+ + +
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/src/components/MangaSectionNav.tsx b/src/components/MangaSectionNav.tsx index 153f1e3..8d285b4 100644 --- a/src/components/MangaSectionNav.tsx +++ b/src/components/MangaSectionNav.tsx @@ -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: '历史' }, ]; diff --git a/src/components/manga/MangaLayout.tsx b/src/components/manga/MangaLayout.tsx index b42af3b..e0028c0 100644 --- a/src/components/manga/MangaLayout.tsx +++ b/src/components/manga/MangaLayout.tsx @@ -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) { if (pathname === '/manga/shelf') { return { title: '漫画书架', subtitle: '集中管理收藏的漫画' }; @@ -28,6 +27,9 @@ function getMeta(pathname: string, searchParams: ReturnType
- {bottomTabs.map((tab) => { + {sectionTabs.map((tab) => { const Icon = tab.icon; const active = isActive(tab.href); return ( diff --git a/src/lib/manga.types.ts b/src/lib/manga.types.ts index c190871..07287f5 100644 --- a/src/lib/manga.types.ts +++ b/src/lib/manga.types.ts @@ -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; diff --git a/src/lib/suwayomi.client.ts b/src/lib/suwayomi.client.ts index 2018b63..bfe5733 100644 --- a/src/lib/suwayomi.client.ts +++ b/src/lib/suwayomi.client.ts @@ -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 { + 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 { const mutation = ` mutation GET_MANGA_CHAPTERS_FETCH($input: FetchChaptersInput!) {