mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
[V1] Patched MangaPreview & Added Themes (Contrast)
This commit is contained in:
@@ -475,4 +475,154 @@
|
||||
background: rgba(var(--color-error-rgb, 180, 60, 60), 0.08);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid rgba(var(--color-error-rgb, 180, 60, 60), 0.2);
|
||||
}
|
||||
/* ── Source context pill (step 2 header) ── */
|
||||
.searchContext {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.searchContextIcon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: var(--radius-sm);
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.searchContextName {
|
||||
flex: 1;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.searchContextChange {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
color: var(--accent-fg);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
transition: opacity var(--t-base);
|
||||
}
|
||||
.searchContextChange:hover { opacity: 0.75; }
|
||||
|
||||
/* ── Result row: updated layout with similarity ── */
|
||||
.resultInfo {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.resultMeta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
}
|
||||
|
||||
.bestMatchBadge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 9px;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
color: var(--accent-fg);
|
||||
background: var(--accent-muted);
|
||||
border: 1px solid var(--accent-dim);
|
||||
padding: 1px 5px;
|
||||
border-radius: var(--radius-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.simBar {
|
||||
width: 48px;
|
||||
height: 3px;
|
||||
background: var(--bg-overlay);
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.simFill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--accent);
|
||||
border-radius: var(--radius-full);
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.simLabel {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Confirm step additions ── */
|
||||
.confirmDivider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.confirmTag {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
text-transform: uppercase;
|
||||
padding: 2px 7px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim);
|
||||
color: var(--text-faint);
|
||||
}
|
||||
|
||||
.confirmTagNew {
|
||||
background: var(--accent-muted);
|
||||
border-color: var(--accent-dim);
|
||||
color: var(--accent-fg);
|
||||
}
|
||||
|
||||
.statGood { color: var(--color-success) !important; }
|
||||
.statWarn { color: #d97706 !important; }
|
||||
.statBad { color: var(--color-error) !important; }
|
||||
|
||||
.chapterDiff {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-2xs);
|
||||
color: #d97706;
|
||||
letter-spacing: var(--tracking-wide);
|
||||
margin-left: var(--sp-2);
|
||||
}
|
||||
|
||||
.warnBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-3);
|
||||
background: rgba(217, 119, 6, 0.08);
|
||||
border: 1px solid rgba(217, 119, 6, 0.25);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-xs);
|
||||
color: #d97706;
|
||||
line-height: var(--leading-snug);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning } from "@phosphor-icons/react";
|
||||
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";
|
||||
@@ -18,20 +18,33 @@ 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<Step>("source");
|
||||
const [sources, setSources] = useState<Source[]>([]);
|
||||
const [step, setStep] = useState<Step>("source");
|
||||
const [sources, setSources] = useState<Source[]>([]);
|
||||
const [loadingSources, setLoadingSources] = useState(true);
|
||||
const [selectedSource, setSelectedSource] = useState<Source | null>(null);
|
||||
const [query, setQuery] = useState(manga.title);
|
||||
const [results, setResults] = useState<Manga[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [selectedMatch, setSelectedMatch] = useState<Match | null>(null);
|
||||
const [loadingMatch, setLoadingMatch] = useState(false);
|
||||
const [migrating, setMigrating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [query, setQuery] = useState(manga.title);
|
||||
const [results, setResults] = useState<{ manga: Manga; similarity: number }[]>([]);
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [selectedMatch, setSelectedMatch] = useState<Match | null>(null);
|
||||
const [loadingMatchId, setLoadingMatchId] = useState<number | null>(null);
|
||||
const [migrating, setMigrating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||
@@ -40,25 +53,38 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
|
||||
.finally(() => setLoadingSources(false));
|
||||
}, []);
|
||||
|
||||
async function searchSource() {
|
||||
if (!selectedSource || !query.trim()) return;
|
||||
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: selectedSource.id, type: "SEARCH", page: 1, query: query.trim(),
|
||||
source: src.id, type: "SEARCH", page: 1, query: q.trim(),
|
||||
});
|
||||
setResults(d.fetchSourceManga.mangas);
|
||||
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) {
|
||||
setLoadingMatch(true);
|
||||
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 });
|
||||
@@ -67,12 +93,12 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
|
||||
const old = currentChapters.find((o) => Math.abs(o.chapterNumber - c.chapterNumber) < 0.01);
|
||||
return old?.isRead;
|
||||
}).length;
|
||||
setSelectedMatch({ manga: m, chapters, readCount });
|
||||
setSelectedMatch({ manga: m, chapters, readCount, similarity });
|
||||
setStep("confirm");
|
||||
} catch (e: any) {
|
||||
setError(e.message);
|
||||
} finally {
|
||||
setLoadingMatch(false);
|
||||
setLoadingMatchId(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,8 +108,6 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
|
||||
setError(null);
|
||||
try {
|
||||
const { manga: newManga, chapters: newChapters } = selectedMatch;
|
||||
|
||||
// Build read/bookmark/progress maps from old chapters keyed by chapterNumber
|
||||
const oldByNum = new Map(currentChapters.map((c) => [Math.round(c.chapterNumber * 100), c]));
|
||||
|
||||
const toMarkRead: number[] = [];
|
||||
@@ -96,25 +120,17 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
|
||||
if (!old) continue;
|
||||
if (old.isRead) toMarkRead.push(nc.id);
|
||||
if (old.isBookmarked) toMarkBookmarked.push(nc.id);
|
||||
if ((old.lastPageRead ?? 0) > 0 && !old.isRead) {
|
||||
if ((old.lastPageRead ?? 0) > 0 && !old.isRead)
|
||||
progressUpdates.push({ id: nc.id, lastPageRead: old.lastPageRead! });
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate read state
|
||||
if (toMarkRead.length) {
|
||||
if (toMarkRead.length)
|
||||
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkRead, isRead: true });
|
||||
}
|
||||
// Migrate bookmarks
|
||||
if (toMarkBookmarked.length) {
|
||||
if (toMarkBookmarked.length)
|
||||
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkBookmarked, isBookmarked: true });
|
||||
}
|
||||
// Migrate in-progress pages one by one (different lastPageRead per chapter)
|
||||
for (const { id, lastPageRead } of progressUpdates) {
|
||||
for (const { id, lastPageRead } of progressUpdates)
|
||||
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: [id], lastPageRead });
|
||||
}
|
||||
|
||||
// Add new to library, remove old
|
||||
await gql(UPDATE_MANGA, { id: newManga.id, inLibrary: true });
|
||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false });
|
||||
|
||||
@@ -125,33 +141,48 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
|
||||
}
|
||||
}
|
||||
|
||||
const readCount = currentChapters.filter((c) => c.isRead).length;
|
||||
const totalCount = currentChapters.length;
|
||||
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 (
|
||||
<div className={s.overlay} onClick={(e) => e.target === e.currentTarget && onClose()}>
|
||||
<div className={s.modal}>
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className={s.modalHeader}>
|
||||
<div className={s.modalTitle}>
|
||||
<span className={s.modalTitleLabel}>Migrate source</span>
|
||||
<span className={s.modalTitleManga}>{manga.title}</span>
|
||||
</div>
|
||||
<button className={s.closeBtn} onClick={onClose}>
|
||||
<X size={14} weight="light" />
|
||||
</button>
|
||||
<button className={s.closeBtn} onClick={onClose}><X size={14} weight="light" /></button>
|
||||
</div>
|
||||
|
||||
{/* ── Step indicators ── */}
|
||||
<div className={s.steps}>
|
||||
{(["source", "search", "confirm"] as Step[]).map((st, i) => (
|
||||
<div key={st} className={[s.step, step === st ? s.stepActive : "", i < ["source","search","confirm"].indexOf(step) ? s.stepDone : ""].join(" ").trim()}>
|
||||
<span className={s.stepDot}>{i < ["source","search","confirm"].indexOf(step) ? <Check size={9} weight="bold" /> : i + 1}</span>
|
||||
<span className={s.stepLabel}>{st.charAt(0).toUpperCase() + st.slice(1)}</span>
|
||||
{STEPS.map((st, i) => (
|
||||
<div key={st}
|
||||
className={[s.step, step === st ? s.stepActive : "", i < stepIdx ? s.stepDone : ""].join(" ").trim()}>
|
||||
<span className={s.stepDot}>
|
||||
{i < stepIdx ? <Check size={9} weight="bold" /> : i + 1}
|
||||
</span>
|
||||
<span className={s.stepLabel}>
|
||||
{st === "source" ? "Pick source"
|
||||
: st === "search" ? (selectedSource ? selectedSource.displayName : "Search")
|
||||
: "Confirm"}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={s.body}>
|
||||
|
||||
{/* ── Step 1: Pick source ── */}
|
||||
{step === "source" && (
|
||||
<div className={s.sourceList}>
|
||||
@@ -163,11 +194,9 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
|
||||
<div className={s.centered}><span className={s.hint}>No other sources installed.</span></div>
|
||||
) : (
|
||||
sources.map((src) => (
|
||||
<button
|
||||
key={src.id}
|
||||
<button key={src.id}
|
||||
className={[s.sourceRow, selectedSource?.id === src.id ? s.sourceRowActive : ""].join(" ").trim()}
|
||||
onClick={() => { setSelectedSource(src); setStep("search"); searchSource(); }}
|
||||
>
|
||||
onClick={() => pickSource(src)}>
|
||||
<img src={thumbUrl(src.iconUrl)} alt={src.name} className={s.sourceIcon}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
<div className={s.sourceInfo}>
|
||||
@@ -184,22 +213,34 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
|
||||
{/* ── Step 2: Search & pick match ── */}
|
||||
{step === "search" && (
|
||||
<div className={s.searchStep}>
|
||||
|
||||
{/* Source context pill */}
|
||||
{selectedSource && (
|
||||
<div className={s.searchContext}>
|
||||
<img src={thumbUrl(selectedSource.iconUrl)} alt="" className={s.searchContextIcon}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
<span className={s.searchContextName}>{selectedSource.displayName}</span>
|
||||
<button className={s.searchContextChange} onClick={() => { setStep("source"); setResults([]); }}>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={s.searchRow}>
|
||||
<div className={s.searchBar}>
|
||||
<MagnifyingGlass size={13} weight="light" className={s.searchIcon} />
|
||||
<input
|
||||
className={s.searchInput}
|
||||
value={query}
|
||||
<input className={s.searchInput} value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && searchSource()}
|
||||
autoFocus
|
||||
/>
|
||||
onKeyDown={(e) => e.key === "Enter" && selectedSource && searchSource(selectedSource, query)}
|
||||
placeholder="Search title…"
|
||||
autoFocus />
|
||||
</div>
|
||||
<button className={s.searchBtn} onClick={searchSource} disabled={searching}>
|
||||
{searching ? <CircleNotch size={13} weight="light" className="anim-spin" /> : "Search"}
|
||||
</button>
|
||||
<button className={s.backBtn} onClick={() => { setStep("source"); setResults([]); }}>
|
||||
Back
|
||||
<button className={s.searchBtn}
|
||||
onClick={() => selectedSource && searchSource(selectedSource, query)}
|
||||
disabled={searching || !selectedSource}>
|
||||
{searching
|
||||
? <CircleNotch size={13} weight="light" className="anim-spin" />
|
||||
: <><MagnifyingGlass size={12} weight="bold" /> Search</>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -211,25 +252,40 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
|
||||
<div className={["skeleton", s.skCover].join(" ")} />
|
||||
<div className={s.skMeta}>
|
||||
<div className={["skeleton", s.skTitle].join(" ")} />
|
||||
<div className={["skeleton", s.skTitle].join(" ")} style={{ width: "40%" }} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{!searching && results.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
className={s.resultRow}
|
||||
onClick={() => selectMatch(m)}
|
||||
disabled={loadingMatch}
|
||||
>
|
||||
{!searching && results.map(({ manga: m, similarity }, idx) => (
|
||||
<button key={m.id} className={s.resultRow}
|
||||
onClick={() => selectMatch(m, similarity)}
|
||||
disabled={loadingMatchId !== null}>
|
||||
<div className={s.resultCoverWrap}>
|
||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} className={s.resultCover} />
|
||||
</div>
|
||||
<span className={s.resultTitle}>{m.title}</span>
|
||||
{loadingMatch && <CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)", marginLeft: "auto" }} />}
|
||||
<div className={s.resultInfo}>
|
||||
<span className={s.resultTitle}>{m.title}</span>
|
||||
<div className={s.resultMeta}>
|
||||
{idx === 0 && similarity > 0.5 && (
|
||||
<span className={s.bestMatchBadge}>
|
||||
<Sparkle size={9} weight="fill" /> Best match
|
||||
</span>
|
||||
)}
|
||||
<span className={s.simBar}>
|
||||
<span className={s.simFill} style={{ width: `${Math.round(similarity * 100)}%` }} />
|
||||
</span>
|
||||
<span className={s.simLabel}>{Math.round(similarity * 100)}% match</span>
|
||||
</div>
|
||||
</div>
|
||||
{loadingMatchId === m.id
|
||||
? <CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)", flexShrink: 0 }} />
|
||||
: <ArrowRight size={13} weight="light" style={{ color: "var(--text-faint)", flexShrink: 0, opacity: 0.5 }} />}
|
||||
</button>
|
||||
))}
|
||||
{!searching && results.length === 0 && query && (
|
||||
<div className={s.centered}><span className={s.hint}>No results.</span></div>
|
||||
{!searching && results.length === 0 && !error && (
|
||||
<div className={s.centered}>
|
||||
<span className={s.hint}>{query ? "No results — try a different title." : "Enter a title to search."}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -245,9 +301,12 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
|
||||
</div>
|
||||
<p className={s.confirmTitle}>{manga.title}</p>
|
||||
<p className={s.confirmSource}>{manga.source?.displayName ?? "Unknown"}</p>
|
||||
<span className={s.confirmTag}>Current</span>
|
||||
</div>
|
||||
|
||||
<ArrowRight size={20} weight="light" className={s.confirmArrow} />
|
||||
<div className={s.confirmDivider}>
|
||||
<ArrowRight size={16} weight="light" className={s.confirmArrow} />
|
||||
</div>
|
||||
|
||||
<div className={s.confirmManga}>
|
||||
<div className={s.confirmCoverWrap}>
|
||||
@@ -255,24 +314,39 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
|
||||
</div>
|
||||
<p className={s.confirmTitle}>{selectedMatch.manga.title}</p>
|
||||
<p className={s.confirmSource}>{selectedSource?.displayName ?? "Unknown"}</p>
|
||||
<span className={[s.confirmTag, s.confirmTagNew].join(" ")}>New</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={s.confirmStats}>
|
||||
<div className={s.statRow}>
|
||||
<span className={s.statLabel}>Title match</span>
|
||||
<span className={[s.statVal, selectedMatch.similarity > 0.7 ? s.statGood : selectedMatch.similarity > 0.4 ? s.statWarn : s.statBad].join(" ")}>
|
||||
{Math.round(selectedMatch.similarity * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className={s.statRow}>
|
||||
<span className={s.statLabel}>Chapters on new source</span>
|
||||
<span className={s.statVal}>{selectedMatch.chapters.length}</span>
|
||||
<span className={[s.statVal, chapterDiff < -5 ? s.statWarn : ""].join(" ").trim()}>
|
||||
{selectedMatch.chapters.length}
|
||||
{chapterDiff !== 0 && (
|
||||
<span className={s.chapterDiff}>{chapterDiff > 0 ? `+${chapterDiff}` : chapterDiff} vs current</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={s.statRow}>
|
||||
<span className={s.statLabel}>Read progress to migrate</span>
|
||||
<span className={s.statVal}>{readCount} / {totalCount} chapters</span>
|
||||
</div>
|
||||
<div className={s.statRow}>
|
||||
<span className={s.statLabel}>Matched chapters</span>
|
||||
<span className={s.statVal}>{selectedMatch.readCount} will carry over</span>
|
||||
<span className={s.statLabel}>Read progress to carry over</span>
|
||||
<span className={s.statVal}>{selectedMatch.readCount} / {readCount} chapters</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{chapterDiff < -5 && (
|
||||
<div className={s.warnBox}>
|
||||
<Warning size={13} weight="light" />
|
||||
New source has {Math.abs(chapterDiff)} fewer chapters — some content may be missing.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className={s.confirmNote}>
|
||||
The current entry will be removed from your library. Downloads are not transferred.
|
||||
</p>
|
||||
@@ -286,7 +360,7 @@ export default function MigrateModal({ manga, currentChapters, onClose, onMigrat
|
||||
<button className={s.migrateBtn} onClick={migrate} disabled={migrating}>
|
||||
{migrating
|
||||
? <><CircleNotch size={13} weight="light" className="anim-spin" /> Migrating…</>
|
||||
: "Migrate"}
|
||||
: <><Check size={13} weight="bold" /> Migrate</>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -315,7 +315,25 @@
|
||||
.tagGrid .skCard { width: auto; }
|
||||
.tagGrid .skCover { width: 100%; }
|
||||
|
||||
/* ── NSFW badge ──────────────────────────────────────────────────────────── */
|
||||
/* ── Show more (tag grid & genre drill) ──────────────────────────────────── */
|
||||
.showMoreCell {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: var(--sp-2) 0 var(--sp-4);
|
||||
}
|
||||
|
||||
.showMoreBtn {
|
||||
display: flex; align-items: center; gap: var(--sp-2);
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
padding: 7px 20px; border-radius: var(--radius-md);
|
||||
background: var(--bg-raised); color: var(--text-muted);
|
||||
border: 1px solid var(--border-dim); cursor: pointer;
|
||||
transition: color var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.showMoreBtn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); }
|
||||
.showMoreBtn:disabled { opacity: 0.5; cursor: default; }
|
||||
.nsfwBadge {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide); padding: 1px 5px;
|
||||
|
||||
+117
-28
@@ -4,8 +4,8 @@ import {
|
||||
} from "@phosphor-icons/react";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS, getTopSources } from "../../lib/cache";
|
||||
import { dedupeSources, dedupeMangaByTitle } from "../../lib/sourceUtils";
|
||||
import { cache, CACHE_KEYS } from "../../lib/cache";
|
||||
import { dedupeSources, dedupeMangaById } from "../../lib/sourceUtils";
|
||||
import { useStore } from "../../store";
|
||||
import type { Manga, Source } from "../../lib/types";
|
||||
import s from "./Search.module.css";
|
||||
@@ -428,6 +428,10 @@ function KeywordTab({
|
||||
|
||||
// ── Tag tab ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const TAG_PAGE_SIZE = 50; // items shown per "page"
|
||||
const TAG_FETCH_PAGES = 3; // source pages to fetch per source on initial load
|
||||
const TAG_MAX_SOURCES = 12; // max sources to query
|
||||
|
||||
function TagTab({
|
||||
preferredLang, onMangaClick,
|
||||
}: {
|
||||
@@ -436,11 +440,16 @@ function TagTab({
|
||||
preferredLang: string;
|
||||
onMangaClick: (m: Manga) => void;
|
||||
}) {
|
||||
const [activeTag, setActiveTag] = useState<string | null>(null);
|
||||
const [tagResults, setTagResults] = useState<Manga[]>([]);
|
||||
const [loadingTag, setLoadingTag] = useState(false);
|
||||
const [tagFilter, setTagFilter] = useState("");
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const [activeTag, setActiveTag] = useState<string | null>(null);
|
||||
const [tagResults, setTagResults] = useState<Manga[]>([]);
|
||||
const [loadingTag, setLoadingTag] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [visibleCount, setVisibleCount] = useState(TAG_PAGE_SIZE);
|
||||
const [tagFilter, setTagFilter] = useState("");
|
||||
// Track next page to fetch per source for "load more from network"
|
||||
const nextPageRef = useRef<Map<string, number>>(new Map());
|
||||
const sourcesRef = useRef<Source[]>([]);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
useEffect(() => () => { abortRef.current?.abort(); }, []);
|
||||
|
||||
@@ -449,6 +458,8 @@ function TagTab({
|
||||
setActiveTag(tag);
|
||||
setTagResults([]);
|
||||
setLoadingTag(true);
|
||||
setVisibleCount(TAG_PAGE_SIZE);
|
||||
nextPageRef.current = new Map();
|
||||
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
@@ -459,27 +470,44 @@ function TagTab({
|
||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
||||
.then((d) => d.sources.nodes.filter((s) => s.id !== "0"))
|
||||
);
|
||||
const deduped = dedupeSources(sources, preferredLang);
|
||||
const top = getTopSources(deduped);
|
||||
const deduped = dedupeSources(sources, preferredLang).slice(0, TAG_MAX_SOURCES);
|
||||
sourcesRef.current = deduped;
|
||||
|
||||
const results = await cache.get(CACHE_KEYS.GENRE(tag), () =>
|
||||
Promise.allSettled(
|
||||
top.map((src) =>
|
||||
gql<{ fetchSourceManga: { mangas: Manga[] } }>(
|
||||
// Start all at -1; the fetch loop sets the real next page if hasNextPage is true
|
||||
for (const src of deduped) {
|
||||
nextPageRef.current.set(src.id, -1);
|
||||
}
|
||||
|
||||
// Stream results in: fetch each source's pages concurrently, update state as each settles
|
||||
await runConcurrent(deduped, async (src) => {
|
||||
if (ctrl.signal.aborted) return;
|
||||
const pageResults: Manga[] = [];
|
||||
// Fetch TAG_FETCH_PAGES pages in series per source
|
||||
for (let page = 1; page <= TAG_FETCH_PAGES; page++) {
|
||||
if (ctrl.signal.aborted) return;
|
||||
try {
|
||||
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||
FETCH_SOURCE_MANGA,
|
||||
{ source: src.id, type: "SEARCH", page: 1, query: tag },
|
||||
{ source: src.id, type: "SEARCH", page, query: tag },
|
||||
ctrl.signal,
|
||||
).then((d) => d.fetchSourceManga.mangas)
|
||||
)
|
||||
).then((settled) => {
|
||||
const merged: Manga[] = [];
|
||||
for (const r of settled)
|
||||
if (r.status === "fulfilled") merged.push(...r.value);
|
||||
return dedupeMangaByTitle(merged);
|
||||
})
|
||||
);
|
||||
|
||||
if (!ctrl.signal.aborted) setTagResults(results);
|
||||
);
|
||||
pageResults.push(...d.fetchSourceManga.mangas);
|
||||
if (!d.fetchSourceManga.hasNextPage) {
|
||||
nextPageRef.current.set(src.id, -1); // no more pages
|
||||
break;
|
||||
} else if (page === TAG_FETCH_PAGES) {
|
||||
// Still has more pages beyond what we fetched upfront
|
||||
nextPageRef.current.set(src.id, TAG_FETCH_PAGES + 1);
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e?.name === "AbortError") return;
|
||||
break; // source error — move on
|
||||
}
|
||||
}
|
||||
if (!ctrl.signal.aborted && pageResults.length > 0) {
|
||||
setTagResults((prev) => dedupeMangaById([...prev, ...pageResults]));
|
||||
}
|
||||
}, ctrl.signal);
|
||||
} catch (e: any) {
|
||||
if (e?.name !== "AbortError") console.error(e);
|
||||
} finally {
|
||||
@@ -487,11 +515,61 @@ function TagTab({
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (!activeTag || loadingMore) return;
|
||||
|
||||
// First check if we have more buffered results to show
|
||||
if (visibleCount < tagResults.length) {
|
||||
setVisibleCount((v) => v + TAG_PAGE_SIZE);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise fetch next pages from sources
|
||||
const sourcesToFetch = sourcesRef.current.filter(
|
||||
(src) => (nextPageRef.current.get(src.id) ?? -1) > 0
|
||||
);
|
||||
if (sourcesToFetch.length === 0) return;
|
||||
|
||||
setLoadingMore(true);
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
|
||||
try {
|
||||
await runConcurrent(sourcesToFetch, async (src) => {
|
||||
const page = nextPageRef.current.get(src.id)!;
|
||||
if (ctrl.signal.aborted) return;
|
||||
try {
|
||||
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||
FETCH_SOURCE_MANGA,
|
||||
{ source: src.id, type: "SEARCH", page, query: activeTag },
|
||||
ctrl.signal,
|
||||
);
|
||||
nextPageRef.current.set(src.id, d.fetchSourceManga.hasNextPage ? page + 1 : -1);
|
||||
if (!ctrl.signal.aborted && d.fetchSourceManga.mangas.length > 0) {
|
||||
setTagResults((prev) => dedupeMangaById([...prev, ...d.fetchSourceManga.mangas]));
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e?.name !== "AbortError") nextPageRef.current.set(src.id, -1);
|
||||
}
|
||||
}, ctrl.signal);
|
||||
} finally {
|
||||
if (!ctrl.signal.aborted) {
|
||||
setVisibleCount((v) => v + TAG_PAGE_SIZE);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const filteredGenres = useMemo(() => {
|
||||
const q = tagFilter.trim().toLowerCase();
|
||||
return q ? COMMON_GENRES.filter((g) => g.toLowerCase().includes(q)) : COMMON_GENRES;
|
||||
}, [tagFilter]);
|
||||
|
||||
const visibleResults = tagResults.slice(0, visibleCount);
|
||||
const hasMore = visibleCount < tagResults.length ||
|
||||
sourcesRef.current.some((src) => (nextPageRef.current.get(src.id) ?? -1) > 0);
|
||||
|
||||
return (
|
||||
<div className={s.splitRoot}>
|
||||
<div className={s.splitSidebar}>
|
||||
@@ -531,15 +609,26 @@ function TagTab({
|
||||
<span className={s.splitContentTitle}>{activeTag}</span>
|
||||
{loadingTag
|
||||
? <CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||
: <span className={s.splitResultCount}>{tagResults.length} results</span>}
|
||||
: <span className={s.splitResultCount}>
|
||||
{visibleResults.length}{tagResults.length > visibleCount ? `+` : ""} of {tagResults.length} results
|
||||
</span>}
|
||||
</div>
|
||||
{loadingTag ? (
|
||||
<GridSkeleton />
|
||||
<GridSkeleton count={50} />
|
||||
) : tagResults.length > 0 ? (
|
||||
<div className={s.tagGrid}>
|
||||
{tagResults.map((m) => (
|
||||
{visibleResults.map((m) => (
|
||||
<MangaCard key={m.id} manga={m} onClick={() => onMangaClick(m)} />
|
||||
))}
|
||||
{hasMore && (
|
||||
<div className={s.showMoreCell}>
|
||||
<button className={s.showMoreBtn} onClick={loadMore} disabled={loadingMore}>
|
||||
{loadingMore
|
||||
? <><CircleNotch size={13} weight="light" className="anim-spin" /> Loading…</>
|
||||
: "Show more"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className={s.empty}>
|
||||
|
||||
@@ -302,6 +302,7 @@ export default function SeriesDetail() {
|
||||
const updateSettings = useStore((state) => state.updateSettings);
|
||||
const addToast = useStore((state) => state.addToast);
|
||||
const setGenreFilter = useStore((state) => state.setGenreFilter);
|
||||
const setNavPage = useStore((state) => state.setNavPage);
|
||||
|
||||
const [manga, setManga] = useState<Manga | null>(null);
|
||||
const [chapters, setChapters] = useState<Chapter[]>([]);
|
||||
@@ -733,7 +734,11 @@ export default function SeriesDetail() {
|
||||
key={g}
|
||||
className={[s.genre, s.genreClickable].join(" ")}
|
||||
title={`Filter library by "${g}"`}
|
||||
onClick={() => setGenreFilter(g)}
|
||||
onClick={() => {
|
||||
setGenreFilter(g);
|
||||
setNavPage("explore");
|
||||
setActiveManga(null);
|
||||
}}
|
||||
>
|
||||
{g}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user