diff --git a/src/components/pages/Search.module.css b/src/components/pages/Search.module.css index fff0938..31a0620 100644 --- a/src/components/pages/Search.module.css +++ b/src/components/pages/Search.module.css @@ -1,250 +1,789 @@ +/* ── Root ──────────────────────────────────────────────────────────────────── */ + .root { - position: fixed; inset: 0; - background: #000; - display: flex; flex-direction: column; - z-index: var(--z-reader); - transform: translateZ(0); will-change: transform; + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + animation: fadeIn 0.14s ease both; } -/* ── UI autohide ── */ -.uiHidden { - opacity: 0; - pointer-events: none; - transition: opacity 0.25s ease; -} -.topbar, .bottombar { - transition: opacity 0.25s ease; +/* ── Header ────────────────────────────────────────────────────────────────── */ + +.header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--sp-5) var(--sp-6) var(--sp-3); + flex-shrink: 0; + border-bottom: 1px solid var(--border-dim); } -/* ── Topbar ── */ -.topbar { - display: flex; align-items: center; gap: var(--sp-1); - padding: 0 var(--sp-3); height: 40px; - background: var(--bg-void); border-bottom: 1px solid var(--border-dim); - flex-shrink: 0; overflow: visible; - position: relative; z-index: 2; +.heading { + font-family: var(--font-ui); + font-size: var(--text-xs); + font-weight: var(--weight-normal); + color: var(--text-faint); + letter-spacing: var(--tracking-wider); + text-transform: uppercase; } -.iconBtn { - display: flex; align-items: center; justify-content: center; - width: 28px; height: 28px; border-radius: var(--radius-sm); - color: var(--text-muted); flex-shrink: 0; +/* ── Tabs ──────────────────────────────────────────────────────────────────── */ + +.tabs { + display: flex; + gap: 2px; + background: var(--bg-raised); + border: 1px solid var(--border-dim); + border-radius: var(--radius-md); + padding: 2px; +} + +.tab { + display: flex; + align-items: center; + gap: 5px; + font-family: var(--font-ui); + font-size: var(--text-2xs); + letter-spacing: var(--tracking-wide); + text-transform: uppercase; + padding: 4px 10px; + border-radius: var(--radius-sm); + background: none; + color: var(--text-faint); + cursor: pointer; + transition: background var(--t-base), color var(--t-base); + white-space: nowrap; +} +.tab:hover { color: var(--text-muted); } + +.tabActive { + background: var(--accent-muted); + color: var(--accent-fg); + border: 1px solid var(--accent-dim); +} +.tabActive:hover { color: var(--accent-fg); } + +/* ── Keyword bar ───────────────────────────────────────────────────────────── */ + +.keywordBar { + padding: var(--sp-3) var(--sp-4); + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: var(--sp-2); +} + +.searchBar { + 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); } + +.clearBtn { + color: var(--text-faint); + font-size: 14px; + line-height: 1; + background: none; + border: none; + cursor: pointer; + padding: 2px; + transition: color var(--t-base); +} +.clearBtn:hover { color: var(--text-muted); } + +.advancedBtn { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: var(--radius-sm); + color: var(--text-faint); + flex-shrink: 0; transition: color var(--t-base), background var(--t-base); } -.iconBtn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); } -.iconBtn:disabled { opacity: 0.2; cursor: default; } +.advancedBtn:hover { color: var(--text-muted); background: var(--bg-overlay); } +.advancedBtnActive { color: var(--accent-fg); background: var(--accent-muted); } +.advancedBtnActive:hover { color: var(--accent-fg); background: var(--accent-muted); } -.chLabel { - flex: 1; display: flex; align-items: center; gap: var(--sp-2); - font-size: var(--text-sm); color: var(--text-muted); - overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +.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); } -.chTitle { color: var(--text-secondary); font-weight: var(--weight-medium); } -.chSep { color: var(--text-faint); } +.searchBtn:hover:not(:disabled) { filter: brightness(1.1); } +.searchBtn:disabled { opacity: 0.4; cursor: default; } -.pageLabel { - font-family: var(--font-ui); font-size: var(--text-xs); - color: var(--text-muted); letter-spacing: var(--tracking-wide); +/* ── Advanced filter panel ─────────────────────────────────────────────────── */ + +.advancedPanel { + background: var(--bg-raised); + border: 1px solid var(--border-dim); + border-radius: var(--radius-md); + padding: var(--sp-3); + display: flex; + flex-direction: column; + gap: var(--sp-2); + animation: fadeIn 0.1s ease both; +} + +.advancedHeader { + display: flex; + align-items: center; + justify-content: space-between; +} + +.advancedTitle { + font-family: var(--font-ui); + font-size: var(--text-2xs); + color: var(--text-faint); + letter-spacing: var(--tracking-wider); + text-transform: uppercase; +} + +.advancedActions { display: flex; gap: var(--sp-1); } + +.advancedLink { + font-family: var(--font-ui); + font-size: var(--text-2xs); + letter-spacing: var(--tracking-wide); + color: var(--accent-fg); + background: none; + border: none; + padding: 0; + cursor: pointer; + opacity: 0.7; + transition: opacity var(--t-base); +} +.advancedLink:hover { opacity: 1; } + +.langGrid { + display: flex; + flex-wrap: wrap; + gap: var(--sp-1); +} + +.langChip { + font-family: var(--font-ui); + font-size: var(--text-2xs); + letter-spacing: var(--tracking-wide); + 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); +} +.langChip:hover { color: var(--text-muted); border-color: var(--border-strong); } + +.langChipActive { + background: var(--accent-muted); + border-color: var(--accent-dim); + color: var(--accent-fg); +} +.langChipActive:hover { background: var(--accent-muted); color: var(--accent-fg); } + +.advancedDivider { + height: 1px; + background: var(--border-dim); + margin: 2px 0; +} + +.advancedCheck { + display: flex; + align-items: center; + gap: var(--sp-2); + font-family: var(--font-ui); + font-size: var(--text-xs); + color: var(--text-muted); + cursor: pointer; +} + +.checkbox { accent-color: var(--accent-fg); cursor: pointer; } + +.advancedFooter { + font-family: var(--font-ui); + font-size: var(--text-2xs); + color: var(--text-faint); + letter-spacing: var(--tracking-wide); +} + +.advancedLinkStandalone { + display: inline-flex; + align-items: center; + gap: 5px; + font-family: var(--font-ui); + font-size: var(--text-2xs); + letter-spacing: var(--tracking-wide); + color: var(--accent-fg); + background: none; + border: none; + padding: 0; + cursor: pointer; + opacity: 0.7; + transition: opacity var(--t-base); +} +.advancedLinkStandalone:hover { opacity: 1; } + +/* ── Empty states ──────────────────────────────────────────────────────────── */ + +.empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--sp-2); +} + +.emptyIcon { color: var(--text-faint); } +.emptyText { font-size: var(--text-base); color: var(--text-muted); } +.emptyHint { font-size: var(--text-sm); color: var(--text-faint); } + +/* ── Keyword results ───────────────────────────────────────────────────────── */ + +.results { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +.sourceSection { + padding: var(--sp-1) var(--sp-4) var(--sp-3); + border-bottom: 1px solid var(--border-dim); +} +.sourceSection:last-child { border-bottom: none; } + +.sourceHeader { + display: flex; + align-items: center; + gap: var(--sp-2); + padding: var(--sp-2) 0; +} + +.sourceIcon { + width: 18px; + height: 18px; + border-radius: var(--radius-sm); + object-fit: cover; + flex-shrink: 0; + background: var(--bg-raised); +} + +.sourceName { + font-size: var(--text-base); + font-weight: var(--weight-medium); + color: var(--text-secondary); +} + +.sourceLang { + font-family: var(--font-ui); + font-size: var(--text-2xs); + color: var(--text-faint); + letter-spacing: var(--tracking-wide); + background: var(--bg-raised); + border: 1px solid var(--border-dim); + border-radius: var(--radius-sm); + padding: 1px 5px; +} + +.resultCount { + margin-left: auto; + font-family: var(--font-ui); + font-size: var(--text-2xs); + color: var(--text-faint); + letter-spacing: var(--tracking-wide); +} + +.sourceError { + font-size: var(--text-xs); + color: var(--color-error); + padding: var(--sp-1) 0; + margin: 0; +} + +/* Horizontal scroll row */ +.sourceRow { + display: flex; + gap: var(--sp-3); + overflow-x: auto; + padding-bottom: var(--sp-1); + scrollbar-width: none; +} +.sourceRow::-webkit-scrollbar { display: none; } + +/* ── Manga card ────────────────────────────────────────────────────────────── */ + +.card { + display: flex; + flex-direction: column; + gap: var(--sp-2); + cursor: pointer; + flex-shrink: 0; + width: 110px; + text-align: left; + background: none; + border: none; + padding: 0; +} +.card:hover .cover { filter: brightness(1.06); } +.card:hover .cardTitle { color: var(--text-primary); } + +.coverWrap { + position: relative; + width: 100%; + aspect-ratio: 2 / 3; + border-radius: var(--radius-md); + overflow: hidden; + background: var(--bg-raised); + border: 1px solid var(--border-dim); + transform: translateZ(0); +} + +.cover { + width: 100%; + height: 100%; + object-fit: cover; + transition: filter var(--t-base); +} + +.inLibBadge { + position: absolute; + bottom: var(--sp-1); + right: var(--sp-1); + background: var(--accent-dim); + color: var(--accent-fg); + font-family: var(--font-ui); + font-size: 9px; + font-weight: var(--weight-medium); + letter-spacing: var(--tracking-wide); + padding: 1px 5px; + border-radius: var(--radius-sm); + border: 1px solid var(--accent-muted); +} + +.cardTitle { + font-size: var(--text-sm); + color: var(--text-secondary); + line-height: var(--leading-snug); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + transition: color var(--t-base); +} + +/* ── Skeleton ──────────────────────────────────────────────────────────────── */ + +.skCard { + display: flex; + flex-direction: column; + gap: var(--sp-2); + flex-shrink: 0; + width: 110px; +} + +.tagGrid .card { width: 100%; } +.tagGrid .skCard { width: 100%; } + +.skeleton { border-radius: var(--radius-sm); } + +.skCover { + aspect-ratio: 2 / 3; + width: 100%; + border-radius: var(--radius-md); +} + +.skTitle { height: 10px; width: 80%; } + +/* ── Split root (Tag + Source tabs) ────────────────────────────────────────── */ + +.splitRoot { + flex: 1; + display: flex; + overflow: hidden; +} + +/* ── Split sidebar ─────────────────────────────────────────────────────────── */ + +.splitSidebar { + width: 180px; + flex-shrink: 0; + border-right: 1px solid var(--border-dim); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.splitSearchWrap { + display: flex; + align-items: center; + gap: var(--sp-1); + padding: var(--sp-2) var(--sp-3); + border-bottom: 1px solid var(--border-dim); flex-shrink: 0; } -.topSep { - width: 1px; height: 16px; - background: var(--border-dim); flex-shrink: 0; margin: 0 var(--sp-1); -} +.splitSearchIcon { color: var(--text-faint); flex-shrink: 0; } -.modeBtn { - display: flex; align-items: center; gap: 4px; - padding: 4px var(--sp-2); border-radius: var(--radius-sm); - color: var(--text-muted); flex-shrink: 0; - font-family: var(--font-ui); font-size: var(--text-2xs); - letter-spacing: var(--tracking-wide); - transition: color var(--t-base), background var(--t-base); +.splitSearchInput { + flex: 1; + background: none; + border: none; + outline: none; + font-size: var(--text-xs); + color: var(--text-primary); + font-family: var(--font-ui); + min-width: 0; } -.modeBtn:hover { color: var(--text-primary); background: var(--bg-raised); } -.modeBtnActive { color: var(--accent-fg); background: var(--accent-muted); } -.modeBtnActive:hover { color: var(--accent-fg); background: var(--accent-muted); } -.modeBtnLabel { text-transform: capitalize; } +.splitSearchInput::placeholder { color: var(--text-faint); } -/* ── Zoom ── */ -.zoomWrap { - position: relative; flex-shrink: 0; -} - -.zoomBtn { - font-family: var(--font-ui); font-size: var(--text-2xs); - letter-spacing: var(--tracking-wide); color: var(--text-faint); - padding: 4px var(--sp-2); border-radius: var(--radius-sm); - min-width: 36px; text-align: center; - transition: color var(--t-base), background var(--t-base); -} -.zoomBtn:hover { color: var(--text-secondary); background: var(--bg-raised); } - -.zoomPopover { - position: absolute; top: calc(100% + 6px); left: 50%; - transform: translateX(-50%); - background: var(--bg-raised); border: 1px solid var(--border-base); - border-radius: var(--radius-lg); padding: var(--sp-3) var(--sp-3) var(--sp-2); - display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); - box-shadow: 0 8px 24px rgba(0,0,0,0.5); - z-index: 100; min-width: 160px; - animation: scaleIn 0.1s ease both; transform-origin: top center; -} - -.zoomSlider { - -webkit-appearance: none; - appearance: none; - width: 140px; height: 3px; - background: var(--border-strong); - border-radius: 2px; outline: none; cursor: pointer; -} -.zoomSlider::-webkit-slider-thumb { - -webkit-appearance: none; - appearance: none; - width: 12px; height: 12px; - border-radius: 50%; - background: var(--accent-fg); +.splitSearchClear { + color: var(--text-faint); + font-size: 13px; + line-height: 1; + background: none; + border: none; cursor: pointer; + padding: 2px; + transition: color var(--t-base); } -.zoomSlider::-moz-range-thumb { - width: 12px; height: 12px; - border-radius: 50%; border: none; - background: var(--accent-fg); - cursor: pointer; +.splitSearchClear:hover { color: var(--text-muted); } + +.splitList { + flex: 1; + overflow-y: auto; + padding: var(--sp-1); + scrollbar-width: thin; + scrollbar-color: var(--border-dim) transparent; } -.zoomResetBtn { - font-family: var(--font-ui); font-size: var(--text-xs); - color: var(--text-muted); letter-spacing: var(--tracking-wide); - padding: 2px var(--sp-2); border-radius: var(--radius-sm); - transition: color var(--t-base), background var(--t-base); -} -.zoomResetBtn:hover { color: var(--text-primary); background: var(--bg-overlay); } - -/* ── Viewer ── */ -.viewer { - flex: 1; overflow-y: auto; overflow-x: hidden; - display: flex; flex-direction: column; - align-items: center; justify-content: center; - -webkit-overflow-scrolling: touch; -} - -.viewerStrip { - justify-content: flex-start; - padding: var(--sp-4) 0; - overflow-anchor: auto; /* browser preserves scroll pos when nodes are added/removed above */ -} - -/* Sentinel must not become a scroll anchor point */ -.viewerStrip > div:last-child { overflow-anchor: none; } - -/* ── Images ── */ -.img { - display: block; user-select: none; - image-rendering: auto; - overflow-anchor: auto; /* each image is a valid anchor point */ -} -.img.optimizeContrast { image-rendering: -webkit-optimize-contrast; } - -/* Fit modes */ -.fitWidth { max-width: var(--max-page-width); width: 100%; height: auto; } -.fitHeight { max-height: calc(100vh - 80px); width: auto; max-width: 100%; } -.fitScreen { max-width: 100%; max-height: calc(100vh - 80px); object-fit: contain; } -.fitOriginal { max-width: none; width: auto; height: auto; } - -/* Longstrip */ -.stripGap { margin-bottom: 8px; } - -/* ── Double page ── */ -.doubleWrap { - display: flex; align-items: flex-start; justify-content: center; - max-width: calc(var(--max-page-width) * 2); +.splitItem { + display: flex; + align-items: center; + gap: var(--sp-2); width: 100%; + padding: 7px var(--sp-3); + border-radius: var(--radius-md); + border: 1px solid transparent; + background: none; + text-align: left; + cursor: pointer; + transition: background var(--t-fast), border-color var(--t-fast); } -.pageHalf { flex: 1; min-width: 0; object-fit: contain; } -.gapLeft { margin-right: 2px; } -.gapRight { margin-left: 2px; } +.splitItem:hover { background: var(--bg-raised); border-color: var(--border-dim); } -/* ── Bottom nav ── */ -.bottombar { - display: flex; align-items: center; justify-content: center; gap: var(--sp-4); - padding: var(--sp-3); border-top: 1px solid var(--border-dim); - background: var(--bg-void); flex-shrink: 0; +.splitItemActive { + background: var(--accent-muted); + border-color: var(--accent-dim); +} +.splitItemActive:hover { background: var(--accent-muted); } + +.splitItemLabel { + font-size: var(--text-xs); + color: var(--text-muted); + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.splitItemActive .splitItemLabel { color: var(--accent-fg); font-weight: var(--weight-medium); } + +.splitItemSource { gap: var(--sp-2); } + +.splitEmpty { + font-family: var(--font-ui); + font-size: var(--text-xs); + color: var(--text-faint); + padding: var(--sp-3); + margin: 0; } -.navBtn { - display: flex; align-items: center; justify-content: center; - width: 34px; height: 34px; border-radius: var(--radius-md); - border: 1px solid var(--border-strong); color: var(--text-muted); - transition: background var(--t-base), color var(--t-base); -} -.navBtn:hover:not(:disabled) { background: var(--bg-raised); color: var(--text-primary); } -.navBtn:disabled { opacity: 0.25; cursor: default; } - -/* ── States ── */ -.center { - display: flex; flex-direction: column; align-items: center; justify-content: center; - position: fixed; inset: 0; background: #000; -} -.errorMsg { color: var(--color-error); font-size: var(--text-base); } - -/* ── Download modal ── */ -.dlBackdrop { - position: fixed; inset: 0; - z-index: calc(var(--z-reader) + 10); - display: flex; align-items: flex-start; justify-content: flex-end; - padding: 48px var(--sp-4) 0; +.splitLoading { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: var(--sp-6); } -.dlModal { - background: var(--bg-raised); border: 1px solid var(--border-base); - border-radius: var(--radius-xl); padding: var(--sp-3); - min-width: 210px; display: flex; flex-direction: column; gap: var(--sp-1); - box-shadow: 0 8px 32px rgba(0,0,0,0.6); - animation: scaleIn 0.12s ease both; transform-origin: top right; +/* ── Split content ─────────────────────────────────────────────────────────── */ + +.splitContent { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; } -.dlTitle { - font-family: var(--font-ui); font-size: var(--text-2xs); - color: var(--text-faint); letter-spacing: var(--tracking-wider); - text-transform: uppercase; padding: 2px var(--sp-2) var(--sp-2); - border-bottom: 1px solid var(--border-dim); margin-bottom: var(--sp-1); +.splitContentHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--sp-3) var(--sp-4); + border-bottom: 1px solid var(--border-dim); + flex-shrink: 0; + gap: var(--sp-2); } -.dlOption { - display: flex; flex-direction: column; align-items: flex-start; gap: 2px; - width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); - font-size: var(--text-sm); color: var(--text-secondary); - background: none; border: none; cursor: pointer; text-align: left; - transition: background var(--t-fast), color var(--t-fast); -} -.dlOption:hover:not(:disabled) { background: var(--bg-overlay); color: var(--text-primary); } -.dlOption:disabled { opacity: 0.3; cursor: default; } - -.dlSub { font-size: var(--text-xs); color: var(--text-faint); } - -.dlRow { display: flex; align-items: center; gap: var(--sp-2); } - -.dlStepper { - display: flex; align-items: center; gap: 2px; - background: var(--bg-overlay); border: 1px solid var(--border-strong); - border-radius: var(--radius-sm); overflow: hidden; flex-shrink: 0; +.splitSourceTitle { + display: flex; + align-items: center; + gap: var(--sp-2); + flex: 1; + min-width: 0; } -.dlStepBtn { - display: flex; align-items: center; justify-content: center; - width: 22px; height: 28px; - font-size: var(--text-base); color: var(--text-muted); - background: none; border: none; cursor: pointer; line-height: 1; - transition: color var(--t-fast), background var(--t-fast); +.splitContentTitle { + font-size: var(--text-base); + font-weight: var(--weight-medium); + color: var(--text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + letter-spacing: var(--tracking-tight); } -.dlStepBtn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); } -.dlStepBtn:disabled { opacity: 0.25; cursor: default; } -.dlStepVal { - font-family: var(--font-ui); font-size: var(--text-xs); - color: var(--text-secondary); min-width: 24px; text-align: center; +.splitResultCount { + font-family: var(--font-ui); + font-size: var(--text-2xs); + color: var(--text-faint); letter-spacing: var(--tracking-wide); + flex-shrink: 0; } -/* Viewer focus — suppress outline since we're handling keys ourselves */ -.viewer:focus { outline: none; } \ No newline at end of file + +.splitSourceIcon { + width: 18px; + height: 18px; + border-radius: var(--radius-sm); + object-fit: cover; + flex-shrink: 0; + background: var(--bg-raised); +} + +/* ── Tag active bar ────────────────────────────────────────────────────────── */ + +.tagActiveBar { + display: flex; + align-items: flex-start; + gap: var(--sp-2); + padding: var(--sp-2) var(--sp-4); + border-bottom: 1px solid var(--border-dim); + flex-shrink: 0; + flex-wrap: wrap; +} + +.tagPillRow { + display: flex; + flex-wrap: wrap; + gap: var(--sp-1); + flex: 1; + min-width: 0; +} + +.tagPill { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 7px; + background: var(--accent-muted); + border: 1px solid var(--accent-dim); + border-radius: var(--radius-sm); + font-family: var(--font-ui); + font-size: var(--text-2xs); + letter-spacing: var(--tracking-wide); + color: var(--accent-fg); +} + +.tagPillRemove { + color: var(--accent-fg); + opacity: 0.6; + font-size: 13px; + line-height: 1; + background: none; + border: none; + cursor: pointer; + padding: 0; + transition: opacity var(--t-base); +} +.tagPillRemove:hover { opacity: 1; } + +.tagBarRight { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} + +.tagModeToggle { + display: flex; + border: 1px solid var(--border-dim); + border-radius: var(--radius-md); + overflow: hidden; +} + +.tagModeBtn { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 8px; + font-family: var(--font-ui); + font-size: var(--text-2xs); + letter-spacing: var(--tracking-wide); + color: var(--text-faint); + background: none; + border: none; + border-right: 1px solid var(--border-dim); + cursor: pointer; + transition: color var(--t-base), background var(--t-base); +} +.tagModeBtn:last-child { border-right: none; } +.tagModeBtn:hover { color: var(--text-muted); background: var(--bg-raised); } +.tagModeBtnActive { color: var(--accent-fg); background: var(--accent-muted); } +.tagModeBtnActive:hover { color: var(--accent-fg); background: var(--accent-muted); } + +.tagClearAll { + display: flex; + align-items: center; + font-family: var(--font-ui); + font-size: var(--text-2xs); + letter-spacing: var(--tracking-wide); + color: var(--text-faint); + padding: 4px 8px; + border-radius: var(--radius-md); + border: 1px solid var(--border-dim); + background: none; + cursor: pointer; + transition: color var(--t-base), border-color var(--t-base), background var(--t-base); +} +.tagClearAll:hover { + color: var(--color-error); + border-color: color-mix(in srgb, var(--color-error) 40%, transparent); + background: var(--color-error-bg, color-mix(in srgb, var(--color-error) 8%, transparent)); +} + +.tagCheckMark { + font-size: var(--text-xs); + color: var(--accent-fg); + margin-left: auto; +} + +/* ── Grid results ──────────────────────────────────────────────────────────── */ + +.tagGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); + gap: var(--sp-4); + padding: var(--sp-4); + overflow-y: auto; + flex: 1; + align-content: start; +} + +/* ── Show more / load more ─────────────────────────────────────────────────── */ + +.showMoreCell { + grid-column: 1 / -1; + display: flex; + justify-content: center; + gap: var(--sp-2); + padding: var(--sp-2) 0; +} + +.showMoreBtn { + display: inline-flex; + align-items: center; + gap: var(--sp-1); + padding: 5px 12px; + border-radius: var(--radius-md); + border: 1px solid var(--border-dim); + background: none; + font-family: var(--font-ui); + font-size: var(--text-xs); + letter-spacing: var(--tracking-wide); + color: var(--text-muted); + cursor: pointer; + transition: background var(--t-base), color var(--t-base), border-color var(--t-base); +} +.showMoreBtn:hover:not(:disabled) { + background: var(--bg-raised); + color: var(--text-secondary); + border-color: var(--border-strong); +} +.showMoreBtn:disabled { opacity: 0.4; cursor: default; } + +.loadMoreRow { + display: flex; + justify-content: center; + padding: var(--sp-3) var(--sp-4); + flex-shrink: 0; + border-top: 1px solid var(--border-dim); +} + +/* ── Source tab: lang filter + browse bar ──────────────────────────────────── */ + +.langFilterRow { + display: flex; + flex-wrap: wrap; + gap: var(--sp-1); + padding: var(--sp-2) var(--sp-3); + border-bottom: 1px solid var(--border-dim); + flex-shrink: 0; +} + +.sourceBrowseBar { + display: flex; + align-items: center; + gap: var(--sp-2); + padding: var(--sp-2) var(--sp-4); + border-bottom: 1px solid var(--border-dim); + flex-shrink: 0; +} + +/* ── NSFW badge ────────────────────────────────────────────────────────────── */ + +.nsfwBadge { + font-family: var(--font-ui); + font-size: var(--text-2xs); + letter-spacing: var(--tracking-wide); + color: var(--color-error); + background: var(--color-error-bg, rgba(180, 60, 60, 0.08)); + border: 1px solid rgba(180, 60, 60, 0.25); + border-radius: var(--radius-sm); + padding: 1px 5px; + margin-left: auto; + flex-shrink: 0; +} \ No newline at end of file diff --git a/src/components/pages/Search.tsx b/src/components/pages/Search.tsx index 2416f1e..5d783a0 100644 --- a/src/components/pages/Search.tsx +++ b/src/components/pages/Search.tsx @@ -27,7 +27,7 @@ interface SourceResult { const CONCURRENCY = 4; const RESULTS_PER_SOURCE = 8; const TAG_PAGE_SIZE = 48; -const MAX_TAG_SOURCES = 10; // sources queried when "Search sources" is toggled on +const MAX_TAG_SOURCES = 10; const COMMON_GENRES = [ "Action","Adventure","Comedy","Drama","Fantasy","Romance", @@ -37,7 +37,7 @@ const COMMON_GENRES = [ "Magic","Music","Cooking","Medical","Military","Harem","Ecchi", ]; -// ── Shared helpers ──────────────────────────────────────────────────────────── +// ── Helpers ─────────────────────────────────────────────────────────────────── async function runConcurrent( items: T[], @@ -55,7 +55,6 @@ async function runConcurrent( await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker)); } -/** Keep only manga whose genre array includes every tag (case-insensitive). */ function matchesAllTags(m: Manga, tags: string[]): boolean { const genres = (m.genre ?? []).map((g) => g.toLowerCase()); return tags.every((t) => genres.includes(t.toLowerCase())); @@ -68,8 +67,10 @@ const CoverImg = memo(function CoverImg({ }: { src: string; alt: string; className?: string }) { const [loaded, setLoaded] = useState(false); return ( - {alt} setLoaded(true)} + {alt} setLoaded(true)} style={{ opacity: loaded ? 1 : 0, transition: loaded ? "opacity 0.15s ease" : "none" }} /> ); @@ -91,8 +92,8 @@ function GridSkeleton({ count = 18 }: { count?: number }) { return (
{Array.from({ length: count }).map((_, i) => ( -
-
+
+
))} @@ -125,10 +126,8 @@ export default function Search() { const [allSources, setAllSources] = useState([]); const [loadingSources, setLoadingSources] = useState(false); - const pendingPrefill = useRef(""); - // Consume searchPrefill → route to keyword tab useEffect(() => { if (!searchPrefill) return; pendingPrefill.current = searchPrefill; @@ -136,13 +135,13 @@ export default function Search() { setSearchPrefill(""); }, [searchPrefill, setSearchPrefill]); - // Load sources once, shared across all tabs useEffect(() => { setLoadingSources(true); - cache.get(CACHE_KEYS.SOURCES, () => - gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) - .then((d) => d.sources.nodes.filter((src) => src.id !== "0")), - Infinity, // source list is stable within a session + cache.get( + CACHE_KEYS.SOURCES, + () => gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) + .then((d) => d.sources.nodes.filter((src) => src.id !== "0")), + Infinity, ) .then(setAllSources) .catch(console.error) @@ -150,8 +149,10 @@ export default function Search() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const availableLangs = useMemo(() => - Array.from(new Set(allSources.map((s) => s.lang))).sort(), [allSources]); + const availableLangs = useMemo( + () => Array.from(new Set(allSources.map((src) => src.lang))).sort(), + [allSources], + ); const hasMultipleLangs = availableLangs.length > 1; return ( @@ -159,13 +160,22 @@ export default function Search() {

Search

- - -
@@ -204,19 +214,18 @@ export default function Search() { } // ── Keyword tab ─────────────────────────────────────────────────────────────── -// Unchanged from v1. function KeywordTab({ allSources, loadingSources, availableLangs, hasMultipleLangs, preferredLang, pendingPrefill, onMangaClick, }: { - allSources: Source[]; - loadingSources: boolean; - availableLangs: string[]; + allSources: Source[]; + loadingSources: boolean; + availableLangs: string[]; hasMultipleLangs: boolean; - preferredLang: string; - pendingPrefill: React.MutableRefObject; - onMangaClick: (m: Manga) => void; + preferredLang: string; + pendingPrefill: React.MutableRefObject; + onMangaClick: (m: Manga) => void; }) { const [query, setQuery] = useState(""); const [submitted, setSubmitted] = useState(""); @@ -229,19 +238,22 @@ function KeywordTab({ const inputRef = useRef(null); const allSourcesRef = useRef([]); const selectedLangsRef = useRef>(new Set()); + const includeNsfwRef = useRef(false); - useEffect(() => { allSourcesRef.current = allSources; }, [allSources]); + useEffect(() => { allSourcesRef.current = allSources; }, [allSources]); useEffect(() => { selectedLangsRef.current = selectedLangs; }, [selectedLangs]); + useEffect(() => { includeNsfwRef.current = includeNsfw; }, [includeNsfw]); - // Set default lang selection once sources load useEffect(() => { if (!allSources.length) return; - const available = new Set(allSources.map((s) => s.lang)); - setSelectedLangs(available.has(preferredLang) - ? new Set([preferredLang]) - : new Set(availableLangs.slice(0, 1)) + const available = new Set(allSources.map((src) => src.lang)); + setSelectedLangs( + available.has(preferredLang) + ? new Set([preferredLang]) + : new Set(availableLangs.slice(0, 1)), ); - }, [allSources]); // eslint-disable-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [allSources]); // Consume prefill once sources are ready useEffect(() => { @@ -250,7 +262,7 @@ function KeywordTab({ const q = pendingPrefill.current; pendingPrefill.current = ""; setQuery(q); - doSearch(q); + Promise.resolve().then(() => doSearch(q)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [loadingSources]); @@ -259,11 +271,11 @@ function KeywordTab({ const getVisibleSources = useCallback((): Source[] => { let filtered = allSourcesRef.current; if (selectedLangsRef.current.size > 0) - filtered = filtered.filter((s) => selectedLangsRef.current.has(s.lang)); - if (!includeNsfw) - filtered = filtered.filter((s) => !s.isNsfw); + filtered = filtered.filter((src) => selectedLangsRef.current.has(src.lang)); + if (!includeNsfwRef.current) + filtered = filtered.filter((src) => !src.isNsfw); return filtered; - }, [includeNsfw]); + }, []); const doSearch = useCallback(async (q: string) => { const trimmed = q.trim(); @@ -288,12 +300,12 @@ function KeywordTab({ ); if (ctrl.signal.aborted) return; setResults((prev) => prev.map((r) => - r.source.id === src.id ? { ...r, mangas: d.fetchSourceManga.mangas, loading: false } : r + r.source.id === src.id ? { ...r, mangas: d.fetchSourceManga.mangas, loading: false } : r, )); } catch (e: any) { if (ctrl.signal.aborted || e?.name === "AbortError") return; setResults((prev) => prev.map((r) => - r.source.id === src.id ? { ...r, loading: false, error: e.message } : r + r.source.id === src.id ? { ...r, loading: false, error: e.message ?? "Error" } : r, )); } }, ctrl.signal); @@ -310,7 +322,7 @@ function KeywordTab({ const visibleCount = getVisibleSources().length; const hasResults = results.some((r) => r.mangas.length > 0); - const allDone = results.every((r) => !r.loading); + const allDone = results.length > 0 && results.every((r) => !r.loading); return ( <> @@ -325,6 +337,13 @@ function KeywordTab({ onChange={(e) => setQuery(e.target.value)} onKeyDown={(e) => e.key === "Enter" && doSearch(query)} /> + {query && ( + + )} {hasMultipleLangs && (
- {!submitted && ( + {!submitted ? (

Search across sources

@@ -392,9 +412,7 @@ function KeywordTab({ )}
- )} - - {submitted && ( + ) : (
{results.length === 0 && (
@@ -410,8 +428,9 @@ function KeywordTab({ onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} /> {source.displayName} {hasMultipleLangs && {source.lang.toUpperCase()}} - {loading && } - {!loading && mangas.length > 0 && {mangas.length} results} + {loading + ? + : mangas.length > 0 && {mangas.length} results}
{error ? (

{error}

@@ -429,6 +448,7 @@ function KeywordTab({ {allDone && !hasResults && (

No results for "{submitted}"

+

Try a different spelling or fewer words

)}
@@ -438,18 +458,6 @@ function KeywordTab({ } // ── Tag tab ─────────────────────────────────────────────────────────────────── -// -// Two data sources, selectable independently: -// -// 1. Local DB (always on) — instant MangaFilterInput query with AND/OR support. -// "Show more" uses GraphQL offset pagination. -// -// 2. Source search (opt-in via "Search sources" toggle) — fires FETCH_SOURCE_MANGA -// across the top sources, using getPageSet() + cache.get(sourceMangaPage) so -// results survive navigation and "Show more" fetches the next cached page before -// hitting the network. -// For multi-tag AND: sends the first tag as the source query string (sources only -// support one term) and client-filters the results by the remaining tags. const MANGAS_BY_GENRE = ` query MangasByGenre($filter: MangaFilterInput, $first: Int, $offset: Int) { @@ -471,10 +479,7 @@ function buildGenreFilter(tags: string[], mode: TagMode): Record("AND"); const [tagFilter, setTagFilter] = useState(""); - // ── Local DB state ──────────────────────────────────────────────────────── const [localResults, setLocalResults] = useState([]); const [totalCount, setTotalCount] = useState(0); const [loadingLocal, setLoadingLocal] = useState(false); @@ -494,12 +498,10 @@ function TagTab({ const [localHasNext, setLocalHasNext] = useState(false); const abortLocalRef = useRef(null); - // ── Source search state ─────────────────────────────────────────────────── - const [searchSources, setSearchSources] = useState(false); - const [sourceResults, setSourceResults] = useState([]); + const [searchSources, setSearchSources] = useState(false); + const [sourceResults, setSourceResults] = useState([]); const [loadingSourceSearch, setLoadingSourceSearch] = useState(false); - const [loadingMoreSource, setLoadingMoreSource] = useState(false); - // Per-source next-page tracker; -1 = exhausted + const [loadingMoreSource, setLoadingMoreSource] = useState(false); const srcNextPageRef = useRef>(new Map()); const abortSourceRef = useRef(null); @@ -508,7 +510,6 @@ function TagTab({ abortSourceRef.current?.abort(); }, []); - // ── Local DB query ──────────────────────────────────────────────────────── useEffect(() => { if (activeTags.length === 0) { setLocalResults([]); setTotalCount(0); setLocalHasNext(false); setLocalOffset(0); @@ -535,12 +536,9 @@ function TagTab({ }).finally(() => { if (!ctrl.signal.aborted) setLoadingLocal(false); }); - }, [activeTags, tagMode]); // eslint-disable-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeTags, tagMode]); - // ── Source search ───────────────────────────────────────────────────────── - // Fires when toggled on (or when tags change while already on). - // Uses getPageSet() + cache.get(sourceMangaPage) so the first page of each - // source is re-used from cache if the user navigates away and back. useEffect(() => { if (!searchSources || activeTags.length === 0 || loadingSources) return; @@ -553,7 +551,7 @@ function TagTab({ setLoadingSourceSearch(true); const sources = dedupeSources(allSources, preferredLang).slice(0, MAX_TAG_SOURCES); - const primaryTag = activeTags[0]; // sources only support a single query string + const primaryTag = activeTags[0]; for (const src of sources) srcNextPageRef.current.set(src.id, -1); @@ -582,23 +580,22 @@ function TagTab({ ps.add(1); srcNextPageRef.current.set(src.id, result.hasNextPage ? 2 : -1); - // Multi-tag AND: client-filter for tags beyond the first const matching = activeTags.length > 1 ? result.mangas.filter((m) => matchesAllTags(m, activeTags)) : result.mangas; if (matching.length > 0) { setSourceResults((prev) => dedupeMangaById([...prev, ...matching])); - setLoadingSourceSearch(false); // reveal as results arrive + setLoadingSourceSearch(false); } }, ctrl.signal).finally(() => { if (!ctrl.signal.aborted) setLoadingSourceSearch(false); }); return () => { ctrl.abort(); }; - }, [searchSources, activeTags, allSources, loadingSources]); // eslint-disable-line react-hooks/exhaustive-deps + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchSources, activeTags, allSources, loadingSources]); - // ── Load more: local ────────────────────────────────────────────────────── async function loadMoreLocal() { if (loadingMoreLocal || !localHasNext) return; setLoadingMoreLocal(true); @@ -622,7 +619,6 @@ function TagTab({ } } - // ── Load more: sources ──────────────────────────────────────────────────── const sourceHasMore = searchSources && [...srcNextPageRef.current.values()].some((p) => p > 0); @@ -677,13 +673,11 @@ function TagTab({ } } - // ── Tag toggle ──────────────────────────────────────────────────────────── function toggleTag(tag: string) { - // Clear source sessions when tags change — new query = new page buckets srcNextPageRef.current = new Map(); setSourceResults([]); setActiveTags((prev) => - prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag] + prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag], ); } @@ -693,18 +687,14 @@ function TagTab({ }, [tagFilter]); const hasActiveTags = activeTags.length > 0; - - // Merge local + source results (local first, source de-duped against local IDs) const localIds = useMemo(() => new Set(localResults.map((m) => m.id)), [localResults]); const mergedResults = searchSources ? [...localResults, ...sourceResults.filter((m) => !localIds.has(m.id))] : localResults; - - const totalVisible = localResults.length + (searchSources ? sourceResults.length : 0); + const totalVisible = localResults.length + (searchSources ? sourceResults.length : 0); return (
- {/* ── Sidebar ────────────────────────────────────────────────────── */}
@@ -714,6 +704,9 @@ function TagTab({ value={tagFilter} onChange={(e) => setTagFilter(e.target.value)} /> + {tagFilter && ( + + )}
{filteredGenres.map((tag) => ( @@ -730,7 +723,6 @@ function TagTab({
- {/* ── Content ────────────────────────────────────────────────────── */}
{!hasActiveTags ? (
@@ -740,7 +732,6 @@ function TagTab({
) : ( <> - {/* Active tag pills + controls */}
{activeTags.map((tag) => ( @@ -756,16 +747,15 @@ function TagTab({
)} - {/* "Search sources" toggle — fetches from external sources */}
- {/* Result header */}
{activeTags.length === 1 ? activeTags[0] : `${activeTags.length} tags (${tagMode})`} @@ -792,13 +781,11 @@ function TagTab({ {(loadingLocal || loadingSourceSearch) ? : - {totalVisible} - {localHasNext || sourceHasMore ? "+" : ""} of {totalCount + sourceResults.length} results + {totalVisible}{localHasNext || sourceHasMore ? "+" : ""} of {totalCount + sourceResults.length} results }
- {/* Results grid */} {loadingLocal ? ( ) : mergedResults.length > 0 ? ( @@ -807,15 +794,13 @@ function TagTab({ onMangaClick(m)} /> ))} - {/* Inline skeletons while source results are still streaming in */} {loadingSourceSearch && Array.from({ length: 8 }).map((_, i) => ( -
-
+
+
))} - {/* Show more buttons — one per data source */} {(localHasNext || sourceHasMore) && (
{localHasNext && ( @@ -853,7 +838,6 @@ function TagTab({ } // ── Source tab ──────────────────────────────────────────────────────────────── -// Unchanged from v1. function SourceTab({ allSources, loadingSources, availableLangs, hasMultipleLangs, onMangaClick, @@ -870,29 +854,33 @@ function SourceTab({ const [loadingBrowse, setLoadingBrowse] = useState(false); const [browseQuery, setBrowseQuery] = useState(""); const [submitted, setSubmitted] = useState(""); + const [hasNextPage, setHasNextPage] = useState(false); + const [currentPage, setCurrentPage] = useState(1); const abortRef = useRef(null); useEffect(() => () => { abortRef.current?.abort(); }, []); - const visibleSources = useMemo(() => - selectedLang === "all" ? allSources : allSources.filter((s) => s.lang === selectedLang), - [allSources, selectedLang] + const visibleSources = useMemo( + () => selectedLang === "all" ? allSources : allSources.filter((src) => src.lang === selectedLang), + [allSources, selectedLang], ); - async function fetchBrowse(src: Source, type: "POPULAR" | "SEARCH", q?: string) { + async function fetchBrowse(src: Source, type: "POPULAR" | "SEARCH", q?: string, page = 1) { abortRef.current?.abort(); const ctrl = new AbortController(); abortRef.current = ctrl; - setLoadingBrowse(true); - setBrowseResults([]); + if (page === 1) { setLoadingBrowse(true); setBrowseResults([]); } try { - const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>( + const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>( FETCH_SOURCE_MANGA, - { source: src.id, type, page: 1, query: q ?? null }, + { source: src.id, type, page, query: q ?? null }, ctrl.signal, ); - if (!ctrl.signal.aborted) setBrowseResults(d.fetchSourceManga.mangas); + if (ctrl.signal.aborted) return; + setBrowseResults((prev) => page === 1 ? d.fetchSourceManga.mangas : [...prev, ...d.fetchSourceManga.mangas]); + setHasNextPage(d.fetchSourceManga.hasNextPage); + setCurrentPage(page); } catch (e: any) { if (e?.name !== "AbortError") console.error(e); } finally { @@ -970,38 +958,58 @@ function SourceTab({ { (e.target as HTMLImageElement).style.display = "none"; }} /> {activeSource.displayName} - {loadingBrowse && } - {!loadingBrowse && browseResults.length > 0 && {browseResults.length} results} -
-
-
- - setBrowseQuery(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleSearch()} - /> - {submitted && ( - - )} -
- + {loadingBrowse + ? + : browseResults.length > 0 && {browseResults.length} results + }
- {loadingBrowse ? : browseResults.length > 0 ? ( -
- {browseResults.map((m) => onMangaClick(m)} />)} +
+
+ + setBrowseQuery(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSearch()} + /> + {submitted && ( + + )}
- ) : ( + +
+ + {loadingBrowse && browseResults.length === 0 ? ( + + ) : browseResults.length > 0 ? ( + <> +
+ {browseResults.map((m) => onMangaClick(m)} />)} +
+ {hasNextPage && ( +
+ +
+ )} + + ) : !loadingBrowse ? (

{submitted ? `No results for "${submitted}"` : "No results"}

- )} + ) : null} )}