diff --git a/package.json b/package.json index 2402fd1..067dafe 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "build": "tsc && vite build", "preview": "vite preview", "tauri": "tauri", - "tauri:dev": "tauri dev", + "tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json", "tauri:build": "tauri build" }, "dependencies": { diff --git a/src-tauri/tauri.dev.conf.json b/src-tauri/tauri.dev.conf.json new file mode 100644 index 0000000..1434a4f --- /dev/null +++ b/src-tauri/tauri.dev.conf.json @@ -0,0 +1,6 @@ +{ + "build": { + "devUrl": "http://localhost:1420", + "beforeDevCommand": "pnpm dev" + } +} \ No newline at end of file diff --git a/src/components/layout/Sidebar.module.css b/src/components/layout/Sidebar.module.css index 409c2bc..b8996b6 100644 --- a/src/components/layout/Sidebar.module.css +++ b/src/components/layout/Sidebar.module.css @@ -10,41 +10,34 @@ } .logo { - /* Logo set to 80px */ width: 80px; height: 80px; display: flex; align-items: center; justify-content: center; - - /* MARGIN REMOVED */ - margin-bottom: 0; - - /* Allows the logo to overflow the sidebar width if the sidebar is smaller than 80px */ + + margin-bottom: var(--sp-3); + overflow: visible; } .logoIcon { - /* Icon set to 80px */ width: 80px; height: 80px; - - /* Apply your UI accent green */ + background-color: var(--accent); - - /* SVG Mask Logic using Moku-Icon.svg */ - mask-image: url("../../assets/Moku-Icon.svg"); + + mask-image: url("../../assets/moku-icon.svg"); mask-repeat: no-repeat; mask-position: center; mask-size: contain; - - -webkit-mask-image: url("../../assets/Moku-Icon.svg"); + + -webkit-mask-image: url("../../assets/moku-icon.svg"); -webkit-mask-repeat: no-repeat; -webkit-mask-position: center; -webkit-mask-size: contain; - /* Prominent glow for the large logo */ - filter: drop-shadow(0 0 12px rgba(107, 143, 107, 0.4)); + filter: drop-shadow(0 0 8px rgba(107, 143, 107, 0.35)); } .nav { @@ -83,7 +76,6 @@ background: var(--accent-muted); } -/* ── Bottom section ── */ .bottom { display: flex; flex-direction: column; diff --git a/src/components/pages/MigrateModal.module.css b/src/components/pages/MigrateModal.module.css new file mode 100644 index 0000000..a745f86 --- /dev/null +++ b/src/components/pages/MigrateModal.module.css @@ -0,0 +1,478 @@ +.overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 200; + animation: fadeIn 0.1s ease both; +} + +.modal { + background: var(--bg-base); + border: 1px solid var(--border-base); + border-radius: var(--radius-xl); + width: 520px; + max-height: 80vh; + display: flex; + flex-direction: column; + overflow: hidden; + box-shadow: 0 8px 40px rgba(0, 0, 0, 0.5); +} + +.modalHeader { + display: flex; + align-items: flex-start; + justify-content: space-between; + padding: var(--sp-4) var(--sp-5); + border-bottom: 1px solid var(--border-dim); + flex-shrink: 0; +} + +.modalTitle { + display: flex; + flex-direction: column; + gap: 2px; +} + +.modalTitleLabel { + font-family: var(--font-ui); + font-size: var(--text-2xs); + color: var(--text-faint); + letter-spacing: var(--tracking-wider); + text-transform: uppercase; +} + +.modalTitleManga { + font-size: var(--text-base); + font-weight: var(--weight-medium); + color: var(--text-primary); + letter-spacing: var(--tracking-tight); +} + +.closeBtn { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: var(--radius-md); + color: var(--text-faint); + transition: color var(--t-base), background var(--t-base); + flex-shrink: 0; +} +.closeBtn:hover { color: var(--text-muted); background: var(--bg-raised); } + +/* ── Steps ── */ +.steps { + display: flex; + align-items: center; + gap: var(--sp-1); + padding: var(--sp-3) var(--sp-5); + border-bottom: 1px solid var(--border-dim); + flex-shrink: 0; +} + +.step { + display: flex; + align-items: center; + gap: var(--sp-2); + opacity: 0.4; + transition: opacity var(--t-base); +} + +.stepActive { opacity: 1; } +.stepDone { opacity: 0.6; } + +.stepDot { + width: 18px; + height: 18px; + border-radius: 50%; + background: var(--bg-raised); + border: 1px solid var(--border-base); + display: flex; + align-items: center; + justify-content: center; + font-family: var(--font-ui); + font-size: 10px; + color: var(--text-faint); + flex-shrink: 0; +} + +.stepActive .stepDot { + background: var(--accent-muted); + border-color: var(--accent-dim); + color: var(--accent-fg); +} + +.stepLabel { + font-family: var(--font-ui); + font-size: var(--text-2xs); + letter-spacing: var(--tracking-wide); + color: var(--text-muted); +} + +.stepActive .stepLabel { color: var(--text-secondary); } + +.steps .step + .step::before { + content: "›"; + color: var(--text-faint); + margin-right: var(--sp-1); + font-size: var(--text-sm); +} + +/* ── Body ── */ +.body { + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.centered { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: var(--sp-8); +} + +.hint { + font-family: var(--font-ui); + font-size: var(--text-xs); + color: var(--text-faint); + letter-spacing: var(--tracking-wide); +} + +/* ── Source list ── */ +.sourceList { + flex: 1; + overflow-y: auto; + padding: var(--sp-2); + display: flex; + flex-direction: column; + gap: 1px; +} + +.sourceRow { + display: flex; + align-items: center; + gap: var(--sp-3); + padding: 9px var(--sp-3); + border-radius: var(--radius-md); + border: 1px solid transparent; + background: none; + text-align: left; + width: 100%; + cursor: pointer; + transition: background var(--t-fast), border-color var(--t-fast); +} +.sourceRow:hover { background: var(--bg-raised); border-color: var(--border-dim); } +.sourceRowActive { background: var(--accent-muted); border-color: var(--accent-dim); } + +.sourceIcon { + width: 28px; + height: 28px; + border-radius: var(--radius-md); + object-fit: cover; + flex-shrink: 0; + background: var(--bg-raised); +} + +.sourceInfo { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; } + +.sourceName { + font-size: var(--text-sm); + font-weight: var(--weight-medium); + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.sourceMeta { + font-family: var(--font-ui); + font-size: var(--text-2xs); + color: var(--text-faint); + letter-spacing: var(--tracking-wide); +} + +.sourceArrow { + color: var(--text-faint); + opacity: 0; + transition: opacity var(--t-base); +} +.sourceRow:hover .sourceArrow { opacity: 1; } + +/* ── Search step ── */ +.searchStep { + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; + gap: var(--sp-3); + padding: var(--sp-3) var(--sp-4); +} + +.searchRow { + display: flex; + align-items: center; + gap: var(--sp-2); + flex-shrink: 0; +} + +.searchBar { + flex: 1; + display: flex; + align-items: center; + gap: var(--sp-2); + background: var(--bg-raised); + border: 1px solid var(--border-dim); + border-radius: var(--radius-md); + padding: 0 var(--sp-3) 0 var(--sp-2); + transition: border-color var(--t-base); +} +.searchBar:focus-within { border-color: var(--border-strong); } + +.searchIcon { color: var(--text-faint); flex-shrink: 0; } + +.searchInput { + flex: 1; + background: none; + border: none; + outline: none; + color: var(--text-primary); + font-size: var(--text-sm); + padding: 7px 0; +} +.searchInput::placeholder { color: var(--text-faint); } + +.searchBtn { + font-family: var(--font-ui); + font-size: var(--text-xs); + letter-spacing: var(--tracking-wide); + padding: 6px 12px; + border-radius: var(--radius-md); + background: var(--accent-muted); + color: var(--accent-fg); + border: 1px solid var(--accent-dim); + cursor: pointer; + flex-shrink: 0; + display: flex; + align-items: center; + gap: var(--sp-1); + transition: filter var(--t-base); +} +.searchBtn:hover:not(:disabled) { filter: brightness(1.1); } +.searchBtn:disabled { opacity: 0.4; cursor: default; } + +.backBtn { + font-family: var(--font-ui); + font-size: var(--text-xs); + letter-spacing: var(--tracking-wide); + padding: 6px 10px; + border-radius: var(--radius-md); + background: none; + color: var(--text-muted); + border: 1px solid var(--border-dim); + cursor: pointer; + flex-shrink: 0; + transition: color var(--t-base), border-color var(--t-base), background var(--t-base); +} +.backBtn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); } +.backBtn:disabled { opacity: 0.4; cursor: default; } + +.results { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 1px; +} + +.resultRow { + display: flex; + align-items: center; + gap: var(--sp-3); + padding: 7px var(--sp-2); + border-radius: var(--radius-md); + border: none; + background: none; + text-align: left; + width: 100%; + cursor: pointer; + transition: background var(--t-fast); +} +.resultRow:hover:not(:disabled) { background: var(--bg-raised); } +.resultRow:disabled { opacity: 0.5; cursor: default; } + +.resultCoverWrap { + width: 36px; + height: 54px; + border-radius: var(--radius-sm); + overflow: hidden; + background: var(--bg-raised); + border: 1px solid var(--border-dim); + flex-shrink: 0; +} + +.resultCover { width: 100%; height: 100%; object-fit: cover; } + +.resultTitle { + font-size: var(--text-sm); + color: var(--text-secondary); + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Skeletons */ +.skResult { + display: flex; + align-items: center; + gap: var(--sp-3); + padding: 7px var(--sp-2); +} + +.skCover { + width: 36px; + height: 54px; + border-radius: var(--radius-sm); + flex-shrink: 0; +} + +.skMeta { flex: 1; display: flex; flex-direction: column; gap: var(--sp-2); } +.skTitle { height: 13px; width: 65%; border-radius: var(--radius-sm); } + +/* ── Confirm step ── */ +.confirmStep { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: var(--sp-4); + padding: var(--sp-4) var(--sp-5); +} + +.confirmRow { + display: flex; + align-items: center; + justify-content: center; + gap: var(--sp-4); +} + +.confirmManga { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--sp-2); + flex: 1; + max-width: 160px; +} + +.confirmCoverWrap { + width: 100%; + aspect-ratio: 2/3; + border-radius: var(--radius-md); + overflow: hidden; + background: var(--bg-raised); + border: 1px solid var(--border-dim); +} + +.confirmCover { width: 100%; height: 100%; object-fit: cover; } + +.confirmTitle { + font-size: var(--text-xs); + color: var(--text-secondary); + text-align: center; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + line-height: var(--leading-snug); +} + +.confirmSource { + font-family: var(--font-ui); + font-size: var(--text-2xs); + color: var(--text-faint); + letter-spacing: var(--tracking-wide); + text-align: center; +} + +.confirmArrow { color: var(--text-faint); flex-shrink: 0; } + +.confirmStats { + display: flex; + flex-direction: column; + gap: var(--sp-2); + background: var(--bg-raised); + border: 1px solid var(--border-dim); + border-radius: var(--radius-md); + padding: var(--sp-3) var(--sp-4); +} + +.statRow { + display: flex; + justify-content: space-between; + align-items: center; +} + +.statLabel { + font-family: var(--font-ui); + font-size: var(--text-xs); + color: var(--text-muted); + letter-spacing: var(--tracking-wide); +} + +.statVal { + font-family: var(--font-ui); + font-size: var(--text-xs); + color: var(--text-secondary); + letter-spacing: var(--tracking-wide); +} + +.confirmNote { + font-size: var(--text-xs); + color: var(--text-faint); + line-height: var(--leading-base); +} + +.confirmActions { + display: flex; + justify-content: flex-end; + gap: var(--sp-2); + flex-shrink: 0; +} + +.migrateBtn { + 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 16px; + border-radius: var(--radius-md); + background: var(--accent-dim); + border: 1px solid var(--accent); + color: var(--accent-fg); + cursor: pointer; + transition: background var(--t-base), border-color var(--t-base); +} +.migrateBtn:hover:not(:disabled) { background: var(--accent-muted); border-color: var(--accent-bright); } +.migrateBtn:disabled { opacity: 0.5; cursor: default; } + +.error { + display: flex; + align-items: center; + gap: var(--sp-2); + font-size: var(--text-xs); + color: var(--color-error); + padding: var(--sp-2) var(--sp-3); + 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); +} \ No newline at end of file diff --git a/src/components/pages/MigrateModal.tsx b/src/components/pages/MigrateModal.tsx new file mode 100644 index 0000000..f6c10d0 --- /dev/null +++ b/src/components/pages/MigrateModal.tsx @@ -0,0 +1,298 @@ +import { useState, useEffect } from "react"; +import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning } 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; +} + +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([]); + const [searching, setSearching] = useState(false); + const [selectedMatch, setSelectedMatch] = useState(null); + const [loadingMatch, setLoadingMatch] = useState(false); + 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)); + }, []); + + async function searchSource() { + if (!selectedSource || !query.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(), + }); + setResults(d.fetchSourceManga.mangas); + } catch (e: any) { + setError(e.message); + } finally { + setSearching(false); + } + } + + async function selectMatch(m: Manga) { + setLoadingMatch(true); + 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 }); + setStep("confirm"); + } catch (e: any) { + setError(e.message); + } finally { + setLoadingMatch(false); + } + } + + async function migrate() { + if (!selectedMatch) return; + setMigrating(true); + 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[] = []; + 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! }); + } + } + + // Migrate read state + if (toMarkRead.length) { + await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkRead, isRead: true }); + } + // Migrate bookmarks + 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) { + 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 }); + + onMigrated({ ...newManga, inLibrary: true }); + } catch (e: any) { + setError(e.message); + setMigrating(false); + } + } + + const readCount = currentChapters.filter((c) => c.isRead).length; + const totalCount = currentChapters.length; + + return ( +
e.target === e.currentTarget && onClose()}> +
+
+
+ Migrate source + {manga.title} +
+ +
+ + {/* ── Step indicators ── */} +
+ {(["source", "search", "confirm"] as Step[]).map((st, i) => ( +
+ {i < ["source","search","confirm"].indexOf(step) ? : i + 1} + {st.charAt(0).toUpperCase() + st.slice(1)} +
+ ))} +
+ +
+ {/* ── Step 1: Pick source ── */} + {step === "source" && ( +
+ {loadingSources ? ( +
+ +
+ ) : sources.length === 0 ? ( +
No other sources installed.
+ ) : ( + sources.map((src) => ( + + )) + )} +
+ )} + + {/* ── Step 2: Search & pick match ── */} + {step === "search" && ( +
+
+
+ + setQuery(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && searchSource()} + autoFocus + /> +
+ + +
+ + {error &&

{error}

} + +
+ {searching && Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+
+
+ ))} + {!searching && results.map((m) => ( + + ))} + {!searching && results.length === 0 && query && ( +
No results.
+ )} +
+
+ )} + + {/* ── Step 3: Confirm ── */} + {step === "confirm" && selectedMatch && ( +
+
+
+
+ {manga.title} +
+

{manga.title}

+

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

+
+ + + +
+
+ {selectedMatch.manga.title} +
+

{selectedMatch.manga.title}

+

{selectedSource?.displayName ?? "Unknown"}

+
+
+ +
+
+ Chapters on new source + {selectedMatch.chapters.length} +
+
+ Read progress to migrate + {readCount} / {totalCount} chapters +
+
+ Matched chapters + {selectedMatch.readCount} will carry over +
+
+ +

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

+ + {error &&

{error}

} + +
+ + +
+
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/pages/Search.module.css b/src/components/pages/Search.module.css index a447408..9afbe99 100644 --- a/src/components/pages/Search.module.css +++ b/src/components/pages/Search.module.css @@ -35,6 +35,44 @@ .searchBtn:hover:not(:disabled) { filter: brightness(1.1); } .searchBtn:disabled { opacity: 0.4; cursor: default; } +.langBar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--sp-1); + padding: var(--sp-2) var(--sp-6); + border-bottom: 1px solid var(--border-dim); + flex-shrink: 0; +} + +.langBtn { + font-family: var(--font-ui); + font-size: var(--text-2xs); + letter-spacing: var(--tracking-wider); + padding: 3px 8px; + border-radius: var(--radius-sm); + border: 1px solid var(--border-dim); + background: none; + color: var(--text-faint); + cursor: pointer; + transition: color var(--t-base), border-color var(--t-base), background var(--t-base); +} +.langBtn:hover { color: var(--text-muted); border-color: var(--border-strong); } +.langBtnActive { + background: var(--accent-muted); + border-color: var(--accent-dim); + color: var(--accent-fg); +} +.langBtnActive:hover { background: var(--accent-muted); color: var(--accent-fg); } + +.sourceCount { + font-family: var(--font-ui); + font-size: var(--text-2xs); + color: var(--text-faint); + letter-spacing: var(--tracking-wide); + margin-left: auto; +} + .results { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6); display: flex; flex-direction: column; gap: var(--sp-6); } .sourceSection { display: flex; flex-direction: column; gap: var(--sp-3); } @@ -77,7 +115,6 @@ line-height: var(--leading-snug); } -/* Skeletons */ .skCard { flex-shrink: 0; width: 110px; } .skCover { aspect-ratio: 2/3; border-radius: var(--radius-md); } .skTitle { height: 11px; margin-top: var(--sp-1); width: 75%; } diff --git a/src/components/pages/Search.tsx b/src/components/pages/Search.tsx index dc3f943..d2165a2 100644 --- a/src/components/pages/Search.tsx +++ b/src/components/pages/Search.tsx @@ -1,4 +1,4 @@ -import { useState, useRef, useCallback } from "react"; +import { useState, useRef, useCallback, useEffect } from "react"; import { MagnifyingGlass, CircleNotch } from "@phosphor-icons/react"; import { gql, thumbUrl } from "../../lib/client"; import { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries"; @@ -13,57 +13,73 @@ interface SourceResult { error: string | null; } +const CONCURRENCY = 3; + +async function runConcurrent( + items: T[], + fn: (item: T) => Promise +): Promise { + let i = 0; + async function worker() { + while (i < items.length) { + const item = items[i++]; + await fn(item).catch(() => {}); + } + } + await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker)); +} + export default function Search() { const [query, setQuery] = useState(""); const [submitted, setSubmitted] = useState(""); const [results, setResults] = useState([]); - const [sources, setSources] = useState([]); + const [allSources, setAllSources] = useState([]); const [loadingSources, setLoadingSources] = useState(false); + const [activeLang, setActiveLang] = useState("preferred"); const inputRef = useRef(null); - const setActiveManga = useStore((s) => s.setActiveManga); - const setNavPage = useStore((s) => s.setNavPage); + const setActiveManga = useStore((st) => st.setActiveManga); + const setNavPage = useStore((st) => st.setNavPage); + const preferredLang = useStore((st) => st.settings.preferredExtensionLang); - const loadSources = useCallback(async () => { - if (sources.length) return sources; + useEffect(() => { setLoadingSources(true); - const data = await gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) + gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) + .then((d) => setAllSources(d.sources.nodes.filter((s) => s.id !== "0"))) + .catch(console.error) .finally(() => setLoadingSources(false)); - const nodes = data.sources.nodes.filter((s) => s.id !== "0"); - setSources(nodes); - return nodes; - }, [sources]); + }, []); - async function runSearch() { + const langs = ["preferred", ...Array.from(new Set(allSources.map((s) => s.lang))).sort(), "all"]; + + const visibleSources = allSources.filter((src) => { + if (activeLang === "all") return true; + if (activeLang === "preferred") return src.lang === preferredLang; + return src.lang === activeLang; + }); + + const runSearch = useCallback(async () => { const q = query.trim(); - if (!q) return; + if (!q || !visibleSources.length) return; setSubmitted(q); - const srcs = await loadSources(); - // Initialise loading state for each source - setResults(srcs.map((src) => ({ source: src, mangas: [], loading: true, error: null }))); + setResults(visibleSources.map((src) => ({ source: src, mangas: [], loading: true, error: null }))); - // Fire all source queries in parallel, update each independently - srcs.forEach((src) => { - gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, { - source: src.id, type: "SEARCH", page: 1, query: q, - }) - .then((d) => { - setResults((prev) => prev.map((r) => - r.source.id === src.id - ? { ...r, mangas: d.fetchSourceManga.mangas, loading: false } - : r - )); - }) - .catch((e) => { - setResults((prev) => prev.map((r) => - r.source.id === src.id - ? { ...r, loading: false, error: e.message } - : r - )); + await runConcurrent(visibleSources, async (src) => { + try { + const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, { + source: src.id, type: "SEARCH", page: 1, query: q, }); + setResults((prev) => prev.map((r) => + r.source.id === src.id ? { ...r, mangas: d.fetchSourceManga.mangas, loading: false } : r + )); + } catch (e: any) { + setResults((prev) => prev.map((r) => + r.source.id === src.id ? { ...r, loading: false, error: e.message } : r + )); + } }); - } + }, [query, visibleSources]); function openManga(m: Manga) { setActiveManga(m); @@ -75,20 +91,24 @@ export default function Search() { return (
- {/* ── Search bar ── */}

Search

- setQuery(e.target.value)} onKeyDown={(e) => e.key === "Enter" && runSearch()} - autoFocus /> -
- {/* ── Empty state ── */} +
+ {langs.map((l) => ( + + ))} + {visibleSources.length > 0 && ( + {visibleSources.length} sources + )} +
+ {!submitted && (
-

Search across all installed sources at once

-

Results from each source appear as they load.

+

Search across sources

+

+ Searching {visibleSources.length} {activeLang === "preferred" ? `${preferredLang.toUpperCase()}` : activeLang === "all" ? "" : activeLang.toUpperCase()} source{visibleSources.length !== 1 ? "s" : ""}. +

)} - {/* ── Results ── */} {submitted && (
{results.length === 0 && ( @@ -117,44 +152,47 @@ export default function Search() { {results .filter((r) => r.mangas.length > 0 || r.loading || r.error) .map(({ source, mangas, loading, error }) => ( -
-
- {source.displayName} { (e.target as HTMLImageElement).style.display = "none"; }} /> - {source.displayName} - {loading && } - {!loading && mangas.length > 0 && ( - {mangas.length} results - )} -
+
+
+ {source.displayName} { (e.target as HTMLImageElement).style.display = "none"; }} + /> + {source.displayName} + {loading && } + {!loading && mangas.length > 0 && ( + {mangas.length} results + )} +
- {error ? ( -

{error}

- ) : loading ? ( -
- {Array.from({ length: 4 }).map((_, i) => ( -
-
-
-
- ))} -
- ) : mangas.length > 0 ? ( -
- {mangas.slice(0, 8).map((m) => ( - - ))} -
- ) : null} -
- ))} + ))} +
+ ) : mangas.length > 0 ? ( +
+ {mangas.slice(0, 8).map((m) => ( + + ))} +
+ ) : null} +
+ ))} {allDone && !hasResults && submitted && (
diff --git a/src/components/pages/SeriesDetail.module.css b/src/components/pages/SeriesDetail.module.css index a91483c..96cf85d 100644 --- a/src/components/pages/SeriesDetail.module.css +++ b/src/components/pages/SeriesDetail.module.css @@ -427,4 +427,92 @@ } .dlItem:hover { background: var(--bg-overlay); color: var(--text-primary); } -.dlItemSub { font-size: var(--text-xs); color: var(--text-faint); } \ No newline at end of file +.dlItemSub { font-size: var(--text-xs); color: var(--text-faint); } +/* ── Details section ── */ +.detailsSection { + margin-top: var(--sp-2); + border-top: 1px solid var(--border-dim); + padding-top: var(--sp-2); +} + +.detailsToggle { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 4px var(--sp-1); + border-radius: var(--radius-md); + background: none; + border: none; + color: var(--text-faint); + font-family: var(--font-ui); + font-size: var(--text-2xs); + letter-spacing: var(--tracking-wider); + text-transform: uppercase; + cursor: pointer; + transition: color var(--t-base), background var(--t-base); +} +.detailsToggle:hover { color: var(--text-muted); background: var(--bg-raised); } + +.caretClosed { transition: transform var(--t-base); } +.caretOpen { transform: rotate(180deg); transition: transform var(--t-base); } + +.detailsBody { + display: flex; + flex-direction: column; + gap: var(--sp-2); + padding: var(--sp-2) var(--sp-1); +} + +.detailRow { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: var(--sp-2); +} + +.detailKey { + font-family: var(--font-ui); + font-size: var(--text-2xs); + color: var(--text-faint); + letter-spacing: var(--tracking-wide); + flex-shrink: 0; +} + +.detailVal { + font-size: var(--text-xs); + color: var(--text-muted); + text-align: right; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.detailMono { + font-family: monospace; + font-size: var(--text-2xs); + color: var(--text-faint); +} + +.migrateBtn { + display: flex; + align-items: center; + gap: var(--sp-2); + width: 100%; + padding: 6px var(--sp-2); + margin-top: var(--sp-1); + border-radius: var(--radius-md); + border: 1px solid var(--border-dim); + background: none; + color: var(--text-muted); + font-family: var(--font-ui); + font-size: var(--text-xs); + letter-spacing: var(--tracking-wide); + cursor: pointer; + transition: color var(--t-base), border-color var(--t-base), background var(--t-base); +} +.migrateBtn:hover { + color: var(--text-secondary); + border-color: var(--border-strong); + background: var(--bg-raised); +} \ No newline at end of file diff --git a/src/components/pages/SeriesDetail.tsx b/src/components/pages/SeriesDetail.tsx index ad3613f..ed79565 100644 --- a/src/components/pages/SeriesDetail.tsx +++ b/src/components/pages/SeriesDetail.tsx @@ -2,7 +2,7 @@ import { useEffect, useState, useMemo, useCallback } from "react"; import { ArrowLeft, BookmarkSimple, Download, CheckCircle, ArrowSquareOut, BookOpen, CircleNotch, Play, - SortAscending, SortDescending, + SortAscending, SortDescending, CaretDown, ArrowsClockwise, } from "@phosphor-icons/react"; import { gql, thumbUrl } from "../../lib/client"; import { @@ -12,6 +12,7 @@ import { } from "../../lib/queries"; import { useStore } from "../../store"; import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu"; +import MigrateModal from "./MigrateModal"; import type { Manga, Chapter } from "../../lib/types"; import s from "./SeriesDetail.module.css"; @@ -44,6 +45,8 @@ export default function SeriesDetail() { const [loadingChapters, setLoadingChapters] = useState(true); const [enqueueing, setEnqueueing] = useState>(new Set()); const [dlOpen, setDlOpen] = useState(false); + const [detailsOpen, setDetailsOpen] = useState(false); + const [migrateOpen, setMigrateOpen] = useState(false); const [togglingLibrary, setTogglingLibrary] = useState(false); const [chapterPage, setChapterPage] = useState(1); const [ctx, setCtx] = useState(null); @@ -62,7 +65,6 @@ export default function SeriesDetail() { const loadChapters = useCallback((mangaId: number) => { return gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId }) .then((data) => { - // Always store in natural order (ascending sourceOrder), sort in render const sorted = [...data.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder); setChapters(sorted); return sorted; @@ -79,17 +81,13 @@ export default function SeriesDetail() { .catch(console.error) .finally(() => setLoadingChapters(false)); - // Fetch from source in background gql(FETCH_CHAPTERS, { mangaId: activeManga.id }) .then(() => loadChapters(activeManga.id)) .catch(console.error); }, [activeManga?.id]); - // Sorted chapters based on setting const sortedChapters = useMemo(() => - sortDir === "desc" - ? [...chapters].reverse() - : [...chapters], + sortDir === "desc" ? [...chapters].reverse() : [...chapters], [chapters, sortDir] ); @@ -99,15 +97,12 @@ export default function SeriesDetail() { chapterPage * CHAPTERS_PER_PAGE ); - // Progress stats const readCount = chapters.filter((c) => c.isRead).length; const totalCount = chapters.length; const progressPct = totalCount > 0 ? (readCount / totalCount) * 100 : 0; - // Start / Continue reading logic const continueChapter = useMemo(() => { if (!chapters.length) return null; - // Find first unread chapter (in ascending order) const asc = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder); const inProgress = asc.find((c) => !c.isRead && (c.lastPageRead ?? 0) > 0); if (inProgress) return { chapter: inProgress, type: "continue" as const }; @@ -139,7 +134,6 @@ export default function SeriesDetail() { } async function markAllAboveRead(indexInSorted: number) { - // "above" = all chapters that appear before this one in the current sort const targets = sortedChapters.slice(0, indexInSorted + 1); const ids = targets.filter((c) => !c.isRead).map((c) => c.id); if (!ids.length) return; @@ -244,7 +238,6 @@ export default function SeriesDetail() {
)} - {manga?.source &&

{manga.source.displayName}

} {manga?.description &&

{manga.description}

}
)} @@ -279,7 +272,6 @@ export default function SeriesDetail() { )}
- {/* Start / Continue reading button */} {continueChapter && ( + {detailsOpen && ( +
+
+ Source + {manga.source.displayName} +
+
+ Language + {manga.source.name.match(/\(([^)]+)\)$/)?.[1] ?? "—"} +
+
+ Source ID + {manga.source.id} +
+ +
+ )} +
+ )}
{/* ── Chapter list ── */}
- {/* List header with sort + pagination */}
); } \ No newline at end of file diff --git a/src/components/sources/SourceList.module.css b/src/components/sources/SourceList.module.css index 40a7e81..6882c8f 100644 --- a/src/components/sources/SourceList.module.css +++ b/src/components/sources/SourceList.module.css @@ -100,6 +100,15 @@ .row:hover { background: var(--bg-raised); border-color: var(--border-dim); } +.rowIndented { + padding-left: var(--sp-5); +} + +.indentSpacer { + width: 32px; + flex-shrink: 0; +} + .icon { width: 32px; height: 32px; @@ -128,6 +137,8 @@ } .arrow { + display: flex; + align-items: center; font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); diff --git a/src/components/sources/SourceList.tsx b/src/components/sources/SourceList.tsx index 524ef65..49b4177 100644 --- a/src/components/sources/SourceList.tsx +++ b/src/components/sources/SourceList.tsx @@ -1,16 +1,19 @@ import { useEffect, useState } from "react"; -import { MagnifyingGlass, CircleNotch } from "@phosphor-icons/react"; +import { MagnifyingGlass, CircleNotch, CaretDown, CaretRight } from "@phosphor-icons/react"; import { gql, thumbUrl } from "../../lib/client"; import { GET_SOURCES } from "../../lib/queries"; import { useStore } from "../../store"; import type { Source } from "../../lib/types"; import s from "./SourceList.module.css"; +type Group = { name: string; icon: string; sources: Source[] }; + export default function SourceList() { const [sources, setSources] = useState([]); const [loading, setLoading] = useState(true); const [lang, setLang] = useState("all"); const [search, setSearch] = useState(""); + const [expanded, setExpanded] = useState>(new Set()); const setActiveSource = useStore((state) => state.setActiveSource); useEffect(() => { @@ -20,10 +23,10 @@ export default function SourceList() { .finally(() => setLoading(false)); }, []); - const langs = ["all", ...Array.from(new Set(sources.map((s) => s.lang))).sort()]; + const langs = ["all", ...Array.from(new Set(sources.map((src) => src.lang))).sort()]; const filtered = sources.filter((src) => { - if (src.id === "0") return false; // hide local source + if (src.id === "0") return false; const matchLang = lang === "all" || src.lang === lang; const matchSearch = src.name.toLowerCase().includes(search.toLowerCase()) || @@ -31,6 +34,26 @@ export default function SourceList() { return matchLang && matchSearch; }); + const groups: Group[] = []; + const seen = new Map(); + for (const src of filtered) { + const key = src.name; + if (!seen.has(key)) { + const g: Group = { name: src.name, icon: src.iconUrl, sources: [] }; + seen.set(key, g); + groups.push(g); + } + seen.get(key)!.sources.push(src); + } + + function toggleGroup(name: string) { + setExpanded((prev) => { + const next = new Set(prev); + next.has(name) ? next.delete(name) : next.add(name); + return next; + }); + } + return (
@@ -62,29 +85,55 @@ export default function SourceList() {
- ) : filtered.length === 0 ? ( + ) : groups.length === 0 ? (
No sources found.
) : (
- {filtered.map((src) => ( - + + {!single && open && g.sources.map((src) => ( + + ))}
- - - ))} + ); + })}
)}
diff --git a/src/lib/queries.ts b/src/lib/queries.ts index eb542fe..06fb393 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -1,6 +1,5 @@ // ── Library ────────────────────────────────────────────────────────────────── -// Full library query with chapter progress — only used for inLibrary manga export const GET_LIBRARY = ` query GetLibrary { mangas(condition: { inLibrary: true }) { @@ -19,7 +18,6 @@ export const GET_LIBRARY = ` } `; -// Lightweight query for browse/search (no progress needed) export const GET_ALL_MANGA = ` query GetAllManga { mangas { @@ -141,6 +139,19 @@ export const MARK_CHAPTERS_READ = ` } `; +export const UPDATE_CHAPTERS_PROGRESS = ` + mutation UpdateChaptersProgress($ids: [Int!]!, $isRead: Boolean, $isBookmarked: Boolean, $lastPageRead: Int) { + updateChapters(input: { ids: $ids, patch: { isRead: $isRead, isBookmarked: $isBookmarked, lastPageRead: $lastPageRead } }) { + chapters { + id + isRead + isBookmarked + lastPageRead + } + } + } +`; + export const DELETE_DOWNLOADED_CHAPTERS = ` mutation DeleteDownloadedChapters($ids: [Int!]!) { deleteDownloadedChapters(input: { ids: $ids }) { @@ -153,7 +164,6 @@ export const DELETE_DOWNLOADED_CHAPTERS = ` `; // ── Downloads ───────────────────────────────────────────────────────────────── -// Updated to include manga title, thumbnail, and pageCount export const GET_DOWNLOAD_STATUS = ` query GetDownloadStatus { @@ -284,6 +294,30 @@ export const FETCH_SOURCE_MANGA = ` } `; +export const FETCH_MANGA = ` + mutation FetchManga($id: Int!) { + fetchManga(input: { id: $id }) { + manga { + id + title + description + thumbnailUrl + status + author + artist + genre + inLibrary + realUrl + source { + id + name + displayName + } + } + } + } +`; + // ── Extensions ──────────────────────────────────────────────────────────────── export const GET_EXTENSIONS = `