mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
[BETA] Added Source Switching & Fixed Extensions Filter
This commit is contained in:
+1
-1
@@ -8,7 +8,7 @@
|
|||||||
"build": "tsc && vite build",
|
"build": "tsc && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"tauri": "tauri",
|
"tauri": "tauri",
|
||||||
"tauri:dev": "tauri dev",
|
"tauri:dev": "tauri dev --config src-tauri/tauri.dev.conf.json",
|
||||||
"tauri:build": "tauri build"
|
"tauri:build": "tauri build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"build": {
|
||||||
|
"devUrl": "http://localhost:1420",
|
||||||
|
"beforeDevCommand": "pnpm dev"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,41 +10,34 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
/* Logo set to 80px */
|
|
||||||
width: 80px;
|
width: 80px;
|
||||||
height: 80px;
|
height: 80px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
/* MARGIN REMOVED */
|
margin-bottom: var(--sp-3);
|
||||||
margin-bottom: 0;
|
|
||||||
|
|
||||||
/* Allows the logo to overflow the sidebar width if the sidebar is smaller than 80px */
|
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.logoIcon {
|
.logoIcon {
|
||||||
/* Icon set to 80px */
|
|
||||||
width: 80px;
|
width: 80px;
|
||||||
height: 80px;
|
height: 80px;
|
||||||
|
|
||||||
/* Apply your UI accent green */
|
|
||||||
background-color: var(--accent);
|
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-repeat: no-repeat;
|
||||||
mask-position: center;
|
mask-position: center;
|
||||||
mask-size: contain;
|
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-repeat: no-repeat;
|
||||||
-webkit-mask-position: center;
|
-webkit-mask-position: center;
|
||||||
-webkit-mask-size: contain;
|
-webkit-mask-size: contain;
|
||||||
|
|
||||||
/* Prominent glow for the large logo */
|
filter: drop-shadow(0 0 8px rgba(107, 143, 107, 0.35));
|
||||||
filter: drop-shadow(0 0 12px rgba(107, 143, 107, 0.4));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
@@ -83,7 +76,6 @@
|
|||||||
background: var(--accent-muted);
|
background: var(--accent-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Bottom section ── */
|
|
||||||
.bottom {
|
.bottom {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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<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);
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className={s.overlay} onClick={(e) => e.target === e.currentTarget && onClose()}>
|
||||||
|
<div className={s.modal}>
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={s.body}>
|
||||||
|
{/* ── Step 1: Pick source ── */}
|
||||||
|
{step === "source" && (
|
||||||
|
<div className={s.sourceList}>
|
||||||
|
{loadingSources ? (
|
||||||
|
<div className={s.centered}>
|
||||||
|
<CircleNotch size={16} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||||
|
</div>
|
||||||
|
) : sources.length === 0 ? (
|
||||||
|
<div className={s.centered}><span className={s.hint}>No other sources installed.</span></div>
|
||||||
|
) : (
|
||||||
|
sources.map((src) => (
|
||||||
|
<button
|
||||||
|
key={src.id}
|
||||||
|
className={[s.sourceRow, selectedSource?.id === src.id ? s.sourceRowActive : ""].join(" ").trim()}
|
||||||
|
onClick={() => { setSelectedSource(src); setStep("search"); searchSource(); }}
|
||||||
|
>
|
||||||
|
<img src={thumbUrl(src.iconUrl)} alt={src.name} className={s.sourceIcon}
|
||||||
|
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||||
|
<div className={s.sourceInfo}>
|
||||||
|
<span className={s.sourceName}>{src.displayName}</span>
|
||||||
|
<span className={s.sourceMeta}>{src.lang.toUpperCase()}{src.isNsfw ? " · NSFW" : ""}</span>
|
||||||
|
</div>
|
||||||
|
<ArrowRight size={13} weight="light" className={s.sourceArrow} />
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Step 2: Search & pick match ── */}
|
||||||
|
{step === "search" && (
|
||||||
|
<div className={s.searchStep}>
|
||||||
|
<div className={s.searchRow}>
|
||||||
|
<div className={s.searchBar}>
|
||||||
|
<MagnifyingGlass size={13} weight="light" className={s.searchIcon} />
|
||||||
|
<input
|
||||||
|
className={s.searchInput}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && searchSource()}
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className={s.error}><Warning size={13} weight="light" /> {error}</p>}
|
||||||
|
|
||||||
|
<div className={s.results}>
|
||||||
|
{searching && Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<div key={i} className={s.skResult}>
|
||||||
|
<div className={["skeleton", s.skCover].join(" ")} />
|
||||||
|
<div className={s.skMeta}>
|
||||||
|
<div className={["skeleton", s.skTitle].join(" ")} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!searching && results.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
className={s.resultRow}
|
||||||
|
onClick={() => selectMatch(m)}
|
||||||
|
disabled={loadingMatch}
|
||||||
|
>
|
||||||
|
<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" }} />}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{!searching && results.length === 0 && query && (
|
||||||
|
<div className={s.centered}><span className={s.hint}>No results.</span></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Step 3: Confirm ── */}
|
||||||
|
{step === "confirm" && selectedMatch && (
|
||||||
|
<div className={s.confirmStep}>
|
||||||
|
<div className={s.confirmRow}>
|
||||||
|
<div className={s.confirmManga}>
|
||||||
|
<div className={s.confirmCoverWrap}>
|
||||||
|
<img src={thumbUrl(manga.thumbnailUrl)} alt={manga.title} className={s.confirmCover} />
|
||||||
|
</div>
|
||||||
|
<p className={s.confirmTitle}>{manga.title}</p>
|
||||||
|
<p className={s.confirmSource}>{manga.source?.displayName ?? "Unknown"}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ArrowRight size={20} weight="light" className={s.confirmArrow} />
|
||||||
|
|
||||||
|
<div className={s.confirmManga}>
|
||||||
|
<div className={s.confirmCoverWrap}>
|
||||||
|
<img src={thumbUrl(selectedMatch.manga.thumbnailUrl)} alt={selectedMatch.manga.title} className={s.confirmCover} />
|
||||||
|
</div>
|
||||||
|
<p className={s.confirmTitle}>{selectedMatch.manga.title}</p>
|
||||||
|
<p className={s.confirmSource}>{selectedSource?.displayName ?? "Unknown"}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={s.confirmStats}>
|
||||||
|
<div className={s.statRow}>
|
||||||
|
<span className={s.statLabel}>Chapters on new source</span>
|
||||||
|
<span className={s.statVal}>{selectedMatch.chapters.length}</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className={s.confirmNote}>
|
||||||
|
The current entry will be removed from your library. Downloads are not transferred.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && <p className={s.error}><Warning size={13} weight="light" /> {error}</p>}
|
||||||
|
|
||||||
|
<div className={s.confirmActions}>
|
||||||
|
<button className={s.backBtn} onClick={() => setStep("search")} disabled={migrating}>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button className={s.migrateBtn} onClick={migrate} disabled={migrating}>
|
||||||
|
{migrating
|
||||||
|
? <><CircleNotch size={13} weight="light" className="anim-spin" /> Migrating…</>
|
||||||
|
: "Migrate"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -35,6 +35,44 @@
|
|||||||
.searchBtn:hover:not(:disabled) { filter: brightness(1.1); }
|
.searchBtn:hover:not(:disabled) { filter: brightness(1.1); }
|
||||||
.searchBtn:disabled { opacity: 0.4; cursor: default; }
|
.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); }
|
.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); }
|
.sourceSection { display: flex; flex-direction: column; gap: var(--sp-3); }
|
||||||
@@ -77,7 +115,6 @@
|
|||||||
line-height: var(--leading-snug);
|
line-height: var(--leading-snug);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Skeletons */
|
|
||||||
.skCard { flex-shrink: 0; width: 110px; }
|
.skCard { flex-shrink: 0; width: 110px; }
|
||||||
.skCover { aspect-ratio: 2/3; border-radius: var(--radius-md); }
|
.skCover { aspect-ratio: 2/3; border-radius: var(--radius-md); }
|
||||||
.skTitle { height: 11px; margin-top: var(--sp-1); width: 75%; }
|
.skTitle { height: 11px; margin-top: var(--sp-1); width: 75%; }
|
||||||
|
|||||||
@@ -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 { MagnifyingGlass, CircleNotch } from "@phosphor-icons/react";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
import { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
|
import { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
|
||||||
@@ -13,57 +13,73 @@ interface SourceResult {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CONCURRENCY = 3;
|
||||||
|
|
||||||
|
async function runConcurrent<T>(
|
||||||
|
items: T[],
|
||||||
|
fn: (item: T) => Promise<void>
|
||||||
|
): Promise<void> {
|
||||||
|
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() {
|
export default function Search() {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [submitted, setSubmitted] = useState("");
|
const [submitted, setSubmitted] = useState("");
|
||||||
const [results, setResults] = useState<SourceResult[]>([]);
|
const [results, setResults] = useState<SourceResult[]>([]);
|
||||||
const [sources, setSources] = useState<Source[]>([]);
|
const [allSources, setAllSources] = useState<Source[]>([]);
|
||||||
const [loadingSources, setLoadingSources] = useState(false);
|
const [loadingSources, setLoadingSources] = useState(false);
|
||||||
|
const [activeLang, setActiveLang] = useState<string>("preferred");
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const setActiveManga = useStore((s) => s.setActiveManga);
|
const setActiveManga = useStore((st) => st.setActiveManga);
|
||||||
const setNavPage = useStore((s) => s.setNavPage);
|
const setNavPage = useStore((st) => st.setNavPage);
|
||||||
|
const preferredLang = useStore((st) => st.settings.preferredExtensionLang);
|
||||||
|
|
||||||
const loadSources = useCallback(async () => {
|
useEffect(() => {
|
||||||
if (sources.length) return sources;
|
|
||||||
setLoadingSources(true);
|
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));
|
.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();
|
const q = query.trim();
|
||||||
if (!q) return;
|
if (!q || !visibleSources.length) return;
|
||||||
setSubmitted(q);
|
setSubmitted(q);
|
||||||
|
|
||||||
const srcs = await loadSources();
|
setResults(visibleSources.map((src) => ({ source: src, mangas: [], loading: true, error: null })));
|
||||||
// Initialise loading state for each source
|
|
||||||
setResults(srcs.map((src) => ({ source: src, mangas: [], loading: true, error: null })));
|
|
||||||
|
|
||||||
// Fire all source queries in parallel, update each independently
|
await runConcurrent(visibleSources, async (src) => {
|
||||||
srcs.forEach((src) => {
|
try {
|
||||||
gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
|
||||||
source: src.id, type: "SEARCH", page: 1, query: q,
|
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
|
|
||||||
));
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
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) {
|
function openManga(m: Manga) {
|
||||||
setActiveManga(m);
|
setActiveManga(m);
|
||||||
@@ -75,20 +91,24 @@ export default function Search() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={s.root}>
|
<div className={s.root}>
|
||||||
{/* ── Search bar ── */}
|
|
||||||
<div className={s.header}>
|
<div className={s.header}>
|
||||||
<h1 className={s.heading}>Search</h1>
|
<h1 className={s.heading}>Search</h1>
|
||||||
<div className={s.searchBar}>
|
<div className={s.searchBar}>
|
||||||
<MagnifyingGlass size={14} className={s.searchIcon} weight="light" />
|
<MagnifyingGlass size={14} className={s.searchIcon} weight="light" />
|
||||||
<input ref={inputRef} className={s.searchInput}
|
<input
|
||||||
placeholder="Search across all sources…"
|
ref={inputRef}
|
||||||
|
className={s.searchInput}
|
||||||
|
placeholder="Search across sources…"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && runSearch()}
|
onKeyDown={(e) => e.key === "Enter" && runSearch()}
|
||||||
autoFocus />
|
autoFocus
|
||||||
<button className={s.searchBtn}
|
/>
|
||||||
|
<button
|
||||||
|
className={s.searchBtn}
|
||||||
onClick={runSearch}
|
onClick={runSearch}
|
||||||
disabled={!query.trim() || loadingSources}>
|
disabled={!query.trim() || loadingSources}
|
||||||
|
>
|
||||||
{loadingSources
|
{loadingSources
|
||||||
? <CircleNotch size={13} weight="light" className="anim-spin" />
|
? <CircleNotch size={13} weight="light" className="anim-spin" />
|
||||||
: "Search"}
|
: "Search"}
|
||||||
@@ -96,16 +116,31 @@ export default function Search() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Empty state ── */}
|
<div className={s.langBar}>
|
||||||
|
{langs.map((l) => (
|
||||||
|
<button
|
||||||
|
key={l}
|
||||||
|
onClick={() => setActiveLang(l)}
|
||||||
|
className={[s.langBtn, activeLang === l ? s.langBtnActive : ""].join(" ").trim()}
|
||||||
|
>
|
||||||
|
{l === "preferred" ? `${preferredLang.toUpperCase()} (default)` : l === "all" ? "All" : l.toUpperCase()}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{visibleSources.length > 0 && (
|
||||||
|
<span className={s.sourceCount}>{visibleSources.length} sources</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{!submitted && (
|
{!submitted && (
|
||||||
<div className={s.empty}>
|
<div className={s.empty}>
|
||||||
<MagnifyingGlass size={36} weight="light" className={s.emptyIcon} />
|
<MagnifyingGlass size={36} weight="light" className={s.emptyIcon} />
|
||||||
<p className={s.emptyText}>Search across all installed sources at once</p>
|
<p className={s.emptyText}>Search across sources</p>
|
||||||
<p className={s.emptyHint}>Results from each source appear as they load.</p>
|
<p className={s.emptyHint}>
|
||||||
|
Searching {visibleSources.length} {activeLang === "preferred" ? `${preferredLang.toUpperCase()}` : activeLang === "all" ? "" : activeLang.toUpperCase()} source{visibleSources.length !== 1 ? "s" : ""}.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* ── Results ── */}
|
|
||||||
{submitted && (
|
{submitted && (
|
||||||
<div className={s.results}>
|
<div className={s.results}>
|
||||||
{results.length === 0 && (
|
{results.length === 0 && (
|
||||||
@@ -119,9 +154,12 @@ export default function Search() {
|
|||||||
.map(({ source, mangas, loading, error }) => (
|
.map(({ source, mangas, loading, error }) => (
|
||||||
<div key={source.id} className={s.sourceSection}>
|
<div key={source.id} className={s.sourceSection}>
|
||||||
<div className={s.sourceHeader}>
|
<div className={s.sourceHeader}>
|
||||||
<img src={thumbUrl(source.iconUrl)} alt={source.displayName}
|
<img
|
||||||
|
src={thumbUrl(source.iconUrl)}
|
||||||
|
alt={source.displayName}
|
||||||
className={s.sourceIcon}
|
className={s.sourceIcon}
|
||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||||
|
/>
|
||||||
<span className={s.sourceName}>{source.displayName}</span>
|
<span className={s.sourceName}>{source.displayName}</span>
|
||||||
{loading && <CircleNotch size={12} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />}
|
{loading && <CircleNotch size={12} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />}
|
||||||
{!loading && mangas.length > 0 && (
|
{!loading && mangas.length > 0 && (
|
||||||
|
|||||||
@@ -428,3 +428,91 @@
|
|||||||
.dlItem:hover { background: var(--bg-overlay); color: var(--text-primary); }
|
.dlItem:hover { background: var(--bg-overlay); color: var(--text-primary); }
|
||||||
|
|
||||||
.dlItemSub { font-size: var(--text-xs); color: var(--text-faint); }
|
.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);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { useEffect, useState, useMemo, useCallback } from "react";
|
|||||||
import {
|
import {
|
||||||
ArrowLeft, BookmarkSimple, Download, CheckCircle,
|
ArrowLeft, BookmarkSimple, Download, CheckCircle,
|
||||||
ArrowSquareOut, BookOpen, CircleNotch, Play,
|
ArrowSquareOut, BookOpen, CircleNotch, Play,
|
||||||
SortAscending, SortDescending,
|
SortAscending, SortDescending, CaretDown, ArrowsClockwise,
|
||||||
} from "@phosphor-icons/react";
|
} from "@phosphor-icons/react";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
import {
|
import {
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "../../lib/queries";
|
} from "../../lib/queries";
|
||||||
import { useStore } from "../../store";
|
import { useStore } from "../../store";
|
||||||
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
||||||
|
import MigrateModal from "./MigrateModal";
|
||||||
import type { Manga, Chapter } from "../../lib/types";
|
import type { Manga, Chapter } from "../../lib/types";
|
||||||
import s from "./SeriesDetail.module.css";
|
import s from "./SeriesDetail.module.css";
|
||||||
|
|
||||||
@@ -44,6 +45,8 @@ export default function SeriesDetail() {
|
|||||||
const [loadingChapters, setLoadingChapters] = useState(true);
|
const [loadingChapters, setLoadingChapters] = useState(true);
|
||||||
const [enqueueing, setEnqueueing] = useState<Set<number>>(new Set());
|
const [enqueueing, setEnqueueing] = useState<Set<number>>(new Set());
|
||||||
const [dlOpen, setDlOpen] = useState(false);
|
const [dlOpen, setDlOpen] = useState(false);
|
||||||
|
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||||
|
const [migrateOpen, setMigrateOpen] = useState(false);
|
||||||
const [togglingLibrary, setTogglingLibrary] = useState(false);
|
const [togglingLibrary, setTogglingLibrary] = useState(false);
|
||||||
const [chapterPage, setChapterPage] = useState(1);
|
const [chapterPage, setChapterPage] = useState(1);
|
||||||
const [ctx, setCtx] = useState<CtxState | null>(null);
|
const [ctx, setCtx] = useState<CtxState | null>(null);
|
||||||
@@ -62,7 +65,6 @@ export default function SeriesDetail() {
|
|||||||
const loadChapters = useCallback((mangaId: number) => {
|
const loadChapters = useCallback((mangaId: number) => {
|
||||||
return gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId })
|
return gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId })
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
// Always store in natural order (ascending sourceOrder), sort in render
|
|
||||||
const sorted = [...data.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
const sorted = [...data.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
setChapters(sorted);
|
setChapters(sorted);
|
||||||
return sorted;
|
return sorted;
|
||||||
@@ -79,17 +81,13 @@ export default function SeriesDetail() {
|
|||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
.finally(() => setLoadingChapters(false));
|
.finally(() => setLoadingChapters(false));
|
||||||
|
|
||||||
// Fetch from source in background
|
|
||||||
gql(FETCH_CHAPTERS, { mangaId: activeManga.id })
|
gql(FETCH_CHAPTERS, { mangaId: activeManga.id })
|
||||||
.then(() => loadChapters(activeManga.id))
|
.then(() => loadChapters(activeManga.id))
|
||||||
.catch(console.error);
|
.catch(console.error);
|
||||||
}, [activeManga?.id]);
|
}, [activeManga?.id]);
|
||||||
|
|
||||||
// Sorted chapters based on setting
|
|
||||||
const sortedChapters = useMemo(() =>
|
const sortedChapters = useMemo(() =>
|
||||||
sortDir === "desc"
|
sortDir === "desc" ? [...chapters].reverse() : [...chapters],
|
||||||
? [...chapters].reverse()
|
|
||||||
: [...chapters],
|
|
||||||
[chapters, sortDir]
|
[chapters, sortDir]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -99,15 +97,12 @@ export default function SeriesDetail() {
|
|||||||
chapterPage * CHAPTERS_PER_PAGE
|
chapterPage * CHAPTERS_PER_PAGE
|
||||||
);
|
);
|
||||||
|
|
||||||
// Progress stats
|
|
||||||
const readCount = chapters.filter((c) => c.isRead).length;
|
const readCount = chapters.filter((c) => c.isRead).length;
|
||||||
const totalCount = chapters.length;
|
const totalCount = chapters.length;
|
||||||
const progressPct = totalCount > 0 ? (readCount / totalCount) * 100 : 0;
|
const progressPct = totalCount > 0 ? (readCount / totalCount) * 100 : 0;
|
||||||
|
|
||||||
// Start / Continue reading logic
|
|
||||||
const continueChapter = useMemo(() => {
|
const continueChapter = useMemo(() => {
|
||||||
if (!chapters.length) return null;
|
if (!chapters.length) return null;
|
||||||
// Find first unread chapter (in ascending order)
|
|
||||||
const asc = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
const asc = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
const inProgress = asc.find((c) => !c.isRead && (c.lastPageRead ?? 0) > 0);
|
const inProgress = asc.find((c) => !c.isRead && (c.lastPageRead ?? 0) > 0);
|
||||||
if (inProgress) return { chapter: inProgress, type: "continue" as const };
|
if (inProgress) return { chapter: inProgress, type: "continue" as const };
|
||||||
@@ -139,7 +134,6 @@ export default function SeriesDetail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function markAllAboveRead(indexInSorted: number) {
|
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 targets = sortedChapters.slice(0, indexInSorted + 1);
|
||||||
const ids = targets.filter((c) => !c.isRead).map((c) => c.id);
|
const ids = targets.filter((c) => !c.isRead).map((c) => c.id);
|
||||||
if (!ids.length) return;
|
if (!ids.length) return;
|
||||||
@@ -244,7 +238,6 @@ export default function SeriesDetail() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{manga?.source && <p className={s.sourceLabel}>{manga.source.displayName}</p>}
|
|
||||||
{manga?.description && <p className={s.description}>{manga.description}</p>}
|
{manga?.description && <p className={s.description}>{manga.description}</p>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -279,7 +272,6 @@ export default function SeriesDetail() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Start / Continue reading button */}
|
|
||||||
{continueChapter && (
|
{continueChapter && (
|
||||||
<button
|
<button
|
||||||
className={s.readBtn}
|
className={s.readBtn}
|
||||||
@@ -299,8 +291,6 @@ export default function SeriesDetail() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
{/* ── Download panel ── */}
|
|
||||||
{chapters.length > 0 && (
|
{chapters.length > 0 && (
|
||||||
<div className={s.downloadSection}>
|
<div className={s.downloadSection}>
|
||||||
<button className={s.downloadToggle} onClick={() => setDlOpen((p) => !p)}>
|
<button className={s.downloadToggle} onClick={() => setDlOpen((p) => !p)}>
|
||||||
@@ -347,11 +337,40 @@ export default function SeriesDetail() {
|
|||||||
<p className={s.chapterCount}>
|
<p className={s.chapterCount}>
|
||||||
{totalCount} {totalCount === 1 ? "chapter" : "chapters"}
|
{totalCount} {totalCount === 1 ? "chapter" : "chapters"}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
{/* ── Details (collapsible) ── */}
|
||||||
|
{!loadingManga && manga?.source && (
|
||||||
|
<div className={s.detailsSection}>
|
||||||
|
<button className={s.detailsToggle} onClick={() => setDetailsOpen((p) => !p)}>
|
||||||
|
<span>Details</span>
|
||||||
|
<CaretDown size={11} weight="light" className={detailsOpen ? s.caretOpen : s.caretClosed} />
|
||||||
|
</button>
|
||||||
|
{detailsOpen && (
|
||||||
|
<div className={s.detailsBody}>
|
||||||
|
<div className={s.detailRow}>
|
||||||
|
<span className={s.detailKey}>Source</span>
|
||||||
|
<span className={s.detailVal}>{manga.source.displayName}</span>
|
||||||
|
</div>
|
||||||
|
<div className={s.detailRow}>
|
||||||
|
<span className={s.detailKey}>Language</span>
|
||||||
|
<span className={s.detailVal}>{manga.source.name.match(/\(([^)]+)\)$/)?.[1] ?? "—"}</span>
|
||||||
|
</div>
|
||||||
|
<div className={s.detailRow}>
|
||||||
|
<span className={s.detailKey}>Source ID</span>
|
||||||
|
<span className={[s.detailVal, s.detailMono].join(" ")}>{manga.source.id}</span>
|
||||||
|
</div>
|
||||||
|
<button className={s.migrateBtn} onClick={() => setMigrateOpen(true)}>
|
||||||
|
<ArrowsClockwise size={12} weight="light" />
|
||||||
|
Switch source
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Chapter list ── */}
|
{/* ── Chapter list ── */}
|
||||||
<div className={s.listWrap}>
|
<div className={s.listWrap}>
|
||||||
{/* List header with sort + pagination */}
|
|
||||||
<div className={s.listHeader}>
|
<div className={s.listHeader}>
|
||||||
<button
|
<button
|
||||||
className={s.sortBtn}
|
className={s.sortBtn}
|
||||||
@@ -436,7 +455,6 @@ export default function SeriesDetail() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom pagination */}
|
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className={s.paginationBottom}>
|
<div className={s.paginationBottom}>
|
||||||
<button
|
<button
|
||||||
@@ -454,7 +472,6 @@ export default function SeriesDetail() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Context menu */}
|
|
||||||
{ctx && (
|
{ctx && (
|
||||||
<ContextMenu
|
<ContextMenu
|
||||||
x={ctx.x}
|
x={ctx.x}
|
||||||
@@ -463,6 +480,18 @@ export default function SeriesDetail() {
|
|||||||
onClose={() => setCtx(null)}
|
onClose={() => setCtx(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{migrateOpen && manga && (
|
||||||
|
<MigrateModal
|
||||||
|
manga={manga}
|
||||||
|
currentChapters={chapters}
|
||||||
|
onClose={() => setMigrateOpen(false)}
|
||||||
|
onMigrated={(newManga) => {
|
||||||
|
setMigrateOpen(false);
|
||||||
|
setActiveManga(newManga);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -100,6 +100,15 @@
|
|||||||
|
|
||||||
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
||||||
|
|
||||||
|
.rowIndented {
|
||||||
|
padding-left: var(--sp-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.indentSpacer {
|
||||||
|
width: 32px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
@@ -128,6 +137,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.arrow {
|
.arrow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
import { useEffect, useState } from "react";
|
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 { gql, thumbUrl } from "../../lib/client";
|
||||||
import { GET_SOURCES } from "../../lib/queries";
|
import { GET_SOURCES } from "../../lib/queries";
|
||||||
import { useStore } from "../../store";
|
import { useStore } from "../../store";
|
||||||
import type { Source } from "../../lib/types";
|
import type { Source } from "../../lib/types";
|
||||||
import s from "./SourceList.module.css";
|
import s from "./SourceList.module.css";
|
||||||
|
|
||||||
|
type Group = { name: string; icon: string; sources: Source[] };
|
||||||
|
|
||||||
export default function SourceList() {
|
export default function SourceList() {
|
||||||
const [sources, setSources] = useState<Source[]>([]);
|
const [sources, setSources] = useState<Source[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [lang, setLang] = useState("all");
|
const [lang, setLang] = useState("all");
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
const setActiveSource = useStore((state) => state.setActiveSource);
|
const setActiveSource = useStore((state) => state.setActiveSource);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -20,10 +23,10 @@ export default function SourceList() {
|
|||||||
.finally(() => setLoading(false));
|
.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) => {
|
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 matchLang = lang === "all" || src.lang === lang;
|
||||||
const matchSearch =
|
const matchSearch =
|
||||||
src.name.toLowerCase().includes(search.toLowerCase()) ||
|
src.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
@@ -31,6 +34,26 @@ export default function SourceList() {
|
|||||||
return matchLang && matchSearch;
|
return matchLang && matchSearch;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const groups: Group[] = [];
|
||||||
|
const seen = new Map<string, Group>();
|
||||||
|
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 (
|
return (
|
||||||
<div className={s.root}>
|
<div className={s.root}>
|
||||||
<div className={s.header}>
|
<div className={s.header}>
|
||||||
@@ -62,30 +85,56 @@ export default function SourceList() {
|
|||||||
<div className={s.empty}>
|
<div className={s.empty}>
|
||||||
<CircleNotch size={16} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
<CircleNotch size={16} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||||
</div>
|
</div>
|
||||||
) : filtered.length === 0 ? (
|
) : groups.length === 0 ? (
|
||||||
<div className={s.empty}>No sources found.</div>
|
<div className={s.empty}>No sources found.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className={s.list}>
|
<div className={s.list}>
|
||||||
{filtered.map((src) => (
|
{groups.map((g) => {
|
||||||
|
const single = g.sources.length === 1;
|
||||||
|
const open = expanded.has(g.name);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={g.name}>
|
||||||
<button
|
<button
|
||||||
key={src.id}
|
|
||||||
className={s.row}
|
className={s.row}
|
||||||
onClick={() => setActiveSource(src)}
|
onClick={() => single ? setActiveSource(g.sources[0]) : toggleGroup(g.name)}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
src={thumbUrl(src.iconUrl)}
|
src={thumbUrl(g.icon)}
|
||||||
alt={src.name}
|
alt={g.name}
|
||||||
className={s.icon}
|
className={s.icon}
|
||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||||
/>
|
/>
|
||||||
<div className={s.info}>
|
<div className={s.info}>
|
||||||
<span className={s.name}>{src.displayName}</span>
|
<span className={s.name}>{g.name}</span>
|
||||||
<span className={s.meta}>{src.lang.toUpperCase()}{src.isNsfw ? " · NSFW" : ""}</span>
|
<span className={s.meta}>
|
||||||
|
{single
|
||||||
|
? `${g.sources[0].lang.toUpperCase()}${g.sources[0].isNsfw ? " · NSFW" : ""}`
|
||||||
|
: `${g.sources.length} languages`}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className={s.arrow}>
|
||||||
|
{single ? "→" : open ? <CaretDown size={12} weight="light" /> : <CaretRight size={12} weight="light" />}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!single && open && g.sources.map((src) => (
|
||||||
|
<button
|
||||||
|
key={src.id}
|
||||||
|
className={[s.row, s.rowIndented].join(" ")}
|
||||||
|
onClick={() => setActiveSource(src)}
|
||||||
|
>
|
||||||
|
<div className={s.indentSpacer} />
|
||||||
|
<div className={s.info}>
|
||||||
|
<span className={s.name}>{src.lang.toUpperCase()}{src.isNsfw ? " · NSFW" : ""}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className={s.arrow}>→</span>
|
<span className={s.arrow}>→</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+37
-3
@@ -1,6 +1,5 @@
|
|||||||
// ── Library ──────────────────────────────────────────────────────────────────
|
// ── Library ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Full library query with chapter progress — only used for inLibrary manga
|
|
||||||
export const GET_LIBRARY = `
|
export const GET_LIBRARY = `
|
||||||
query GetLibrary {
|
query GetLibrary {
|
||||||
mangas(condition: { inLibrary: true }) {
|
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 = `
|
export const GET_ALL_MANGA = `
|
||||||
query GetAllManga {
|
query GetAllManga {
|
||||||
mangas {
|
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 = `
|
export const DELETE_DOWNLOADED_CHAPTERS = `
|
||||||
mutation DeleteDownloadedChapters($ids: [Int!]!) {
|
mutation DeleteDownloadedChapters($ids: [Int!]!) {
|
||||||
deleteDownloadedChapters(input: { ids: $ids }) {
|
deleteDownloadedChapters(input: { ids: $ids }) {
|
||||||
@@ -153,7 +164,6 @@ export const DELETE_DOWNLOADED_CHAPTERS = `
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
// ── Downloads ─────────────────────────────────────────────────────────────────
|
// ── Downloads ─────────────────────────────────────────────────────────────────
|
||||||
// Updated to include manga title, thumbnail, and pageCount
|
|
||||||
|
|
||||||
export const GET_DOWNLOAD_STATUS = `
|
export const GET_DOWNLOAD_STATUS = `
|
||||||
query GetDownloadStatus {
|
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 ────────────────────────────────────────────────────────────────
|
// ── Extensions ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const GET_EXTENSIONS = `
|
export const GET_EXTENSIONS = `
|
||||||
|
|||||||
Reference in New Issue
Block a user