import { useState, useEffect, useCallback } from "react"; import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning, Sparkle } from "@phosphor-icons/react"; import { gql, thumbUrl } from "../../lib/client"; import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, UPDATE_CHAPTERS_PROGRESS } from "../../lib/queries"; import type { Manga, Source, Chapter } from "../../lib/types"; import s from "./MigrateModal.module.css"; interface Props { manga: Manga; currentChapters: Chapter[]; onClose: () => void; onMigrated: (newManga: Manga) => void; } type Step = "source" | "search" | "confirm"; interface Match { manga: Manga; chapters: Chapter[]; readCount: number; similarity: number; } // Simple title similarity: normalise → word overlap / Jaccard function titleSimilarity(a: string, b: string): number { const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter(Boolean); const wordsA = new Set(norm(a)); const wordsB = new Set(norm(b)); if (wordsA.size === 0 || wordsB.size === 0) return 0; const intersection = [...wordsA].filter((w) => wordsB.has(w)).length; const union = new Set([...wordsA, ...wordsB]).size; return intersection / union; } export default function MigrateModal({ manga, currentChapters, onClose, onMigrated }: Props) { const [step, setStep] = useState("source"); const [sources, setSources] = useState([]); const [loadingSources, setLoadingSources] = useState(true); const [selectedSource, setSelectedSource] = useState(null); const [query, setQuery] = useState(manga.title); const [results, setResults] = useState<{ manga: Manga; similarity: number }[]>([]); const [searching, setSearching] = useState(false); const [selectedMatch, setSelectedMatch] = useState(null); const [loadingMatchId, setLoadingMatchId] = useState(null); const [migrating, setMigrating] = useState(false); const [error, setError] = useState(null); useEffect(() => { gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) .then((d) => setSources(d.sources.nodes.filter((s) => s.id !== "0" && s.id !== manga.source?.id))) .catch(console.error) .finally(() => setLoadingSources(false)); }, []); const searchSource = useCallback(async (src: Source, q: string) => { if (!src || !q.trim()) return; setSearching(true); setResults([]); setError(null); try { const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, { source: src.id, type: "SEARCH", page: 1, query: q.trim(), }); const scored = d.fetchSourceManga.mangas.map((m) => ({ manga: m, similarity: titleSimilarity(manga.title, m.title), })); // Sort by similarity desc so best matches float to top scored.sort((a, b) => b.similarity - a.similarity); setResults(scored); } catch (e: any) { setError(e.message); } finally { setSearching(false); } }, [manga.title]); function pickSource(src: Source) { setSelectedSource(src); setStep("search"); // Auto-search immediately with original title searchSource(src, query); } async function selectMatch(m: Manga, similarity: number) { setLoadingMatchId(m.id); setError(null); try { const d = await gql<{ fetchChapters: { chapters: Chapter[] } }>(FETCH_CHAPTERS, { mangaId: m.id }); const chapters = d.fetchChapters.chapters; const readCount = chapters.filter((c) => { const old = currentChapters.find((o) => Math.abs(o.chapterNumber - c.chapterNumber) < 0.01); return old?.isRead; }).length; setSelectedMatch({ manga: m, chapters, readCount, similarity }); setStep("confirm"); } catch (e: any) { setError(e.message); } finally { setLoadingMatchId(null); } } async function migrate() { if (!selectedMatch) return; setMigrating(true); setError(null); try { const { manga: newManga, chapters: newChapters } = selectedMatch; const oldByNum = new Map(currentChapters.map((c) => [Math.round(c.chapterNumber * 100), c])); const toMarkRead: number[] = []; const toMarkBookmarked: number[] = []; const progressUpdates: { id: number; lastPageRead: number }[] = []; for (const nc of newChapters) { const key = Math.round(nc.chapterNumber * 100); const old = oldByNum.get(key); if (!old) continue; if (old.isRead) toMarkRead.push(nc.id); if (old.isBookmarked) toMarkBookmarked.push(nc.id); if ((old.lastPageRead ?? 0) > 0 && !old.isRead) progressUpdates.push({ id: nc.id, lastPageRead: old.lastPageRead! }); } if (toMarkRead.length) await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkRead, isRead: true }); if (toMarkBookmarked.length) await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkBookmarked, isBookmarked: true }); for (const { id, lastPageRead } of progressUpdates) await gql(UPDATE_CHAPTERS_PROGRESS, { ids: [id], lastPageRead }); await gql(UPDATE_MANGA, { id: newManga.id, inLibrary: true }); await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false }); onMigrated({ ...newManga, inLibrary: true }); } catch (e: any) { setError(e.message); setMigrating(false); } } const readCount = currentChapters.filter((c) => c.isRead).length; const totalCount = currentChapters.length; const chapterDiff = selectedMatch ? selectedMatch.chapters.length - totalCount : 0; const STEPS: Step[] = ["source", "search", "confirm"]; const stepIdx = STEPS.indexOf(step); return (
e.target === e.currentTarget && onClose()}>
{/* ── Header ── */}
Migrate source {manga.title}
{/* ── Step indicators ── */}
{STEPS.map((st, i) => (
{i < stepIdx ? : i + 1} {st === "source" ? "Pick source" : st === "search" ? (selectedSource ? selectedSource.displayName : "Search") : "Confirm"}
))}
{/* ── Step 1: Pick source ── */} {step === "source" && (
{loadingSources ? (
) : sources.length === 0 ? (
No other sources installed.
) : ( sources.map((src) => ( )) )}
)} {/* ── Step 2: Search & pick match ── */} {step === "search" && (
{/* Source context pill */} {selectedSource && (
{ (e.target as HTMLImageElement).style.display = "none"; }} /> {selectedSource.displayName}
)}
setQuery(e.target.value)} onKeyDown={(e) => e.key === "Enter" && selectedSource && searchSource(selectedSource, query)} placeholder="Search title…" autoFocus />
{error &&

{error}

}
{searching && Array.from({ length: 6 }).map((_, i) => (
))} {!searching && results.map(({ manga: m, similarity }, idx) => ( ))} {!searching && results.length === 0 && !error && (
{query ? "No results — try a different title." : "Enter a title to search."}
)}
)} {/* ── Step 3: Confirm ── */} {step === "confirm" && selectedMatch && (
{manga.title}

{manga.title}

{manga.source?.displayName ?? "Unknown"}

Current
{selectedMatch.manga.title}

{selectedMatch.manga.title}

{selectedSource?.displayName ?? "Unknown"}

New
Title match 0.7 ? s.statGood : selectedMatch.similarity > 0.4 ? s.statWarn : s.statBad].join(" ")}> {Math.round(selectedMatch.similarity * 100)}%
Chapters on new source {selectedMatch.chapters.length} {chapterDiff !== 0 && ( {chapterDiff > 0 ? `+${chapterDiff}` : chapterDiff} vs current )}
Read progress to carry over {selectedMatch.readCount} / {readCount} chapters
{chapterDiff < -5 && (
New source has {Math.abs(chapterDiff)} fewer chapters — some content may be missing.
)}

The current entry will be removed from your library. Downloads are not transferred.

{error &&

{error}

}
)}
); }