Fix: Auth Thumbnails on Windows (WIP)

This commit is contained in:
Youwes09
2026-04-04 19:28:00 -05:00
parent 5cd96abc0c
commit 6446a19b2d
20 changed files with 801 additions and 476 deletions
+12
View File
@@ -264,6 +264,15 @@ EOF
''; '';
}; };
tunnelScript = pkgs.writeShellApplication {
name = "moku-tunnel";
runtimeInputs = with pkgs; [ cloudflared ];
text = ''
PORT="''${1:-4567}"
cloudflared tunnel --url "http://localhost:$PORT"
'';
};
in in
{ {
apps = { apps = {
@@ -272,6 +281,7 @@ EOF
bump = { type = "app"; program = "${bumpScript}/bin/moku-bump"; }; bump = { type = "app"; program = "${bumpScript}/bin/moku-bump"; };
flatpak = { type = "app"; program = "${flatpakScript}/bin/moku-flatpak"; }; flatpak = { type = "app"; program = "${flatpakScript}/bin/moku-flatpak"; };
pkgbuild-bump = { type = "app"; program = "${pkgbuildBumpScript}/bin/moku-pkgbuild-bump"; }; pkgbuild-bump = { type = "app"; program = "${pkgbuildBumpScript}/bin/moku-pkgbuild-bump"; };
tunnel = { type = "app"; program = "${tunnelScript}/bin/moku-tunnel"; };
}; };
packages = { packages = {
@@ -288,6 +298,7 @@ EOF
nodejs_22 nodejs_22
pnpm pnpm
suwayomi-server suwayomi-server
cloudflared
xdg-utils xdg-utils
]; ];
shellHook = '' shellHook = ''
@@ -301,6 +312,7 @@ EOF
echo " nix run .#bump -- <ver> bump versions only" echo " nix run .#bump -- <ver> bump versions only"
echo " nix run .#flatpak -- <ver> full flatpak build" echo " nix run .#flatpak -- <ver> full flatpak build"
echo " nix run .#pkgbuild-bump -- <ver> patch PKGBUILD (after tag push)" echo " nix run .#pkgbuild-bump -- <ver> patch PKGBUILD (after tag push)"
echo " nix run .#tunnel -- [port] cloudflare tunnel (default 4567)"
''; '';
}; };
+3 -3
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { ClockCounterClockwise, Trash, MagnifyingGlass, Play, Books, Fire, BookOpen, Clock, TrendUp } from "phosphor-svelte"; import { ClockCounterClockwise, Trash, MagnifyingGlass, Play, Books, Fire, BookOpen, Clock, TrendUp } from "phosphor-svelte";
import { thumbUrl } from "../../lib/client"; import Thumbnail from "../shared/Thumbnail.svelte";
import { store, clearHistory, openReader, setActiveManga } from "../../store/state.svelte"; import { store, clearHistory, openReader, setActiveManga } from "../../store/state.svelte";
import type { HistoryEntry } from "../../store/state.svelte"; import type { HistoryEntry } from "../../store/state.svelte";
@@ -192,7 +192,7 @@
{#each items as session (session.latestChapterId)} {#each items as session (session.latestChapterId)}
<button class="session-row" onclick={() => resume(session)}> <button class="session-row" onclick={() => resume(session)}>
<div class="thumb-wrap"> <div class="thumb-wrap">
<img src={thumbUrl(session.thumbnailUrl)} alt={session.mangaTitle} class="thumb" /> <Thumbnail src={session.thumbnailUrl} alt={session.mangaTitle} class="thumb" />
{#if session.chapterCount > 1} {#if session.chapterCount > 1}
<span class="session-count">{session.chapterCount}</span> <span class="session-count">{session.chapterCount}</span>
{/if} {/if}
@@ -290,7 +290,7 @@
.session-row:hover .play-pill { opacity: 1; transform: translateX(0); } .session-row:hover .play-pill { opacity: 1; transform: translateX(0); }
.thumb-wrap { position: relative; flex-shrink: 0; } .thumb-wrap { position: relative; flex-shrink: 0; }
.thumb { width: 38px; height: 54px; border-radius: var(--radius-sm); object-fit: cover; display: block; background: var(--bg-raised); border: 1px solid var(--border-dim); } :global(.thumb) { width: 38px; height: 54px; border-radius: var(--radius-sm); object-fit: cover; display: block; background: var(--bg-raised); border: 1px solid var(--border-dim); }
.session-count { .session-count {
position: absolute; bottom: -4px; right: -6px; position: absolute; bottom: -4px; right: -6px;
background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg);
+2 -6
View File
@@ -27,11 +27,7 @@
{#if store.toasts.length} {#if store.toasts.length}
<div class="toaster" aria-live="polite"> <div class="toaster" aria-live="polite">
{#each store.toasts as t (t.id)} {#each store.toasts as t (t.id)}
<button <div role="alert" class="toast toast-{t.kind}" onclick={() => dismissToast(t.id)}>
class="toast toast-{t.kind}"
role="alert"
onclick={() => dismissToast(t.id)}
>
<div class="accent-bar"></div> <div class="accent-bar"></div>
<span class="icon"> <span class="icon">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" <svg width="13" height="13" viewBox="0 0 24 24" fill="none"
@@ -43,7 +39,7 @@
<p class="title">{t.title}</p> <p class="title">{t.title}</p>
{#if t.body}<p class="sub">{t.body}</p>{/if} {#if t.body}<p class="sub">{t.body}</p>{/if}
</div> </div>
</button> </div>
{/each} {/each}
</div> </div>
{/if} {/if}
+11 -36
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onDestroy } from "svelte"; import { onDestroy } from "svelte";
import { BookmarkSimple, FolderSimplePlus, Folder, Sparkle, ArrowsClockwise } from "phosphor-svelte"; import { BookmarkSimple, FolderSimplePlus, Folder, Sparkle, ArrowsClockwise } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql } from "../../lib/client";
import { GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries"; import { GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
import { cache, CACHE_KEYS } from "../../lib/cache"; import { cache, CACHE_KEYS } from "../../lib/cache";
import { dedupeSources, dedupeMangaByTitle, dedupeMangaById, shouldHideNsfw, shouldHideSource } from "../../lib/util"; import { dedupeSources, dedupeMangaByTitle, dedupeMangaById, shouldHideNsfw, shouldHideSource } from "../../lib/util";
@@ -10,13 +10,13 @@
import ContextMenu from "../shared/ContextMenu.svelte"; import ContextMenu from "../shared/ContextMenu.svelte";
import type { MenuEntry } from "../shared/ContextMenu.svelte"; import type { MenuEntry } from "../shared/ContextMenu.svelte";
import SourceBrowse from "../shared/SourceBrowse.svelte"; import SourceBrowse from "../shared/SourceBrowse.svelte";
import Thumbnail from "../shared/Thumbnail.svelte";
// ── Constants ─────────────────────────────────────────────────────────────
const GENRE_TABS = ["All", "Action", "Romance", "Fantasy", "Comedy", "Drama", "Horror", "Sci-Fi", "Adventure", "Thriller"]; const GENRE_TABS = ["All", "Action", "Romance", "Fantasy", "Comedy", "Drama", "Horror", "Sci-Fi", "Adventure", "Thriller"];
const GRID_LIMIT = 200; const GRID_LIMIT = 200;
const CONCURRENCY = 6; const CONCURRENCY = 6;
const PAGES_INIT = 3; // pages per source on All tab const PAGES_INIT = 3;
const PAGES_GENRE = 2; // pages per source on genre tabs const PAGES_GENRE = 2;
const EXPLORE_ALL_MANGA = ` const EXPLORE_ALL_MANGA = `
query ExploreAllManga { query ExploreAllManga {
@@ -37,7 +37,6 @@
return `${srcId}|${type}|${genre}:p${page}`; return `${srcId}|${type}|${genre}:p${page}`;
} }
// ── Local component state ─────────────────────────────────────────────────
let allSources: Source[] = $state([]); let allSources: Source[] = $state([]);
let loadingLib = $state(true); let loadingLib = $state(true);
let loadError = $state(false); let loadError = $state(false);
@@ -54,7 +53,6 @@
const isLoading = $derived(genreLoading || refreshing || (currentGenre === "All" && loadingLib)); const isLoading = $derived(genreLoading || refreshing || (currentGenre === "All" && loadingLib));
const visibleGrid = $derived(genreResults.get(currentGenre) ?? []); const visibleGrid = $derived(genreResults.get(currentGenre) ?? []);
// ── Helpers ───────────────────────────────────────────────────────────────
function dedup(items: Manga[]): Manga[] { function dedup(items: Manga[]): Manga[] {
return dedupeMangaByTitle(dedupeMangaById(items), store.settings.mangaLinks); return dedupeMangaByTitle(dedupeMangaById(items), store.settings.mangaLinks);
} }
@@ -87,7 +85,6 @@
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker)); await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
} }
// Push results into the reactive grid immediately — no batch delay.
function pushToGrid(genre: string, incoming: Manga[]) { function pushToGrid(genre: string, incoming: Manga[]) {
const filtered = filterOut(incoming); const filtered = filterOut(incoming);
if (!filtered.length) return; if (!filtered.length) return;
@@ -96,7 +93,6 @@
genreResults = new Map(genreResults); genreResults = new Map(genreResults);
} }
// ── Source fan-out ────────────────────────────────────────────────────────
async function fanOut(genre: string, ctrl: AbortController) { async function fanOut(genre: string, ctrl: AbortController) {
const srcs = rotatedSources(); const srcs = rotatedSources();
if (!srcs.length) return; if (!srcs.length) return;
@@ -115,7 +111,6 @@
let hasNextPage = false; let hasNextPage = false;
if (store.discoverCache.has(key)) { if (store.discoverCache.has(key)) {
// Cache hit — no network call needed
mangas = store.discoverCache.get(key)!; mangas = store.discoverCache.get(key)!;
} else { } else {
const result = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>( const result = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
@@ -141,13 +136,11 @@
pushToGrid(genre, matching.length ? matching : mangas); pushToGrid(genre, matching.length ? matching : mangas);
} }
// Stop paging early if source is exhausted
if (!hasNextPage) return; if (!hasNextPage) return;
} }
}, ctrl.signal); }, ctrl.signal);
} }
// ── Tab switch ────────────────────────────────────────────────────────────
async function switchGenre(genre: string) { async function switchGenre(genre: string) {
if (currentGenre === genre) return; if (currentGenre === genre) return;
@@ -158,7 +151,6 @@
activeCtrl = ctrl; activeCtrl = ctrl;
if (genre === "All") { if (genre === "All") {
// Already have results from this session — show instantly, re-fan in background
if ((genreResults.get("All") ?? []).length > 0) { if ((genreResults.get("All") ?? []).length > 0) {
genreLoading = false; genreLoading = false;
fanOut("All", ctrl).catch(() => {}); fanOut("All", ctrl).catch(() => {});
@@ -172,7 +164,6 @@
return; return;
} }
// Genre tab: serve cached local results instantly, always fan out too
const localKey = `local|${genre}`; const localKey = `local|${genre}`;
if (store.discoverCache.has(localKey)) { if (store.discoverCache.has(localKey)) {
genreResults.set(genre, store.discoverCache.get(localKey)!); genreResults.set(genre, store.discoverCache.get(localKey)!);
@@ -188,9 +179,7 @@
); );
if (ctrl.signal.aborted) return; if (ctrl.signal.aborted) return;
const local = dedup( const local = dedup(d.mangas.nodes.filter(m => !shouldHideNsfw(m, store.settings)));
d.mangas.nodes.filter(m => !shouldHideNsfw(m, store.settings))
);
store.discoverCache.set(localKey, local); store.discoverCache.set(localKey, local);
genreResults.set(genre, local.slice(0, GRID_LIMIT)); genreResults.set(genre, local.slice(0, GRID_LIMIT));
genreResults = new Map(genreResults); genreResults = new Map(genreResults);
@@ -203,10 +192,9 @@
} }
} }
// ── Refresh ───────────────────────────────────────────────────────────────
async function refresh() { async function refresh() {
activeCtrl?.abort(); activeCtrl?.abort();
clearDiscoverCache(); // wipes store.discoverCache + bumps discoverSrcOffset clearDiscoverCache();
genreResults = new Map(); genreResults = new Map();
refreshing = true; refreshing = true;
genreLoading = true; genreLoading = true;
@@ -217,17 +205,14 @@
refreshing = false; refreshing = false;
} }
// ── Initial load ──────────────────────────────────────────────────────────
function loadAll() { function loadAll() {
loadingLib = true; loadingLib = true;
loadError = false; loadError = false;
// Already have a session grid — show it immediately
if ((genreResults.get("All") ?? []).length > 0) { if ((genreResults.get("All") ?? []).length > 0) {
loadingLib = false; loadingLib = false;
} }
// Refresh library ID set so newly-added manga get filtered out
cache.get(CACHE_KEYS.DISCOVER, () => cache.get(CACHE_KEYS.DISCOVER, () =>
gql<{ mangas: { nodes: Manga[] } }>(EXPLORE_ALL_MANGA).then(d => d.mangas.nodes) gql<{ mangas: { nodes: Manga[] } }>(EXPLORE_ALL_MANGA).then(d => d.mangas.nodes)
).then(m => { ).then(m => {
@@ -237,7 +222,6 @@
}).catch(e => { console.error(e); loadError = true; }) }).catch(e => { console.error(e); loadError = true; })
.finally(() => { loadingLib = false; }); .finally(() => { loadingLib = false; });
// Load sources then kick off All tab fan-out (only if grid is empty)
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then(d => { .then(d => {
allSources = d.sources.nodes; allSources = d.sources.nodes;
@@ -258,7 +242,6 @@
loadAll(); loadAll();
// ── Context menu ──────────────────────────────────────────────────────────
function openCtx(e: MouseEvent, m: Manga) { function openCtx(e: MouseEvent, m: Manga) {
e.preventDefault(); e.stopPropagation(); e.preventDefault(); e.stopPropagation();
ctx = { x: e.clientX, y: e.clientY, manga: m }; ctx = { x: e.clientX, y: e.clientY, manga: m };
@@ -316,11 +299,7 @@
<span class="heading">Discover</span> <span class="heading">Discover</span>
<div class="tab-strip"> <div class="tab-strip">
{#each GENRE_TABS as tab (tab)} {#each GENRE_TABS as tab (tab)}
<button <button class="genre-tab" class:active={currentGenre === tab} onclick={() => switchGenre(tab)}>
class="genre-tab"
class:active={currentGenre === tab}
onclick={() => switchGenre(tab)}
>
{#if tab === "All"}<Sparkle size={10} weight="fill" />{/if} {#if tab === "All"}<Sparkle size={10} weight="fill" />{/if}
{tab} {tab}
</button> </button>
@@ -351,13 +330,9 @@
{:else} {:else}
<div class="manga-grid"> <div class="manga-grid">
{#each visibleGrid as m (m.id)} {#each visibleGrid as m (m.id)}
<button <button class="manga-card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => openCtx(e, m)}>
class="manga-card"
onclick={() => setPreviewManga(m)}
oncontextmenu={(e) => openCtx(e, m)}
>
<div class="cover-wrap"> <div class="cover-wrap">
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" /> <Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
<div class="cover-gradient"></div> <div class="cover-gradient"></div>
{#if m.inLibrary}<span class="lib-badge">Saved</span>{/if} {#if m.inLibrary}<span class="lib-badge">Saved</span>{/if}
<div class="card-footer"> <div class="card-footer">
@@ -392,11 +367,11 @@
.body { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-5) var(--sp-6); will-change: scroll-position; -webkit-overflow-scrolling: touch; contain: layout style; } .body { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-5) var(--sp-6); will-change: scroll-position; -webkit-overflow-scrolling: touch; contain: layout style; }
.manga-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(90px, 11vw, 130px), 1fr)); gap: var(--sp-2); align-content: start; contain: layout style; } .manga-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(90px, 11vw, 130px), 1fr)); gap: var(--sp-2); align-content: start; contain: layout style; }
.manga-card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; } .manga-card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
.manga-card:hover .cover { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); } .manga-card:hover :global(.cover) { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); }
.manga-card:hover .card-title { color: #fff; } .manga-card:hover .card-title { color: #fff; }
.manga-card:hover { will-change: transform; } .manga-card:hover { will-change: transform; }
.cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 2px 8px rgba(0,0,0,0.25); } .cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 2px 8px rgba(0,0,0,0.25); }
.cover { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.15s ease, transform 0.15s ease; } :global(.cover) { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.15s ease, transform 0.15s ease; }
.cover-gradient { position: absolute; inset: 0; background: linear-gradient(to top, rgba(0,0,0,0.82) 0%, rgba(0,0,0,0.15) 50%, transparent 72%); pointer-events: none; } .cover-gradient { position: absolute; inset: 0; background: linear-gradient(to top, rgba(0,0,0,0.82) 0%, rgba(0,0,0,0.15) 50%, transparent 72%); pointer-events: none; }
.lib-badge { position: absolute; top: var(--sp-1); right: var(--sp-1); font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 1px 5px; border-radius: var(--radius-sm); } .lib-badge { position: absolute; top: var(--sp-1); right: var(--sp-1); font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 1px 5px; border-radius: var(--radius-sm); }
.card-footer { position: absolute; bottom: 0; left: 0; right: 0; padding: var(--sp-2); pointer-events: none; } .card-footer { position: absolute; bottom: 0; left: 0; right: 0; padding: var(--sp-2); pointer-events: none; }
+4 -3
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Play, Pause, Trash, CircleNotch, X } from "phosphor-svelte"; import { Play, Pause, Trash, CircleNotch, X } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql } from "../../lib/client";
import Thumbnail from "../shared/Thumbnail.svelte";
import { GET_DOWNLOAD_STATUS, START_DOWNLOADER, STOP_DOWNLOADER, CLEAR_DOWNLOADER, DEQUEUE_DOWNLOAD } from "../../lib/queries"; import { GET_DOWNLOAD_STATUS, START_DOWNLOADER, STOP_DOWNLOADER, CLEAR_DOWNLOADER, DEQUEUE_DOWNLOAD } from "../../lib/queries";
import { store, setActiveDownloads } from "../../store/state.svelte"; import { store, setActiveDownloads } from "../../store/state.svelte";
import type { DownloadStatus } from "../../lib/types"; import type { DownloadStatus } from "../../lib/types";
@@ -114,7 +115,7 @@
<div class="row" class:row-active={isActive} class:row-removing={isRemoving}> <div class="row" class:row-active={isActive} class:row-removing={isRemoving}>
{#if manga?.thumbnailUrl} {#if manga?.thumbnailUrl}
<div class="thumb"> <div class="thumb">
<img src={thumbUrl(manga.thumbnailUrl)} alt={manga?.title} class="thumb-img" loading="lazy" decoding="async" /> <Thumbnail src={manga.thumbnailUrl} alt={manga?.title} class="thumb-img" />
</div> </div>
{/if} {/if}
<div class="info"> <div class="info">
@@ -165,7 +166,7 @@
.row.row-active { border-color: var(--accent-dim); } .row.row-active { border-color: var(--accent-dim); }
.row.row-removing { opacity: 0.4; pointer-events: none; } .row.row-removing { opacity: 0.4; pointer-events: none; }
.thumb { width: 36px; height: 54px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-overlay); flex-shrink: 0; border: 1px solid var(--border-dim); } .thumb { width: 36px; height: 54px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-overlay); flex-shrink: 0; border: 1px solid var(--border-dim); }
.thumb-img { width: 100%; height: 100%; object-fit: cover; } :global(.thumb-img) { width: 100%; height: 100%; object-fit: cover; }
.info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; } .info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; }
.manga-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .manga-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chapter-name { font-size: var(--text-xs); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .chapter-name { font-size: var(--text-xs); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+4 -3
View File
@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte"; import { untrack } from "svelte";
import { MagnifyingGlass, ArrowsClockwise, Plus, CircleNotch, CaretRight, CaretDown, X, Check, GitBranch } from "phosphor-svelte"; import { MagnifyingGlass, ArrowsClockwise, Plus, CircleNotch, CaretRight, CaretDown, X, Check, GitBranch } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql } from "../../lib/client";
import Thumbnail from "../shared/Thumbnail.svelte";
import { GET_EXTENSIONS, FETCH_EXTENSIONS, UPDATE_EXTENSION, INSTALL_EXTERNAL_EXTENSION, GET_SETTINGS, SET_EXTENSION_REPOS } from "../../lib/queries"; import { GET_EXTENSIONS, FETCH_EXTENSIONS, UPDATE_EXTENSION, INSTALL_EXTERNAL_EXTENSION, GET_SETTINGS, SET_EXTENSION_REPOS } from "../../lib/queries";
import { store } from "../../store/state.svelte"; import { store } from "../../store/state.svelte";
import type { Extension } from "../../lib/types"; import type { Extension } from "../../lib/types";
@@ -225,7 +226,7 @@
{@const hasVariants = variants.length > 0} {@const hasVariants = variants.length > 0}
<div class="group"> <div class="group">
<div class="row"> <div class="row">
<img src={thumbUrl(primary.iconUrl)} alt={primary.name} class="icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} /> <Thumbnail src={primary.iconUrl} alt={primary.name} class="icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
<div class="info"> <div class="info">
<span class="name">{base}</span> <span class="name">{base}</span>
<span class="meta"><span class="lang-tag">{primary.lang.toUpperCase()}</span> v{primary.versionName}</span> <span class="meta"><span class="lang-tag">{primary.lang.toUpperCase()}</span> v{primary.versionName}</span>
@@ -323,7 +324,7 @@
.group { display: flex; flex-direction: column; } .group { display: flex; flex-direction: column; }
.row { display: flex; align-items: center; gap: var(--sp-3); padding: 8px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; transition: background var(--t-fast), border-color var(--t-fast); } .row { display: flex; align-items: center; gap: var(--sp-3); padding: 8px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; transition: background var(--t-fast), border-color var(--t-fast); }
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); } .row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.icon { width: 32px; height: 32px; border-radius: var(--radius-md); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); } :global(.icon) { width: 32px; height: 32px; border-radius: var(--radius-md); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
.info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; } .info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.name { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .name { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.meta { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .meta { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
+5 -4
View File
@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "phosphor-svelte"; import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "phosphor-svelte";
import { untrack } from "svelte"; import { untrack } from "svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql } from "../../lib/client";
import Thumbnail from "../shared/Thumbnail.svelte";
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries"; import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache"; import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
import { dedupeSources, dedupeMangaById, shouldHideNsfw } from "../../lib/util"; import { dedupeSources, dedupeMangaById, shouldHideNsfw } from "../../lib/util";
@@ -217,7 +218,7 @@
{#each visibleItems as m (m.id)} {#each visibleItems as m (m.id)}
<button class="card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => { e.stopPropagation(); openCtx(e, m); }}> <button class="card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => { e.stopPropagation(); openCtx(e, m); }}>
<div class="cover-wrap"> <div class="cover-wrap">
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" /> <Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
{#if m.inLibrary}<span class="in-library-badge">Saved</span>{/if} {#if m.inLibrary}<span class="in-library-badge">Saved</span>{/if}
</div> </div>
<p class="card-title">{m.title}</p> <p class="card-title">{m.title}</p>
@@ -247,10 +248,10 @@
.result-count, .loading-hint { margin-left: auto; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .result-count, .loading-hint { margin-left: auto; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(100px,13vw,140px),1fr)); gap: var(--sp-4); padding: var(--sp-5) var(--sp-6) var(--sp-6); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; -webkit-overflow-scrolling: touch; contain: layout style; } .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(100px,13vw,140px),1fr)); gap: var(--sp-4); padding: var(--sp-5) var(--sp-6) var(--sp-6); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; -webkit-overflow-scrolling: touch; contain: layout style; }
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; } .card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
.card:hover .cover { filter: brightness(1.06); } .card:hover :global(.cover) { filter: brightness(1.06); }
.card:hover .card-title { color: var(--text-primary); } .card:hover .card-title { color: var(--text-primary); }
.cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); } .cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); 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); will-change: filter; } :global(.cover) { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); will-change: filter; }
.in-library-badge { position: absolute; bottom: var(--sp-1); left: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 2px 5px; border-radius: var(--radius-sm); } .in-library-badge { position: absolute; bottom: var(--sp-1); left: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 2px 5px; border-radius: var(--radius-sm); }
.card-title { margin-top: var(--sp-2); 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); } .card-title { margin-top: var(--sp-2); 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); }
.card-skeleton { padding: 0; } .card-skeleton { padding: 0; }
+31 -8
View File
@@ -2,6 +2,8 @@
import { onMount, untrack } from "svelte"; import { onMount, untrack } from "svelte";
import { Play, ArrowRight, ArrowLeft, BookOpen, Clock, Fire, TrendUp, CalendarBlank, CheckCircle, PushPin, X as XIcon, MagnifyingGlass, ListBullets } from "phosphor-svelte"; import { Play, ArrowRight, ArrowLeft, BookOpen, Clock, Fire, TrendUp, CalendarBlank, CheckCircle, PushPin, X as XIcon, MagnifyingGlass, ListBullets } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql, thumbUrl } from "../../lib/client";
import { fetchAuthenticated } from "../../lib/auth";
import Thumbnail from "../shared/Thumbnail.svelte";
import { GET_LIBRARY, GET_CHAPTERS, GET_MANGA, GET_CATEGORIES } from "../../lib/queries"; import { GET_LIBRARY, GET_CHAPTERS, GET_MANGA, GET_CATEGORIES } from "../../lib/queries";
import { cache, CACHE_KEYS } from "../../lib/cache"; import { cache, CACHE_KEYS } from "../../lib/cache";
import { store, openReader, setHeroSlot, setActiveManga, setPreviewManga, setNavPage, setGenreFilter, setLibraryFilter } from "../../store/state.svelte"; import { store, openReader, setHeroSlot, setActiveManga, setPreviewManga, setNavPage, setGenreFilter, setLibraryFilter } from "../../store/state.svelte";
@@ -114,7 +116,28 @@
let activeIdx = $state(0); let activeIdx = $state(0);
const activeSlot = $derived(resolvedSlots[activeIdx]); const activeSlot = $derived(resolvedSlots[activeIdx]);
const heroThumb = $derived(activeSlot?.kind === "pinned" ? thumbUrl(activeSlot.manga?.thumbnailUrl ?? "") : activeSlot?.kind === "continue" ? thumbUrl(activeSlot.entry?.thumbnailUrl ?? "") : ""); const heroThumbSrc = $derived(
activeSlot?.kind === "pinned" ? (activeSlot.manga?.thumbnailUrl ?? "") :
activeSlot?.kind === "continue" ? (activeSlot.entry?.thumbnailUrl ?? "") : ""
);
let heroThumb = $state("");
const heroThumbCache = new Map<string, string>();
$effect(() => {
const path = heroThumbSrc;
const mode = store.settings.serverAuthMode ?? "NONE";
if (!path) { heroThumb = ""; return; }
if (mode !== "BASIC_AUTH") { heroThumb = thumbUrl(path); return; }
if (heroThumbCache.has(path)) { heroThumb = heroThumbCache.get(path)!; return; }
heroThumb = "";
fetchAuthenticated(thumbUrl(path), { method: "GET" })
.then(r => r.blob())
.then(blob => {
const url = URL.createObjectURL(blob);
heroThumbCache.set(path, url);
heroThumb = url;
})
.catch(() => {});
});
const heroTitle = $derived(activeSlot?.kind === "pinned" ? (activeSlot.manga?.title ?? "") : activeSlot?.kind === "continue" ? (activeSlot.entry?.mangaTitle ?? "") : ""); const heroTitle = $derived(activeSlot?.kind === "pinned" ? (activeSlot.manga?.title ?? "") : activeSlot?.kind === "continue" ? (activeSlot.entry?.mangaTitle ?? "") : "");
const heroManga = $derived(activeSlot?.kind === "pinned" ? activeSlot.manga : activeSlot?.kind === "continue" ? libraryManga.find(m => m.id === activeSlot.entry?.mangaId) : null); const heroManga = $derived(activeSlot?.kind === "pinned" ? activeSlot.manga : activeSlot?.kind === "continue" ? libraryManga.find(m => m.id === activeSlot.entry?.mangaId) : null);
const heroEntry = $derived(activeSlot?.kind === "continue" ? activeSlot.entry : null); const heroEntry = $derived(activeSlot?.kind === "continue" ? activeSlot.entry : null);
@@ -387,7 +410,7 @@
{#if recentHistory.length > 0} {#if recentHistory.length > 0}
{#each recentHistory as entry (entry.chapterId)} {#each recentHistory as entry (entry.chapterId)}
<button class="activity-row" onclick={() => resumeEntry(entry)}> <button class="activity-row" onclick={() => resumeEntry(entry)}>
<img src={thumbUrl(entry.thumbnailUrl)} alt={entry.mangaTitle} class="activity-thumb" loading="lazy" decoding="async" /> <Thumbnail src={entry.thumbnailUrl} alt={entry.mangaTitle} class="activity-thumb" />
<div class="activity-info"> <div class="activity-info">
<span class="activity-title">{entry.mangaTitle}</span> <span class="activity-title">{entry.mangaTitle}</span>
<span class="activity-sub">{entry.chapterName}{entry.pageNumber > 1 ? ` · p.${entry.pageNumber}` : ""}</span> <span class="activity-sub">{entry.chapterName}{entry.pageNumber > 1 ? ` · p.${entry.pageNumber}` : ""}</span>
@@ -431,7 +454,7 @@
{#each completedManga as m (m.id)} {#each completedManga as m (m.id)}
<button class="mini-card" onclick={() => store.previewManga = m}> <button class="mini-card" onclick={() => store.previewManga = m}>
<div class="mini-cover-wrap"> <div class="mini-cover-wrap">
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="mini-cover" loading="lazy" decoding="async" /> <Thumbnail src={m.thumbnailUrl} alt={m.title} class="mini-cover" />
<div class="mini-gradient"></div> <div class="mini-gradient"></div>
<div class="mini-footer"> <div class="mini-footer">
<p class="mini-card-title">{m.title}</p> <p class="mini-card-title">{m.title}</p>
@@ -487,7 +510,7 @@
{:else} {:else}
{#each pickerResults as m (m.id)} {#each pickerResults as m (m.id)}
<button class="picker-row" onclick={() => pinManga(m)}> <button class="picker-row" onclick={() => pinManga(m)}>
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="picker-thumb" loading="lazy" /> <Thumbnail src={m.thumbnailUrl} alt={m.title} class="picker-thumb" />
<div class="picker-info"> <div class="picker-info">
<span class="picker-manga-title">{m.title}</span> <span class="picker-manga-title">{m.title}</span>
{#if m.source?.displayName}<span class="picker-source">{m.source.displayName}</span>{/if} {#if m.source?.displayName}<span class="picker-source">{m.source.displayName}</span>{/if}
@@ -577,7 +600,7 @@
.activity-row { display: flex; align-items: center; gap: var(--sp-3); padding: 7px var(--sp-2); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; cursor: pointer; width: 100%; transition: background var(--t-fast), border-color var(--t-fast); } .activity-row { display: flex; align-items: center; gap: var(--sp-3); padding: 7px var(--sp-2); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; cursor: pointer; width: 100%; transition: background var(--t-fast), border-color var(--t-fast); }
.activity-row:hover { background: var(--bg-raised); border-color: var(--border-dim); } .activity-row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.activity-row:hover .activity-play { opacity: 1; } .activity-row:hover .activity-play { opacity: 1; }
.activity-thumb { width: 33px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); } :global(.activity-thumb) { width: 33px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
.activity-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; } .activity-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.activity-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .activity-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.activity-sub { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-muted); letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .activity-sub { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-muted); letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@@ -594,10 +617,10 @@
.mini-row::-webkit-scrollbar { display: none; } .mini-row::-webkit-scrollbar { display: none; }
.mini-card { flex: 0 0 120px; width: 120px; background: none; border: none; padding: 0; cursor: pointer; text-align: left; } .mini-card { flex: 0 0 120px; width: 120px; background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
.mini-card:hover .mini-cover { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); } .mini-card:hover :global(.mini-cover) { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); }
.mini-card:hover { will-change: transform; } .mini-card:hover { will-change: transform; }
.mini-cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 2px 12px rgba(0,0,0,0.35); } .mini-cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 2px 12px rgba(0,0,0,0.35); }
.mini-cover { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.15s ease, transform 0.15s ease; } :global(.mini-cover) { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.15s ease, transform 0.15s ease; }
.mini-gradient { position: absolute; inset: 0; background: linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.1) 55%, transparent 75%); pointer-events: none; } .mini-gradient { position: absolute; inset: 0; background: linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.1) 55%, transparent 75%); pointer-events: none; }
.mini-footer { position: absolute; bottom: 0; left: 0; right: 0; padding: var(--sp-2); pointer-events: none; } .mini-footer { position: absolute; bottom: 0; left: 0; right: 0; padding: var(--sp-2); pointer-events: none; }
.mini-card-title { font-size: var(--text-xs); font-weight: var(--weight-medium); color: rgba(255,255,255,0.92); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-shadow: 0 1px 4px rgba(0,0,0,0.7); } .mini-card-title { font-size: var(--text-xs); font-weight: var(--weight-medium); color: rgba(255,255,255,0.92); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-shadow: 0 1px 4px rgba(0,0,0,0.7); }
@@ -636,7 +659,7 @@
.picker-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-4) var(--sp-3); text-align: center; } .picker-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-4) var(--sp-3); text-align: center; }
.picker-row { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); } .picker-row { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
.picker-row:hover { background: var(--bg-raised); } .picker-row:hover { background: var(--bg-raised); }
.picker-thumb { height: 50px; width: 35px; aspect-ratio: 1 / 1.42; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); background: var(--bg-raised); display: block; } :global(.picker-thumb) { height: 50px; width: 35px; aspect-ratio: 1 / 1.42; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); background: var(--bg-raised); display: block; }
.picker-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; } .picker-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.picker-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .picker-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.picker-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .picker-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
+11 -12
View File
@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning, Sparkle } from "phosphor-svelte"; import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning, Sparkle } from "phosphor-svelte";
import { untrack } from "svelte"; import { untrack } from "svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql } from "../../lib/client";
import Thumbnail from "../shared/Thumbnail.svelte";
import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, UPDATE_CHAPTERS_PROGRESS } from "../../lib/queries"; import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, UPDATE_CHAPTERS_PROGRESS } from "../../lib/queries";
import { store } from "../../store/state.svelte"; import { store } from "../../store/state.svelte";
import type { Manga, Source, Chapter } from "../../lib/types"; import type { Manga, Source, Chapter } from "../../lib/types";
@@ -253,8 +254,7 @@
class="source-row" class="source-row"
class:source-row-active={selectedSource?.id === src.id} class:source-row-active={selectedSource?.id === src.id}
onclick={() => pickSource(src)}> onclick={() => pickSource(src)}>
<img src={thumbUrl(src.iconUrl)} alt={src.name} class="source-icon" <Thumbnail src={src.iconUrl} alt={src.name} class="source-icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<div class="source-info"> <div class="source-info">
<span class="source-name">{src.displayName}</span> <span class="source-name">{src.displayName}</span>
<span class="source-meta">{src.lang.toUpperCase()}{src.isNsfw ? " · NSFW" : ""}</span> <span class="source-meta">{src.lang.toUpperCase()}{src.isNsfw ? " · NSFW" : ""}</span>
@@ -272,8 +272,7 @@
<!-- Source context pill --> <!-- Source context pill -->
{#if selectedSource} {#if selectedSource}
<div class="search-context"> <div class="search-context">
<img src={thumbUrl(selectedSource.iconUrl)} alt="" class="search-context-icon" <Thumbnail src={selectedSource.iconUrl} alt="" class="search-context-icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<span class="search-context-name">{selectedSource.displayName}</span> <span class="search-context-name">{selectedSource.displayName}</span>
<button class="search-context-change" onclick={() => { step = "source"; results = []; }}>Change</button> <button class="search-context-change" onclick={() => { step = "source"; results = []; }}>Change</button>
</div> </div>
@@ -316,7 +315,7 @@
onclick={() => selectMatch(m, similarity)} onclick={() => selectMatch(m, similarity)}
disabled={loadingMatchId !== null}> disabled={loadingMatchId !== null}>
<div class="result-cover-wrap"> <div class="result-cover-wrap">
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="result-cover" /> <Thumbnail src={m.thumbnailUrl} alt={m.title} class="result-cover" />
</div> </div>
<div class="result-info"> <div class="result-info">
<span class="result-title">{m.title}</span> <span class="result-title">{m.title}</span>
@@ -350,7 +349,7 @@
<div class="confirm-row"> <div class="confirm-row">
<div class="confirm-manga"> <div class="confirm-manga">
<div class="confirm-cover-wrap"> <div class="confirm-cover-wrap">
<img src={thumbUrl(manga.thumbnailUrl)} alt={manga.title} class="confirm-cover" /> <Thumbnail src={manga.thumbnailUrl} alt={manga.title} class="confirm-cover" />
</div> </div>
<p class="confirm-title">{manga.title}</p> <p class="confirm-title">{manga.title}</p>
<p class="confirm-source">{manga.source?.displayName ?? "Unknown"}</p> <p class="confirm-source">{manga.source?.displayName ?? "Unknown"}</p>
@@ -363,7 +362,7 @@
<div class="confirm-manga"> <div class="confirm-manga">
<div class="confirm-cover-wrap"> <div class="confirm-cover-wrap">
<img src={thumbUrl(selectedMatch.manga.thumbnailUrl)} alt={selectedMatch.manga.title} class="confirm-cover" /> <Thumbnail src={selectedMatch.manga.thumbnailUrl} alt={selectedMatch.manga.title} class="confirm-cover" />
</div> </div>
<p class="confirm-title">{selectedMatch.manga.title}</p> <p class="confirm-title">{selectedMatch.manga.title}</p>
<p class="confirm-source">{selectedSource?.displayName ?? "Unknown"}</p> <p class="confirm-source">{selectedSource?.displayName ?? "Unknown"}</p>
@@ -455,7 +454,7 @@
.source-row { 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); } .source-row { 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); }
.source-row:hover { background: var(--bg-raised); border-color: var(--border-dim); } .source-row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.source-row-active { background: var(--accent-muted); border-color: var(--accent-dim); } .source-row-active { background: var(--accent-muted); border-color: var(--accent-dim); }
.source-icon { width: 28px; height: 28px; border-radius: var(--radius-md); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); } :global(.source-icon) { width: 28px; height: 28px; border-radius: var(--radius-md); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
.source-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; } .source-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; }
.source-name { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .source-name { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.source-meta { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .source-meta { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
@@ -477,7 +476,7 @@
/* Search step */ /* Search step */
.search-step { flex: 1; overflow: hidden; display: flex; flex-direction: column; gap: var(--sp-3); padding: var(--sp-3) var(--sp-4); } .search-step { flex: 1; overflow: hidden; display: flex; flex-direction: column; gap: var(--sp-3); padding: var(--sp-3) var(--sp-4); }
.search-context { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); flex-shrink: 0; } .search-context { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); flex-shrink: 0; }
.search-context-icon { width: 18px; height: 18px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; } :global(.search-context-icon) { width: 18px; height: 18px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; }
.search-context-name { flex: 1; font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .search-context-name { flex: 1; font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.search-context-change { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--accent-fg); background: none; border: none; cursor: pointer; padding: 0; flex-shrink: 0; transition: opacity var(--t-base); } .search-context-change { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--accent-fg); background: none; border: none; cursor: pointer; padding: 0; flex-shrink: 0; transition: opacity var(--t-base); }
.search-context-change:hover { opacity: 0.75; } .search-context-change:hover { opacity: 0.75; }
@@ -495,7 +494,7 @@
.result-row:hover:not(:disabled) { background: var(--bg-raised); } .result-row:hover:not(:disabled) { background: var(--bg-raised); }
.result-row:disabled { opacity: 0.5; cursor: default; } .result-row:disabled { opacity: 0.5; cursor: default; }
.result-cover-wrap { width: 36px; height: 54px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); flex-shrink: 0; } .result-cover-wrap { width: 36px; height: 54px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); flex-shrink: 0; }
.result-cover { width: 100%; height: 100%; object-fit: cover; } :global(.result-cover) { width: 100%; height: 100%; object-fit: cover; }
.result-info { flex: 1; display: flex; flex-direction: column; gap: 4px; overflow: hidden; min-width: 0; } .result-info { flex: 1; display: flex; flex-direction: column; gap: 4px; overflow: hidden; min-width: 0; }
.result-title { font-size: var(--text-sm); color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .result-title { font-size: var(--text-sm); color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.result-meta { display: flex; align-items: center; gap: var(--sp-2); } .result-meta { display: flex; align-items: center; gap: var(--sp-2); }
@@ -515,7 +514,7 @@
.confirm-row { display: flex; align-items: center; justify-content: center; gap: var(--sp-4); } .confirm-row { display: flex; align-items: center; justify-content: center; gap: var(--sp-4); }
.confirm-manga { display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); flex: 1; max-width: 160px; } .confirm-manga { display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); flex: 1; max-width: 160px; }
.confirm-cover-wrap { width: 100%; aspect-ratio: 2/3; border-radius: var(--radius-md); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); } .confirm-cover-wrap { width: 100%; aspect-ratio: 2/3; border-radius: var(--radius-md); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); }
.confirm-cover { width: 100%; height: 100%; object-fit: cover; } :global(.confirm-cover) { width: 100%; height: 100%; object-fit: cover; }
.confirm-title { 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); } .confirm-title { 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); }
.confirm-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-align: center; } .confirm-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-align: center; }
.confirm-divider { display: flex; align-items: center; justify-content: center; flex-shrink: 0; } .confirm-divider { display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
+12 -14
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, untrack } from "svelte"; import { onDestroy, untrack } from "svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql } from "../../lib/client";
import Thumbnail from "../shared/Thumbnail.svelte";
import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_MANGA } from "../../lib/queries"; import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_MANGA } from "../../lib/queries";
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache"; import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
import { dedupeMangaById, dedupeMangaByTitle, shouldHideNsfw, shouldHideSource, normalizeTitle } from "../../lib/util"; import { dedupeMangaById, dedupeMangaByTitle, shouldHideNsfw, shouldHideSource, normalizeTitle } from "../../lib/util";
@@ -732,8 +733,7 @@
{#each kw_results.filter((r) => r.mangas.length > 0 || r.loading || r.error) as { source, mangas, loading, error } (source.id)} {#each kw_results.filter((r) => r.mangas.length > 0 || r.loading || r.error) as { source, mangas, loading, error } (source.id)}
<div class="sourceSection"> <div class="sourceSection">
<div class="sourceHeader"> <div class="sourceHeader">
<img src={thumbUrl(source.iconUrl)} alt={source.displayName} class="sourceIcon" <Thumbnail src={source.iconUrl} alt={source.displayName} class="sourceIcon" onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<span class="sourceName">{source.displayName}</span> <span class="sourceName">{source.displayName}</span>
{#if hasMultipleLangs}<span class="sourceLang">{source.lang.toUpperCase()}</span>{/if} {#if hasMultipleLangs}<span class="sourceLang">{source.lang.toUpperCase()}</span>{/if}
{#if loading} {#if loading}
@@ -757,7 +757,7 @@
{#each mangas.slice(0, (store.settings.renderLimit ?? 48)) as m (m.id)} {#each mangas.slice(0, (store.settings.renderLimit ?? 48)) as m (m.id)}
<button class="card" onclick={() => setPreviewManga(m)}> <button class="card" onclick={() => setPreviewManga(m)}>
<div class="coverWrap"> <div class="coverWrap">
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" /> <Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if} {#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
</div> </div>
<p class="cardTitle">{m.title}</p> <p class="cardTitle">{m.title}</p>
@@ -902,7 +902,7 @@
{#each tag_mergedResults as m (m.id)} {#each tag_mergedResults as m (m.id)}
<button class="card" onclick={() => setPreviewManga(m)}> <button class="card" onclick={() => setPreviewManga(m)}>
<div class="coverWrap"> <div class="coverWrap">
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" /> <Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if} {#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
</div> </div>
<p class="cardTitle">{m.title}</p> <p class="cardTitle">{m.title}</p>
@@ -961,8 +961,7 @@
<div class="splitList"> <div class="splitList">
{#each src_visibleSources as src (src.id)} {#each src_visibleSources as src (src.id)}
<button class="splitItem splitItemSource" class:splitItemActive={src_activeSource?.id === src.id} onclick={() => srcSelectSource(src)}> <button class="splitItem splitItemSource" class:splitItemActive={src_activeSource?.id === src.id} onclick={() => srcSelectSource(src)}>
<img src={thumbUrl(src.iconUrl)} alt="" class="splitSourceIcon" <Thumbnail src={src.iconUrl} alt="" class="splitSourceIcon" onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<span class="splitItemLabel">{src.name}</span> <span class="splitItemLabel">{src.name}</span>
{#if src_selectedLang === "all"} {#if src_selectedLang === "all"}
<span class="sourceLang" style="margin-left:auto;margin-right:4px">{src.lang.toUpperCase()}</span> <span class="sourceLang" style="margin-left:auto;margin-right:4px">{src.lang.toUpperCase()}</span>
@@ -989,8 +988,7 @@
{:else} {:else}
<div class="splitContentHeader"> <div class="splitContentHeader">
<div class="splitSourceTitle"> <div class="splitSourceTitle">
<img src={thumbUrl(src_activeSource.iconUrl)} alt="" class="splitSourceIcon" <Thumbnail src={src_activeSource.iconUrl} alt="" class="splitSourceIcon" onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<span class="splitContentTitle">{src_activeSource.displayName}</span> <span class="splitContentTitle">{src_activeSource.displayName}</span>
{#if src_loadingBrowse} {#if src_loadingBrowse}
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" style="color:var(--text-faint)" aria-hidden="true"> <svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" style="color:var(--text-faint)" aria-hidden="true">
@@ -1030,7 +1028,7 @@
{#each src_browseResults as m (m.id)} {#each src_browseResults as m (m.id)}
<button class="card" onclick={() => setPreviewManga(m)}> <button class="card" onclick={() => setPreviewManga(m)}>
<div class="coverWrap"> <div class="coverWrap">
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" /> <Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if} {#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
</div> </div>
<p class="cardTitle">{m.title}</p> <p class="cardTitle">{m.title}</p>
@@ -1117,7 +1115,7 @@
.sourceSection { padding: var(--sp-1) var(--sp-4) var(--sp-3); border-bottom: 1px solid var(--border-dim); } .sourceSection { padding: var(--sp-1) var(--sp-4) var(--sp-3); border-bottom: 1px solid var(--border-dim); }
.sourceSection:last-child { border-bottom: none; } .sourceSection:last-child { border-bottom: none; }
.sourceHeader { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) 0; } .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); } :global(.sourceIcon) { width: 20px; height: 20px; 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); } .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; } .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); } .resultCount { margin-left: auto; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
@@ -1125,10 +1123,10 @@
.sourceRow { display: flex; gap: var(--sp-3); overflow-x: auto; padding-bottom: var(--sp-1); scrollbar-width: none; } .sourceRow { display: flex; gap: var(--sp-3); overflow-x: auto; padding-bottom: var(--sp-1); scrollbar-width: none; }
.sourceRow::-webkit-scrollbar { display: none; } .sourceRow::-webkit-scrollbar { display: none; }
.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 { 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 :global(.cover) { filter: brightness(1.06); }
.card:hover .cardTitle { color: var(--text-primary); } .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); } .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); } :global(.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); } .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); } .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); }
.skCard { display: flex; flex-direction: column; gap: var(--sp-2); flex-shrink: 0; width: 110px; } .skCard { display: flex; flex-direction: column; gap: var(--sp-2); flex-shrink: 0; width: 110px; }
@@ -1162,7 +1160,7 @@
.splitSourceTitle { display: flex; align-items: center; gap: var(--sp-2); flex: 1; min-width: 0; } .splitSourceTitle { display: flex; align-items: center; gap: var(--sp-2); flex: 1; min-width: 0; }
.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); } .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); }
.splitResultCount { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; } .splitResultCount { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
.splitSourceIcon { width: 18px; height: 18px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); } :global(.splitSourceIcon) { width: 20px; height: 20px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
.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; } .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; } .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); } .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); }
+6 -5
View File
@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount, untrack } from "svelte"; import { onMount, untrack } from "svelte";
import { ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle, ArrowSquareOut, CircleNotch, Play, SortAscending, SortDescending, CaretDown, ArrowsClockwise, List, SquaresFour, FolderSimplePlus, Trash, DownloadSimple, X, LinkSimpleHorizontalBreak, ChartLineUp, MagnifyingGlass, Gear, Eye, MapPin } from "phosphor-svelte"; import { ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle, ArrowSquareOut, CircleNotch, Play, SortAscending, SortDescending, CaretDown, ArrowsClockwise, List, SquaresFour, FolderSimplePlus, Trash, DownloadSimple, X, LinkSimpleHorizontalBreak, ChartLineUp, MagnifyingGlass, Gear, Eye, MapPin } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql } from "../../lib/client";
import Thumbnail from "../shared/Thumbnail.svelte";
import { GET_MANGA, GET_CHAPTERS, FETCH_CHAPTERS, ENQUEUE_DOWNLOAD, UPDATE_MANGA, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS, ENQUEUE_CHAPTERS_DOWNLOAD, GET_ALL_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries"; import { GET_MANGA, GET_CHAPTERS, FETCH_CHAPTERS, ENQUEUE_DOWNLOAD, UPDATE_MANGA, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS, ENQUEUE_CHAPTERS_DOWNLOAD, GET_ALL_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
import { cache, CACHE_KEYS, recordSourceAccess } from "../../lib/cache"; import { cache, CACHE_KEYS, recordSourceAccess } from "../../lib/cache";
import { dedupeMangaById, dedupeMangaByTitle } from "../../lib/util"; import { dedupeMangaById, dedupeMangaByTitle } from "../../lib/util";
@@ -505,7 +506,7 @@
</button> </button>
<div class="cover-wrap"> <div class="cover-wrap">
<img src={thumbUrl(store.activeManga.thumbnailUrl)} alt={store.activeManga.title} class="cover" /> <Thumbnail src={store.activeManga.thumbnailUrl} alt={store.activeManga.title} class="cover" />
</div> </div>
{#if loadingManga} {#if loadingManga}
@@ -892,7 +893,7 @@
{#each linkPickerResults as m (m.id)} {#each linkPickerResults as m (m.id)}
{@const isLinked = linkedIds.includes(m.id)} {@const isLinked = linkedIds.includes(m.id)}
<button class="link-row" class:link-row-linked={isLinked} onclick={() => handleLink(m)}> <button class="link-row" class:link-row-linked={isLinked} onclick={() => handleLink(m)}>
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="link-thumb" loading="lazy" decoding="async" /> <Thumbnail src={m.thumbnailUrl} alt={m.title} class="link-thumb" />
<div class="link-info"> <div class="link-info">
<span class="link-manga-title">{m.title}</span> <span class="link-manga-title">{m.title}</span>
{#if m.source?.displayName}<span class="link-source">{m.source.displayName}</span>{/if} {#if m.source?.displayName}<span class="link-source">{m.source.displayName}</span>{/if}
@@ -916,7 +917,7 @@
.back:hover { color: var(--text-secondary); } .back:hover { color: var(--text-secondary); }
.cover-wrap { width: 100%; aspect-ratio: 2/3; border-radius: var(--radius-md); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); flex-shrink: 0; } .cover-wrap { width: 100%; aspect-ratio: 2/3; border-radius: var(--radius-md); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); flex-shrink: 0; }
.cover { width: 100%; height: 100%; object-fit: cover; } :global(.cover) { width: 100%; height: 100%; object-fit: cover; }
.meta-skeleton { display: flex; flex-direction: column; gap: var(--sp-2); } .meta-skeleton { display: flex; flex-direction: column; gap: var(--sp-2); }
.sk-line { border-radius: var(--radius-sm); } .sk-line { border-radius: var(--radius-sm); }
@@ -980,7 +981,7 @@
.link-row { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); } .link-row { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
.link-row:hover { background: var(--bg-raised); } .link-row:hover { background: var(--bg-raised); }
.link-row-linked { background: var(--accent-muted) !important; } .link-row-linked { background: var(--accent-muted) !important; }
.link-thumb { width: 34px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); } :global(.link-thumb) { width: 34px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
.link-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; } .link-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.link-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .link-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.link-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .link-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
+483 -226
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { CircleNotch, ArrowSquareOut, ArrowsClockwise, X, Lock, MagnifyingGlass, Funnel } from "phosphor-svelte"; import { CircleNotch, ArrowSquareOut, ArrowsClockwise, X, Lock, MagnifyingGlass, Funnel } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql } from "../../lib/client";
import Thumbnail from "../shared/Thumbnail.svelte";
import { import {
GET_ALL_TRACKER_RECORDS, GET_ALL_TRACKER_RECORDS,
UPDATE_TRACK, UPDATE_TRACK,
@@ -10,8 +11,6 @@
import { addToast, setActiveManga, setNavPage } from "../../store/state.svelte"; import { addToast, setActiveManga, setNavPage } from "../../store/state.svelte";
import type { Tracker, TrackRecord } from "../../lib/types"; import type { Tracker, TrackRecord } from "../../lib/types";
// ── Types ──────────────────────────────────────────────────────────────────
interface TrackerWithRecords extends Tracker { interface TrackerWithRecords extends Tracker {
trackRecords: { nodes: TrackRecord[] }; trackRecords: { nodes: TrackRecord[] };
} }
@@ -20,26 +19,20 @@
tracker: Tracker; tracker: Tracker;
} }
// ── State ──────────────────────────────────────────────────────────────────
let trackers: TrackerWithRecords[] = $state([]); let trackers: TrackerWithRecords[] = $state([]);
let loading: boolean = $state(true); let loading: boolean = $state(true);
let error: string | null = $state(null); let error: string | null = $state(null);
// Filter/view state
let activeTrackerId: number | "all" = $state("all"); let activeTrackerId: number | "all" = $state("all");
let statusFilter: number | "all" = $state("all"); let statusFilter: number | "all" = $state("all");
let searchQuery: string = $state(""); let searchQuery: string = $state("");
let sortBy: "title" | "status" | "score" | "progress" = $state("title"); let sortBy: "title" | "status" | "score" | "progress" = $state("title");
// Mutation state
let updatingId: number | null = $state(null); let updatingId: number | null = $state(null);
let syncingId: number | null = $state(null); let syncingId: number | null = $state(null);
// Chapter editing: recordId → draft value
let editingChapter: number | null = $state(null); let editingChapter: number | null = $state(null);
let chapterDraft: number = $state(0); let chapterDraft: number = $state(0);
let confirmUnbindRecord: FlatRecord | null = $state(null);
// ── Load ───────────────────────────────────────────────────────────────────
async function load() { async function load() {
loading = true; error = null; loading = true; error = null;
@@ -55,15 +48,13 @@
$effect(() => { load(); }); $effect(() => { load(); });
// ── Derived data ───────────────────────────────────────────────────────────
const loggedInTrackers = $derived(trackers.filter(t => t.isLoggedIn)); const loggedInTrackers = $derived(trackers.filter(t => t.isLoggedIn));
const allRecords: FlatRecord[] = $derived( const allRecords: FlatRecord[] = $derived(
loggedInTrackers.flatMap(t => loggedInTrackers.flatMap(t =>
t.trackRecords.nodes.map(r => ({ t.trackRecords.nodes.map(r => ({
...r, ...r,
trackerId: r.trackerId ?? t.id, // fallback in case field is missing trackerId: r.trackerId ?? t.id,
tracker: t as Tracker, tracker: t as Tracker,
})) }))
) )
@@ -71,14 +62,11 @@
const totalCount = $derived(allRecords.length); const totalCount = $derived(allRecords.length);
// Status options across active tracker
const statusOptions = $derived.by(() => { const statusOptions = $derived.by(() => {
if (activeTrackerId === "all") { if (activeTrackerId === "all") {
// Merge all statuses, dedupe by value+name
const seen = new Map<string, { value: number; name: string }>(); const seen = new Map<string, { value: number; name: string }>();
for (const t of loggedInTrackers) { for (const t of loggedInTrackers)
for (const s of t.statuses ?? []) seen.set(`${s.value}:${s.name}`, s); for (const s of t.statuses ?? []) seen.set(`${s.value}:${s.name}`, s);
}
return [...seen.values()]; return [...seen.values()];
} }
return loggedInTrackers.find(t => t.id === activeTrackerId)?.statuses ?? []; return loggedInTrackers.find(t => t.id === activeTrackerId)?.statuses ?? [];
@@ -111,8 +99,6 @@
}); });
}); });
// ── Mutations ──────────────────────────────────────────────────────────────
async function updateStatus(record: FlatRecord, status: number) { async function updateStatus(record: FlatRecord, status: number) {
updatingId = record.id; updatingId = record.id;
try { try {
@@ -122,9 +108,7 @@
patchRecord(record.trackerId, res.updateTrack.trackRecord); patchRecord(record.trackerId, res.updateTrack.trackRecord);
} catch (e: any) { } catch (e: any) {
addToast({ kind: "error", title: "Update failed", body: e?.message }); addToast({ kind: "error", title: "Update failed", body: e?.message });
} finally { } finally { updatingId = null; }
updatingId = null;
}
} }
async function updateScore(record: FlatRecord, scoreString: string) { async function updateScore(record: FlatRecord, scoreString: string) {
@@ -136,9 +120,7 @@
patchRecord(record.trackerId, res.updateTrack.trackRecord); patchRecord(record.trackerId, res.updateTrack.trackRecord);
} catch (e: any) { } catch (e: any) {
addToast({ kind: "error", title: "Update failed", body: e?.message }); addToast({ kind: "error", title: "Update failed", body: e?.message });
} finally { } finally { updatingId = null; }
updatingId = null;
}
} }
async function syncRecord(record: FlatRecord) { async function syncRecord(record: FlatRecord) {
@@ -151,9 +133,7 @@
addToast({ kind: "success", title: "Synced from tracker" }); addToast({ kind: "success", title: "Synced from tracker" });
} catch (e: any) { } catch (e: any) {
addToast({ kind: "error", title: "Sync failed", body: e?.message }); addToast({ kind: "error", title: "Sync failed", body: e?.message });
} finally { } finally { syncingId = null; }
syncingId = null;
}
} }
async function unbind(record: FlatRecord) { async function unbind(record: FlatRecord) {
@@ -169,9 +149,7 @@
addToast({ kind: "info", title: "Unlinked from " + record.tracker.name }); addToast({ kind: "info", title: "Unlinked from " + record.tracker.name });
} catch (e: any) { } catch (e: any) {
addToast({ kind: "error", title: "Unbind failed", body: e?.message }); addToast({ kind: "error", title: "Unbind failed", body: e?.message });
} finally { } finally { updatingId = null; }
updatingId = null;
}
} }
function patchRecord(trackerId: number, updated: Partial<TrackRecord> & { id: number }) { function patchRecord(trackerId: number, updated: Partial<TrackRecord> & { id: number }) {
@@ -196,9 +174,7 @@
chapterDraft = record.lastChapterRead; chapterDraft = record.lastChapterRead;
} }
function cancelChapterEditor() { function cancelChapterEditor() { editingChapter = null; }
editingChapter = null;
}
async function submitChapter(record: FlatRecord) { async function submitChapter(record: FlatRecord) {
const val = Math.max(0, chapterDraft); const val = Math.max(0, chapterDraft);
@@ -212,15 +188,34 @@
patchRecord(record.trackerId, res.updateTrack.trackRecord); patchRecord(record.trackerId, res.updateTrack.trackRecord);
} catch (e: any) { } catch (e: any) {
addToast({ kind: "error", title: "Update failed", body: e?.message }); addToast({ kind: "error", title: "Update failed", body: e?.message });
} finally { } finally { updatingId = null; }
updatingId = null;
} }
function requestUnbind(record: FlatRecord) {
confirmUnbindRecord = record;
}
function cancelUnbind() {
confirmUnbindRecord = null;
}
async function confirmAndUnbind() {
if (!confirmUnbindRecord) return;
const record = confirmUnbindRecord;
confirmUnbindRecord = null;
await unbind(record);
}
function scoreToStars(score: string | undefined, scores: string[] | undefined): number {
if (!score || !scores || scores.length === 0) return 0;
const idx = scores.indexOf(score);
if (idx < 0) return 0;
return Math.round((idx / (scores.length - 1)) * 5);
} }
</script> </script>
<div class="page"> <div class="page">
<!-- ── Header ──────────────────────────────────────────────────────────── -->
<div class="header"> <div class="header">
<div class="header-top"> <div class="header-top">
<h1 class="heading">Tracking</h1> <h1 class="heading">Tracking</h1>
@@ -231,7 +226,6 @@
</div> </div>
</div> </div>
<!-- Tracker filter tabs -->
{#if !loading && loggedInTrackers.length > 0} {#if !loading && loggedInTrackers.length > 0}
<div class="tracker-tabs"> <div class="tracker-tabs">
<button <button
@@ -249,14 +243,13 @@
class:tab-active={activeTrackerId === t.id} class:tab-active={activeTrackerId === t.id}
onclick={() => { activeTrackerId = Number(t.id); statusFilter = "all"; }} onclick={() => { activeTrackerId = Number(t.id); statusFilter = "all"; }}
> >
<img src={thumbUrl(t.icon)} alt={t.name} class="tab-tracker-icon" /> <Thumbnail src={t.icon} alt={t.name} class="tab-tracker-icon" />
{t.name} {t.name}
<span class="tab-count">{count}</span> <span class="tab-count">{count}</span>
</button> </button>
{/each} {/each}
</div> </div>
<!-- Filter + sort bar -->
<div class="filter-bar"> <div class="filter-bar">
<div class="search-wrap"> <div class="search-wrap">
<MagnifyingGlass size={12} weight="light" class="search-ico" /> <MagnifyingGlass size={12} weight="light" class="search-ico" />
@@ -266,7 +259,6 @@
bind:value={searchQuery} bind:value={searchQuery}
/> />
</div> </div>
<div class="filter-right"> <div class="filter-right">
<Funnel size={12} weight="light" style="color:var(--text-faint);flex-shrink:0" /> <Funnel size={12} weight="light" style="color:var(--text-faint);flex-shrink:0" />
<select class="filter-select" bind:value={statusFilter} <select class="filter-select" bind:value={statusFilter}
@@ -279,25 +271,23 @@
<option value={s.value}>{s.name}</option> <option value={s.value}>{s.name}</option>
{/each} {/each}
</select> </select>
<select class="filter-select" bind:value={sortBy}> <select class="filter-select" bind:value={sortBy}>
<option value="title">Sort: Title</option> <option value="title">Title</option>
<option value="status">Sort: Status</option> <option value="status">Status</option>
<option value="score">Sort: Score</option> <option value="score">Score</option>
<option value="progress">Sort: Progress</option> <option value="progress">Progress</option>
</select> </select>
</div> </div>
</div> </div>
{/if} {/if}
</div> </div>
<!-- ── Body ────────────────────────────────────────────────────────────── -->
<div class="page-body"> <div class="page-body">
{#if loading} {#if loading}
<div class="state-center"> <div class="state-center">
<CircleNotch size={22} weight="light" class="anim-spin" style="color:var(--text-faint)" /> <CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" />
<span class="state-label">Loading tracking data</span> <span class="state-label">Loading…</span>
</div> </div>
{:else if error} {:else if error}
@@ -309,19 +299,19 @@
{:else if loggedInTrackers.length === 0} {:else if loggedInTrackers.length === 0}
<div class="state-center"> <div class="state-center">
<p class="state-text">No trackers connected.</p> <p class="state-text">No trackers connected.</p>
<p class="state-hint">Go to Settings → Tracking to log in to AniList, MAL, or others.</p> <p class="state-hint">Go to Settings → Tracking to connect AniList, MAL, or others.</p>
</div> </div>
{:else if filtered.length === 0} {:else if filtered.length === 0}
<div class="state-center"> <div class="state-center">
<p class="state-text">{searchQuery || statusFilter !== "all" ? "No results match your filters." : "Nothing tracked yet."}</p> <p class="state-text">{searchQuery || statusFilter !== "all" ? "No results." : "Nothing tracked yet."}</p>
{#if searchQuery || statusFilter !== "all"} {#if searchQuery || statusFilter !== "all"}
<button class="retry-btn" onclick={() => { searchQuery = ""; statusFilter = "all"; }}>Clear filters</button> <button class="retry-btn" onclick={() => { searchQuery = ""; statusFilter = "all"; }}>Clear filters</button>
{/if} {/if}
</div> </div>
{:else} {:else}
<div class="records-list"> <div class="records-grid">
{#each filtered as record (record.tracker.id + ":" + record.id)} {#each filtered as record (record.tracker.id + ":" + record.id)}
{@const tracker = record.tracker} {@const tracker = record.tracker}
{@const isBusy = updatingId === record.id} {@const isBusy = updatingId === record.id}
@@ -329,64 +319,74 @@
{@const progress = record.totalChapters > 0 {@const progress = record.totalChapters > 0
? Math.min(100, (record.lastChapterRead / record.totalChapters) * 100) ? Math.min(100, (record.lastChapterRead / record.totalChapters) * 100)
: null} : null}
{@const stars = scoreToStars(record.displayScore, tracker.scores)}
{@const statusName = (tracker.statuses ?? []).find(s => s.value === record.status)?.name ?? "—"}
<div class="record-card" class:record-busy={isBusy}> <div class="record-card" class:record-busy={isBusy}>
<!-- Cover --> <div class="card-cover-wrap">
<div class="record-cover-wrap" role="button" tabindex="0" <div class="card-cover-region"
role="button" tabindex="0"
onclick={() => openManga(record)} onclick={() => openManga(record)}
onkeydown={(e) => e.key === "Enter" && openManga(record)} onkeydown={(e) => e.key === "Enter" && openManga(record)}
title="Open in library"
> >
{#if record.manga?.thumbnailUrl} {#if record.manga?.thumbnailUrl}
<img src={thumbUrl(record.manga.thumbnailUrl)} alt={record.title} class="record-cover" loading="lazy" /> <Thumbnail src={record.manga.thumbnailUrl} alt={record.title} class="card-cover-img" />
{:else} {:else}
<div class="record-cover record-cover-empty"></div> <div class="card-cover-empty"></div>
{/if} {/if}
<!-- Tracker badge -->
<img src={thumbUrl(tracker.icon)} alt={tracker.name} class="record-tracker-badge" />
</div> </div>
<!-- Info --> <div class="card-top-actions">
<div class="record-body"> {#if record.private}
<div class="record-top"> <span class="card-badge-btn" title="Private"><Lock size={10} weight="fill" /></span>
<div class="record-titles" role="button" tabindex="0"
onclick={() => openManga(record)}
onkeydown={(e) => e.key === "Enter" && openManga(record)}
>
<span class="record-title">{record.title}</span>
{#if record.manga?.title && record.manga.title !== record.title}
<span class="record-local-title">{record.manga.title}</span>
{/if}
</div>
<div class="record-header-actions">
{#if activeTrackerId === "all"}
<span class="record-tracker-label">
<img src={thumbUrl(record.tracker.icon)} alt={record.tracker.name} class="record-tracker-label-icon" />
{record.tracker.name}
</span>
{/if} {/if}
{#if isSyncing} {#if isSyncing}
<CircleNotch size={12} weight="light" class="anim-spin" style="color:var(--text-faint)" /> <span class="card-badge-btn">
<CircleNotch size={10} weight="light" class="anim-spin" />
</span>
{:else} {:else}
<button class="card-icon-btn" title="Sync from tracker" onclick={() => syncRecord(record)}> <button class="card-badge-btn" title="Sync" onclick={() => syncRecord(record)}>
<ArrowsClockwise size={12} weight="light" /> <ArrowsClockwise size={10} weight="light" />
</button> </button>
{/if} {/if}
{#if record.remoteUrl} {#if record.remoteUrl}
<a href={record.remoteUrl} target="_blank" rel="noreferrer" class="card-icon-btn" title="Open on {record.tracker.name}"> <a href={record.remoteUrl} target="_blank" rel="noreferrer" class="card-badge-btn" title="Open on {tracker.name}">
<ArrowSquareOut size={12} weight="light" /> <ArrowSquareOut size={10} weight="light" />
</a> </a>
{/if} {/if}
<button class="card-icon-btn danger" title="Unlink" onclick={() => unbind(record)} disabled={isBusy}> <button class="card-badge-btn danger" title="Unlink" onclick={() => requestUnbind(record)} disabled={isBusy}>
<X size={12} weight="bold" /> <X size={10} weight="bold" />
</button> </button>
</div> </div>
<div class="card-tracker-badge">
<Thumbnail src={tracker.icon} alt={tracker.name} class="tracker-badge-img" />
</div>
</div> </div>
<!-- Controls row --> <div class="card-footer">
<div class="record-controls"> <div class="card-stars">
{#each Array(5) as _, i}
<span class="star" class:star-filled={i < stars}>★</span>
{/each}
</div>
<div class="card-title-block"
role="button" tabindex="0"
onclick={() => openManga(record)}
onkeydown={(e) => e.key === "Enter" && openManga(record)}
>
<span class="card-title">{record.title}</span>
{#if record.manga?.title && record.manga.title !== record.title}
<span class="card-local-title">{record.manga.title}</span>
{/if}
</div>
<div class="card-meta-row">
<select <select
class="record-select" class="status-pill"
value={record.status} value={record.status}
disabled={isBusy} disabled={isBusy}
onchange={(e) => updateStatus(record, parseInt((e.target as HTMLSelectElement).value))} onchange={(e) => updateStatus(record, parseInt((e.target as HTMLSelectElement).value))}
@@ -397,7 +397,7 @@
</select> </select>
<select <select
class="record-select record-select-score" class="score-select"
value={record.displayScore} value={record.displayScore}
disabled={isBusy} disabled={isBusy}
onchange={(e) => updateScore(record, (e.target as HTMLSelectElement).value)} onchange={(e) => updateScore(record, (e.target as HTMLSelectElement).value)}
@@ -406,29 +406,18 @@
<option value={s}> {s}</option> <option value={s}> {s}</option>
{/each} {/each}
</select> </select>
{#if record.private}
<span class="private-badge" title="Private"><Lock size={10} weight="fill" /></span>
{/if}
</div> </div>
<!-- Progress / Chapter editor -->
{#if editingChapter === record.id} {#if editingChapter === record.id}
<div class="chapter-editor"> <div class="chapter-editor" role="presentation" onclick={(e) => e.stopPropagation()}>
<div class="chapter-editor-top"> <div class="chapter-editor-top">
<span class="chapter-editor-label">Chapter read</span> <span class="chapter-editor-label">Chapter</span>
<div class="chapter-input-wrap"> <div class="chapter-input-wrap">
<input <input
type="number" type="number" class="chapter-input"
class="chapter-input" min="0" max={record.totalChapters > 0 ? record.totalChapters : undefined}
min="0" step="0.5" bind:value={chapterDraft}
max={record.totalChapters > 0 ? record.totalChapters : undefined} onkeydown={(e) => { if (e.key === "Enter") submitChapter(record); if (e.key === "Escape") cancelChapterEditor(); }}
step="0.5"
bind:value={chapterDraft}
onkeydown={(e) => {
if (e.key === "Enter") submitChapter(record);
if (e.key === "Escape") cancelChapterEditor();
}}
use:focusEl use:focusEl
/> />
{#if record.totalChapters > 0} {#if record.totalChapters > 0}
@@ -437,45 +426,39 @@
</div> </div>
</div> </div>
{#if record.totalChapters > 0} {#if record.totalChapters > 0}
<input <input type="range" class="chapter-slider" min="0" max={record.totalChapters} step="1" bind:value={chapterDraft} />
type="range"
class="chapter-slider"
min="0"
max={record.totalChapters}
step="1"
bind:value={chapterDraft}
/>
{/if} {/if}
<div class="chapter-editor-actions"> <div class="chapter-editor-actions">
<button class="chapter-save-btn" onclick={() => submitChapter(record)}>Save</button>
<button class="chapter-cancel-btn" onclick={cancelChapterEditor}>Cancel</button> <button class="chapter-cancel-btn" onclick={cancelChapterEditor}>Cancel</button>
<button class="chapter-save-btn" onclick={() => submitChapter(record)}>Save</button>
</div> </div>
</div> </div>
{:else if progress !== null}
<div class="record-progress clickable" role="button" tabindex="0"
onclick={() => openChapterEditor(record)}
onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)}
title="Click to edit"
>
<div class="progress-track">
<div class="progress-fill" style="width:{progress}%"></div>
</div>
<span class="progress-label">Ch. {record.lastChapterRead} / {record.totalChapters}</span>
<span class="progress-edit-hint"></span>
</div>
{:else} {:else}
<div class="record-progress clickable no-total" role="button" tabindex="0" <div class="progress-block clickable"
role="button" tabindex="0"
onclick={() => openChapterEditor(record)} onclick={() => openChapterEditor(record)}
onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)} onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)}
title="Click to set chapter" title="Click to edit chapter"
> >
<span class="progress-label"> <div class="progress-labels">
{record.lastChapterRead > 0 ? `Ch. ${record.lastChapterRead} read` : "Set chapter…"} <span class="progress-text">
{#if progress !== null}
Ch.&nbsp;{record.lastChapterRead}&thinsp;/&thinsp;{record.totalChapters}
{:else if record.lastChapterRead > 0}
Ch.&nbsp;{record.lastChapterRead}&nbsp;read
{:else}
Set chapter…
{/if}
</span> </span>
<span class="progress-edit-hint"></span> {#if progress !== null}
<span class="progress-pct">{Math.round(progress)}%</span>
{/if}
</div>
<div class="progress-track">
<div class="progress-fill" style="width:{progress ?? 0}%"></div>
</div>
</div> </div>
{/if} {/if}
</div> </div>
</div> </div>
{/each} {/each}
@@ -485,151 +468,425 @@
</div> </div>
</div> </div>
<style> {#if confirmUnbindRecord}
.page { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; } {@const r = confirmUnbindRecord}
<div class="modal-backdrop" role="presentation" onclick={cancelUnbind}>
<div class="modal" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
<div class="modal-icon">
<X size={18} weight="bold" />
</div>
<p class="modal-title">Unlink from {r.tracker.name}?</p>
<p class="modal-body">
<strong>{r.title}</strong> will be removed from your tracking list. This won't affect your progress on {r.tracker.name} itself.
</p>
<div class="modal-actions">
<button class="modal-cancel" onclick={cancelUnbind}>Cancel</button>
<button class="modal-confirm" onclick={confirmAndUnbind}>Unlink</button>
</div>
</div>
</div>
{/if}
/* ── Header ─────────────────────────────────────────────────────────────── */ <style>
.header { flex-shrink: 0; border-bottom: 1px solid var(--border-dim); background: var(--bg-base); } .page {
.header-top { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-6) var(--sp-3); } display: flex; flex-direction: column; height: 100%; overflow: hidden;
.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; } animation: fadeIn 0.16s ease both;
}
.header {
flex-shrink: 0;
border-bottom: 1px solid var(--border-dim);
background: var(--bg-base);
}
.header-top {
display: flex; align-items: center; justify-content: space-between;
padding: var(--sp-4) var(--sp-6) var(--sp-3);
}
.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;
}
.header-actions { display: flex; align-items: center; gap: var(--sp-2); } .header-actions { display: flex; align-items: center; gap: var(--sp-2); }
.icon-btn { display: flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: var(--radius-sm); border: none; color: var(--text-faint); background: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); } .icon-btn {
display: flex; align-items: center; justify-content: center;
width: 26px; height: 26px; border-radius: var(--radius-sm);
border: none; color: var(--text-faint); background: none;
cursor: pointer; transition: color var(--t-base), background var(--t-base);
}
.icon-btn:hover:not(:disabled) { color: var(--text-muted); background: var(--bg-raised); } .icon-btn:hover:not(:disabled) { color: var(--text-muted); background: var(--bg-raised); }
.icon-btn:disabled { opacity: 0.3; cursor: default; } .icon-btn:disabled { opacity: 0.3; cursor: default; }
/* ── Tracker tabs ───────────────────────────────────────────────────────── */ .tracker-tabs {
.tracker-tabs { display: flex; align-items: center; gap: 1px; padding: 0 var(--sp-5); overflow-x: auto; scrollbar-width: none; } display: flex; align-items: center; gap: 1px;
padding: 0 var(--sp-5); overflow-x: auto; scrollbar-width: none;
}
.tracker-tabs::-webkit-scrollbar { display: none; } .tracker-tabs::-webkit-scrollbar { display: none; }
.tracker-tab { .tracker-tab {
display: flex; align-items: center; gap: var(--sp-2); display: flex; align-items: center; gap: var(--sp-2);
padding: 9px 10px 8px; padding: 9px 10px 8px;
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-faint); background: none; border: none; letter-spacing: var(--tracking-wide); color: var(--text-faint);
border-bottom: 2px solid transparent; background: none; border: none; border-bottom: 2px solid transparent;
border-radius: 0; cursor: pointer; white-space: nowrap; cursor: pointer; white-space: nowrap; margin-bottom: -1px;
transition: color var(--t-base), border-color var(--t-base); transition: color var(--t-base), border-color var(--t-base);
margin-bottom: -1px;
} }
.tracker-tab:hover { color: var(--text-muted); } .tracker-tab:hover { color: var(--text-muted); }
.tab-active { color: var(--text-secondary) !important; border-bottom-color: var(--accent); } .tab-active { color: var(--text-secondary) !important; border-bottom-color: var(--accent); }
.tab-tracker-icon { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; opacity: 0.85; } :global(.tab-tracker-icon) { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; opacity: 0.85; }
.tab-count { font-size: 10px; padding: 0 4px; border-radius: var(--radius-full); background: var(--bg-overlay); color: var(--text-faint); min-width: 16px; text-align: center; line-height: 16px; } .tab-count {
font-size: 10px; padding: 0 4px; border-radius: var(--radius-full);
background: var(--bg-overlay); color: var(--text-faint);
min-width: 16px; text-align: center; line-height: 16px;
}
.tab-active .tab-count { background: var(--accent-muted); color: var(--accent-fg); } .tab-active .tab-count { background: var(--accent-muted); color: var(--accent-fg); }
/* ── Filter bar ─────────────────────────────────────────────────────────── */ .filter-bar {
.filter-bar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-2) var(--sp-5); border-top: 1px solid var(--border-dim); } display: flex; align-items: center; gap: var(--sp-3);
.search-wrap { display: flex; align-items: center; gap: var(--sp-2); flex: 1; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 4px 10px; } padding: var(--sp-2) var(--sp-5);
border-top: 1px solid var(--border-dim);
}
.search-wrap {
display: flex; align-items: center; gap: var(--sp-2); flex: 1;
background: var(--bg-raised); border: 1px solid var(--border-dim);
border-radius: var(--radius-md); padding: 4px 10px;
}
:global(.search-ico) { color: var(--text-faint); flex-shrink: 0; } :global(.search-ico) { color: var(--text-faint); flex-shrink: 0; }
.filter-search { flex: 1; background: none; border: none; outline: none; font-size: var(--text-sm); color: var(--text-primary); min-width: 0; } .filter-search {
flex: 1; background: none; border: none; outline: none;
font-size: var(--text-sm); color: var(--text-primary); min-width: 0;
}
.filter-search::placeholder { color: var(--text-faint); } .filter-search::placeholder { color: var(--text-faint); }
.filter-right { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; } .filter-right { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
.filter-select { .filter-select {
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); font-family: var(--font-ui); font-size: var(--text-2xs);
padding: 4px 24px 4px 8px; border-radius: var(--radius-sm); letter-spacing: var(--tracking-wide); padding: 4px 22px 4px 8px;
border: 1px solid var(--border-dim); background: var(--bg-raised); border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
color: var(--text-faint); outline: none; cursor: pointer; background: var(--bg-raised); color: var(--text-faint);
appearance: none; -webkit-appearance: none; outline: none; cursor: pointer; appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23555' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23555' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 7px center; background-repeat: no-repeat; background-position: right 6px center;
transition: border-color var(--t-base), color var(--t-base); transition: border-color var(--t-base), color var(--t-base);
} }
.filter-select:hover { border-color: var(--border-strong); color: var(--text-muted); } .filter-select:hover { border-color: var(--border-strong); color: var(--text-muted); }
.filter-select option { background: var(--bg-surface); color: var(--text-secondary); } .filter-select option { background: var(--bg-surface); color: var(--text-secondary); }
/* ── Body ───────────────────────────────────────────────────────────────── */ .page-body {
.page-body { flex: 1; overflow-y: auto; padding: var(--sp-3) var(--sp-5); scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; } flex: 1; overflow-y: auto; padding: var(--sp-5);
scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent;
}
/* ── States ─────────────────────────────────────────────────────────────── */ .state-center {
.state-center { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-3); height: 100%; padding: var(--sp-10); text-align: center; } display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: var(--sp-3); height: 100%;
padding: var(--sp-10); text-align: center;
}
.state-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .state-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.state-text { font-size: var(--text-sm); color: var(--text-muted); } .state-text { font-size: var(--text-sm); color: var(--text-muted); }
.state-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .state-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.state-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); letter-spacing: var(--tracking-wide); } .state-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); letter-spacing: var(--tracking-wide); }
.retry-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 5px 14px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); } .retry-btn {
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 5px 14px; border-radius: var(--radius-md);
border: 1px solid var(--border-dim); background: none;
color: var(--text-faint); cursor: pointer;
transition: color var(--t-base), border-color var(--t-base);
}
.retry-btn:hover { color: var(--accent-fg); border-color: var(--accent-dim); } .retry-btn:hover { color: var(--accent-fg); border-color: var(--accent-dim); }
/* ── Records list ───────────────────────────────────────────────────────── */ .records-grid {
.records-list { display: flex; flex-direction: column; gap: 2px; } display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: var(--sp-4);
align-content: start;
}
.record-card { .record-card {
display: flex; align-items: flex-start; gap: var(--sp-4); display: flex; flex-direction: column;
padding: var(--sp-3) var(--sp-3); border-radius: var(--radius-lg);
border-radius: var(--radius-md); border: 1px solid var(--border-dim);
background: none; background: var(--bg-raised);
transition: background var(--t-fast), opacity var(--t-base); overflow: hidden;
transition: border-color var(--t-base), opacity var(--t-base), transform var(--t-base);
} }
.record-card:hover { background: var(--bg-raised); } .record-card:hover {
.record-busy { opacity: 0.4; pointer-events: none; } border-color: var(--border-strong);
transform: translateY(-2px);
}
.record-busy { opacity: 0.35; pointer-events: none; }
/* Cover */ .card-cover-wrap {
.record-cover-wrap { position: relative; flex-shrink: 0; cursor: pointer; } position: relative;
.record-cover { width: 44px; height: 62px; object-fit: cover; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); display: block; transition: opacity var(--t-fast); } aspect-ratio: 2 / 3;
.record-cover-empty { background: var(--bg-overlay); } flex-shrink: 0;
.record-cover-wrap:hover .record-cover { opacity: 0.75; } overflow: hidden;
.record-tracker-badge { position: absolute; bottom: -3px; right: -3px; width: 14px; height: 14px; border-radius: 2px; border: 1px solid var(--bg-base); object-fit: contain; background: var(--bg-raised); } background: var(--bg-overlay);
}
/* Body */ .card-cover-region {
.record-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-2); } position: absolute; inset: 0;
.record-top { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-3); } cursor: pointer;
.record-titles { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; cursor: pointer; } }
.record-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: color var(--t-base); }
.record-titles:hover .record-title { color: var(--accent-fg); }
.record-local-title { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.record-header-actions { display: flex; align-items: center; gap: 1px; flex-shrink: 0; }
.record-tracker-label { display: flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: 2px 6px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-overlay); margin-right: var(--sp-1); }
.record-tracker-label-icon { width: 11px; height: 11px; border-radius: 2px; object-fit: contain; }
.card-icon-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; text-decoration: none; transition: color var(--t-base), background var(--t-base); }
.card-icon-btn:hover { color: var(--text-muted); background: var(--bg-overlay); }
.card-icon-btn.danger:hover { color: var(--color-error); background: var(--color-error-bg); }
.card-icon-btn:disabled { opacity: 0.3; cursor: default; }
/* Controls */ :global(.card-cover-img) {
.record-controls { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; } width: 100%; height: 100%;
.record-select { object-fit: cover; display: block;
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); transition: transform 0.35s ease, opacity 0.2s ease;
padding: 3px 22px 3px 7px; border-radius: var(--radius-sm); }
border: 1px solid transparent; background: var(--bg-overlay); .card-cover-wrap:hover :global(.card-cover-img) {
color: var(--text-faint); outline: none; cursor: pointer; transform: scale(1.04);
appearance: none; -webkit-appearance: none; opacity: 0.88;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23555' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); }
.card-cover-empty { width: 100%; height: 100%; background: var(--bg-overlay); }
.card-stars {
display: flex; gap: 3px; align-items: center;
padding-bottom: 2px;
}
.star {
font-size: 15px; line-height: 1;
color: var(--border-strong);
transition: color var(--t-base);
}
.star-filled { color: #f5c518; }
.card-top-actions {
position: absolute; top: 6px; right: 6px; z-index: 2;
display: flex; gap: 2px;
opacity: 0;
transition: opacity var(--t-base);
}
.card-cover-wrap:hover .card-top-actions { opacity: 1; }
.card-badge-btn {
display: flex; align-items: center; justify-content: center;
width: 24px; height: 24px; border-radius: var(--radius-sm);
background: rgba(0,0,0,0.6); backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,0.1);
color: rgba(255,255,255,0.75); cursor: pointer;
text-decoration: none;
transition: background var(--t-base), color var(--t-base);
}
.card-badge-btn:hover { background: rgba(0,0,0,0.75); color: #fff; }
.card-badge-btn.danger:hover { background: rgba(180,40,40,0.7); color: #fff; }
.card-badge-btn:disabled { opacity: 0.3; cursor: default; }
.card-tracker-badge {
position: absolute; bottom: 9px; right: 9px; z-index: 2;
width: 22px; height: 22px;
border-radius: 6px;
border: 1px solid rgba(0,0,0,0.35);
background: var(--bg-raised);
box-shadow: 0 2px 8px rgba(0,0,0,0.55);
overflow: hidden;
display: flex; align-items: center; justify-content: center;
}
:global(.tracker-badge-img) {
width: 100%; height: 100%;
object-fit: contain; display: block;
}
/* ── Footer panel ───────────────────────────────────────────────────────── */
.card-footer {
display: flex; flex-direction: column; gap: 10px;
padding: 13px 13px 13px;
border-top: 1px solid var(--border-dim);
}
/* Title */
.card-title-block {
display: flex; flex-direction: column; gap: 3px;
cursor: pointer; min-width: 0;
}
.card-title {
font-size: var(--text-sm); font-weight: var(--weight-medium);
color: var(--text-secondary); line-height: 1.38;
display: -webkit-box; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; overflow: hidden;
transition: color var(--t-base);
}
.card-title-block:hover .card-title { color: var(--accent-fg); }
.card-local-title {
font-family: var(--font-ui); font-size: 11px; color: var(--text-faint);
letter-spacing: var(--tracking-wide);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.card-meta-row {
display: flex; align-items: center; gap: var(--sp-1);
}
.status-pill {
flex: 1; min-width: 0;
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
padding: 5px 20px 5px 9px;
border-radius: 999px;
border: 1px solid var(--border-dim);
background: var(--bg-overlay);
color: var(--text-muted);
outline: none; cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5'%3E%3Cpath d='M1 1l3 3 3-3' stroke='%23555' stroke-width='1.3' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 6px center; background-repeat: no-repeat; background-position: right 6px center;
transition: border-color var(--t-base), color var(--t-base), background var(--t-base); transition: border-color var(--t-base), color var(--t-base);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
} }
.record-select:hover:not(:disabled) { border-color: var(--border-dim); color: var(--text-secondary); background: var(--bg-raised); } .status-pill:hover:not(:disabled) { border-color: var(--border-strong); color: var(--text-secondary); }
.record-select:disabled { opacity: 0.35; cursor: default; } .status-pill:disabled { opacity: 0.35; cursor: default; }
.record-select option { background: var(--bg-surface); color: var(--text-secondary); } .status-pill option { background: var(--bg-surface); color: var(--text-secondary); }
.record-select-score { max-width: 86px; }
.private-badge { display: flex; align-items: center; color: var(--text-faint); padding: 2px 4px; }
/* Progress */ .score-select {
.record-progress { display: flex; align-items: center; gap: var(--sp-3); } flex-shrink: 0; width: 58px;
.progress-track { flex: 1; height: 2px; background: var(--border-strong); border-radius: var(--radius-full); overflow: hidden; max-width: 140px; } font-family: var(--font-ui); font-size: var(--text-2xs);
.progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; } letter-spacing: var(--tracking-wide);
.progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; } padding: 5px 16px 5px 6px;
.record-progress.clickable { cursor: pointer; border-radius: var(--radius-sm); padding: 2px 4px; margin: -2px -4px; transition: background var(--t-fast); } border-radius: var(--radius-sm);
.record-progress.clickable:hover { background: var(--bg-overlay); } border: 1px solid var(--border-dim);
.record-progress.clickable:hover .progress-label { color: var(--text-muted); } background: var(--bg-overlay);
.progress-edit-hint { font-size: 10px; color: var(--text-faint); opacity: 0; transition: opacity var(--t-fast); } color: var(--text-faint);
.record-progress.clickable:hover .progress-edit-hint { opacity: 1; } outline: none; cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5'%3E%3Cpath d='M1 1l3 3 3-3' stroke='%23555' stroke-width='1.3' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 4px center;
transition: border-color var(--t-base), color var(--t-base);
}
.score-select:hover:not(:disabled) { border-color: var(--border-strong); color: var(--text-secondary); }
.score-select:disabled { opacity: 0.35; cursor: default; }
.score-select option { background: var(--bg-surface); color: var(--text-secondary); }
/* Chapter editor */ .progress-block {
.chapter-editor { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3); border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); } display: flex; flex-direction: column; gap: 7px;
.chapter-editor-top { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); } }
.chapter-editor-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .progress-block.clickable {
.chapter-input-wrap { display: flex; align-items: center; gap: var(--sp-2); } cursor: pointer; border-radius: var(--radius-sm);
.chapter-input { width: 72px; background: var(--bg-surface); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 4px 8px; font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-primary); outline: none; text-align: center; appearance: none; -webkit-appearance: none; -moz-appearance: textfield; } padding: 4px 5px;
margin: 0 -5px;
transition: background var(--t-fast);
}
.progress-block.clickable:hover { background: var(--bg-overlay); }
.progress-labels {
display: flex; align-items: center; justify-content: space-between;
}
.progress-text {
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--text-muted); letter-spacing: var(--tracking-wide);
}
.progress-pct {
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--text-faint); letter-spacing: var(--tracking-wide);
}
.progress-track {
height: 3px; background: var(--border-strong);
border-radius: var(--radius-full); overflow: hidden;
}
.progress-fill {
height: 100%; background: var(--accent);
border-radius: var(--radius-full); transition: width 0.3s ease;
}
.chapter-editor {
display: flex; flex-direction: column; gap: var(--sp-2);
padding: var(--sp-2); border-radius: var(--radius-md);
border: 1px solid var(--border-dim); background: var(--bg-surface);
}
.chapter-editor-top { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); }
.chapter-editor-label { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.chapter-input-wrap { display: flex; align-items: center; gap: var(--sp-1); }
.chapter-input {
width: 58px; background: var(--bg-surface);
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
padding: 3px 6px; font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-primary); outline: none; text-align: center;
appearance: none; -moz-appearance: textfield;
}
.chapter-input:focus { border-color: var(--accent); } .chapter-input:focus { border-color: var(--accent); }
.chapter-input::-webkit-outer-spin-button, .chapter-input::-webkit-outer-spin-button,
.chapter-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } .chapter-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.chapter-total { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .chapter-total { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.chapter-slider { width: 100%; accent-color: var(--accent); cursor: pointer; height: 3px; } .chapter-slider { width: 100%; accent-color: var(--accent); cursor: pointer; height: 3px; }
.chapter-editor-actions { display: flex; align-items: center; gap: var(--sp-2); justify-content: flex-end; } .chapter-editor-actions { display: flex; align-items: center; gap: var(--sp-2); justify-content: flex-end; }
.chapter-save-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 12px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base); } .chapter-save-btn {
font-family: var(--font-ui); font-size: 10px; letter-spacing: var(--tracking-wide);
padding: 3px 10px; border-radius: var(--radius-sm);
border: 1px solid var(--accent-dim); background: var(--accent-muted);
color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base);
}
.chapter-save-btn:hover { filter: brightness(1.15); } .chapter-save-btn:hover { filter: brightness(1.15); }
.chapter-cancel-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 8px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base); } .chapter-cancel-btn {
font-family: var(--font-ui); font-size: 10px; letter-spacing: var(--tracking-wide);
padding: 3px 6px; border-radius: var(--radius-sm);
border: none; background: none; color: var(--text-faint);
cursor: pointer; transition: color var(--t-base);
}
.chapter-cancel-btn:hover { color: var(--text-muted); } .chapter-cancel-btn:hover { color: var(--text-muted); }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } } .modal-backdrop {
position: fixed; inset: 0; z-index: 200;
background: rgba(0,0,0,0.55);
backdrop-filter: blur(4px);
display: flex; align-items: center; justify-content: center;
animation: fadeIn 0.12s ease both;
}
.modal {
background: var(--bg-surface);
border: 1px solid var(--border-dim);
border-radius: var(--radius-xl, 14px);
padding: var(--sp-6, 24px);
width: 320px; max-width: calc(100vw - 32px);
display: flex; flex-direction: column; align-items: center; gap: var(--sp-3);
box-shadow: 0 24px 60px rgba(0,0,0,0.5);
animation: modalIn 0.18s cubic-bezier(0.34,1.56,0.64,1) both;
}
.modal-icon {
width: 40px; height: 40px; border-radius: 50%;
background: var(--color-error-bg, rgba(200,50,50,0.12));
border: 1px solid var(--color-error-dim, rgba(200,50,50,0.25));
color: var(--color-error, #e05252);
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.modal-title {
font-size: var(--text-sm); font-weight: var(--weight-medium);
color: var(--text-primary); text-align: center; margin: 0;
}
.modal-body {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-muted); text-align: center; line-height: 1.5;
margin: 0;
}
.modal-body strong { color: var(--text-secondary); font-weight: var(--weight-medium); }
.modal-actions {
display: flex; gap: var(--sp-2); width: 100%; margin-top: var(--sp-1);
}
.modal-cancel {
flex: 1;
font-family: var(--font-ui); font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
padding: 8px 0; border-radius: var(--radius-md);
border: 1px solid var(--border-dim); background: none;
color: var(--text-muted); cursor: pointer;
transition: border-color var(--t-base), color var(--t-base);
}
.modal-cancel:hover { border-color: var(--border-strong); color: var(--text-primary); }
.modal-confirm {
flex: 1;
font-family: var(--font-ui); font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
padding: 8px 0; border-radius: var(--radius-md);
border: 1px solid var(--color-error-dim, rgba(200,50,50,0.3));
background: var(--color-error-bg, rgba(200,50,50,0.1));
color: var(--color-error, #e05252); cursor: pointer;
transition: filter var(--t-base), background var(--t-base);
}
.modal-confirm:hover { filter: brightness(1.2); background: var(--color-error-bg, rgba(200,50,50,0.18)); }
@keyframes modalIn {
from { opacity: 0; transform: scale(0.92) translateY(8px); }
to { opacity: 1; transform: none; }
}
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
</style> </style>
<script module> <script module>
+13 -2
View File
@@ -7,6 +7,8 @@
Bookmark, BookOpen, MonitorPlay, MapPin, Check, Bookmark, BookOpen, MonitorPlay, MapPin, Check,
} from "phosphor-svelte"; } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql, thumbUrl } from "../../lib/client";
import { fetchAuthenticated } from "../../lib/auth";
import { store as appStore } from "../../store/state.svelte";
import { FETCH_CHAPTER_PAGES, MARK_CHAPTER_READ, ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries"; import { FETCH_CHAPTER_PAGES, MARK_CHAPTER_READ, ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries";
import { store, closeReader, openReader, addHistory, updateSettings, checkAndMarkCompleted, setSettingsOpen, addBookmark, removeBookmark, addMarker, removeMarker, updateMarker } from "../../store/state.svelte"; import { store, closeReader, openReader, addHistory, updateSettings, checkAndMarkCompleted, setSettingsOpen, addBookmark, removeBookmark, addMarker, removeMarker, updateMarker } from "../../store/state.svelte";
import { matchesKeybind, toggleFullscreen, DEFAULT_KEYBINDS } from "../../lib/keybinds"; import { matchesKeybind, toggleFullscreen, DEFAULT_KEYBINDS } from "../../lib/keybinds";
@@ -40,8 +42,17 @@
if (signal?.aborted) return Promise.reject(new DOMException("Aborted", "AbortError")); if (signal?.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
if (!inflight.has(chapterId)) { if (!inflight.has(chapterId)) {
const p = gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId }) const p = gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId })
.then(d => { .then(async d => {
const urls = d.fetchChapterPages.pages.map(thumbUrl); const rawUrls = d.fetchChapterPages.pages.map(thumbUrl);
const mode = appStore.settings.serverAuthMode ?? "NONE";
const urls = mode === "BASIC_AUTH"
? await Promise.all(rawUrls.map(u =>
fetchAuthenticated(u, { method: "GET" })
.then(r => r.blob())
.then(b => URL.createObjectURL(b))
.catch(() => u)
))
: rawUrls;
pageCache.set(chapterId, urls); pageCache.set(chapterId, urls);
return urls; return urls;
}) })
+1 -1
View File
@@ -147,7 +147,7 @@
<svelte:window onkeydown={onKey} /> <svelte:window onkeydown={onKey} />
<div class="te-backdrop" tabindex="-1" onclick={onClose} onkeydown={(e) => e.key === "Escape" && onClose()}> <div class="te-backdrop" role="presentation" onclick={onClose} onkeydown={(e) => e.key === "Escape" && onClose()}>
<div <div
class="te-shell" class="te-shell"
role="dialog" role="dialog"
+6 -5
View File
@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from "svelte"; import { onMount, onDestroy } from "svelte";
import { X, BookmarkSimple, ArrowSquareOut, Play, CircleNotch, Books, CaretDown, FolderSimplePlus, Folder, LinkSimpleHorizontalBreak } from "phosphor-svelte"; import { X, BookmarkSimple, ArrowSquareOut, Play, CircleNotch, Books, CaretDown, FolderSimplePlus, Folder, LinkSimpleHorizontalBreak } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql } from "../../lib/client";
import Thumbnail from "../shared/Thumbnail.svelte";
import { GET_MANGA, GET_CHAPTERS, FETCH_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, ENQUEUE_CHAPTERS_DOWNLOAD, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries"; import { GET_MANGA, GET_CHAPTERS, FETCH_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, ENQUEUE_CHAPTERS_DOWNLOAD, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
import { GET_ALL_MANGA } from "../../lib/queries"; import { GET_ALL_MANGA } from "../../lib/queries";
import { cache, CACHE_KEYS } from "../../lib/cache"; import { cache, CACHE_KEYS } from "../../lib/cache";
@@ -248,7 +249,7 @@
<div class="cover-col"> <div class="cover-col">
<div class="cover-wrap"> <div class="cover-wrap">
<img src={thumbUrl(store.previewManga.thumbnailUrl)} alt={displayManga?.title} class="cover" /> <Thumbnail src={store.previewManga.thumbnailUrl} alt={displayManga?.title} class="cover" />
{#if loadingDetail} {#if loadingDetail}
<div class="cover-spinner"><CircleNotch size={18} weight="light" class="anim-spin" /></div> <div class="cover-spinner"><CircleNotch size={18} weight="light" class="anim-spin" /></div>
{/if} {/if}
@@ -437,7 +438,7 @@
{#each linkPickerResults as m (m.id)} {#each linkPickerResults as m (m.id)}
{@const isLinked = linkedIds.includes(m.id)} {@const isLinked = linkedIds.includes(m.id)}
<button class="link-row" class:link-row-linked={isLinked} onclick={() => handleLink(m)}> <button class="link-row" class:link-row-linked={isLinked} onclick={() => handleLink(m)}>
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="link-thumb" loading="lazy" decoding="async" /> <Thumbnail src={m.thumbnailUrl} alt={m.title} class="link-thumb" />
<div class="link-info"> <div class="link-info">
<span class="link-manga-title">{m.title}</span> <span class="link-manga-title">{m.title}</span>
{#if m.source?.displayName}<span class="link-source">{m.source.displayName}</span>{/if} {#if m.source?.displayName}<span class="link-source">{m.source.displayName}</span>{/if}
@@ -462,7 +463,7 @@
.modal { width: min(800px, calc(100vw - 48px)); height: min(560px, calc(100vh - 80px)); display: flex; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; animation: scaleIn 0.16s ease both; box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6); } .modal { width: min(800px, calc(100vw - 48px)); height: min(560px, calc(100vh - 80px)); display: flex; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; animation: scaleIn 0.16s ease both; box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6); }
.cover-col { width: 190px; flex-shrink: 0; background: var(--bg-raised); border-right: 1px solid var(--border-dim); display: flex; flex-direction: column; padding: var(--sp-5) var(--sp-4) var(--sp-4); gap: var(--sp-3); overflow: hidden; } .cover-col { width: 190px; flex-shrink: 0; background: var(--bg-raised); border-right: 1px solid var(--border-dim); display: flex; flex-direction: column; padding: var(--sp-5) var(--sp-4) var(--sp-4); gap: var(--sp-3); overflow: hidden; }
.cover-wrap { position: relative; width: 100%; } .cover-wrap { position: relative; width: 100%; }
.cover { width: 100%; aspect-ratio: 2/3; object-fit: cover; border-radius: var(--radius-md); border: 1px solid var(--border-dim); display: block; } :global(.cover) { width: 100%; aspect-ratio: 2/3; object-fit: cover; border-radius: var(--radius-md); border: 1px solid var(--border-dim); display: block; }
.cover-spinner { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.35); border-radius: var(--radius-md); color: var(--text-faint); } .cover-spinner { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.35); border-radius: var(--radius-md); color: var(--text-faint); }
.cover-actions { display: flex; flex-direction: column; gap: var(--sp-2); } .cover-actions { display: flex; flex-direction: column; gap: var(--sp-2); }
.action-btn { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); border: 1px solid var(--border-strong); background: none; color: var(--text-muted); cursor: pointer; text-align: left; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); } .action-btn { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); border: 1px solid var(--border-strong); background: none; color: var(--text-muted); cursor: pointer; text-align: left; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
@@ -548,7 +549,7 @@
.link-row { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); } .link-row { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
.link-row:hover { background: var(--bg-raised); } .link-row:hover { background: var(--bg-raised); }
.link-row-linked { background: var(--accent-muted) !important; } .link-row-linked { background: var(--accent-muted) !important; }
.link-thumb { width: 34px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); } :global(.link-thumb) { width: 34px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
.link-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; } .link-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.link-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .link-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.link-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .link-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
+5 -4
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { ArrowLeft, MagnifyingGlass, ArrowLeft as Prev, ArrowRight as Next, BookmarkSimple, FolderSimplePlus, Folder } from "phosphor-svelte"; import { ArrowLeft, MagnifyingGlass, ArrowLeft as Prev, ArrowRight as Next, BookmarkSimple, FolderSimplePlus, Folder } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql } from "../../lib/client";
import Thumbnail from "../shared/Thumbnail.svelte";
import { FETCH_SOURCE_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries"; import { FETCH_SOURCE_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
import { store, setActiveSource, setActiveManga, setNavPage } from "../../store/state.svelte"; import { store, setActiveSource, setActiveManga, setNavPage } from "../../store/state.svelte";
import type { Manga, Category } from "../../lib/types"; import type { Manga, Category } from "../../lib/types";
@@ -120,7 +121,7 @@
<button class="card" onclick={() => { setActiveManga(m); setNavPage("library"); }} <button class="card" onclick={() => { setActiveManga(m); setNavPage("library"); }}
oncontextmenu={(e) => openCtx(e, m)}> oncontextmenu={(e) => openCtx(e, m)}>
<div class="cover-wrap"> <div class="cover-wrap">
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" /> <Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
{#if m.inLibrary}<span class="in-library-badge">In Library</span>{/if} {#if m.inLibrary}<span class="in-library-badge">In Library</span>{/if}
</div> </div>
<p class="title">{m.title}</p> <p class="title">{m.title}</p>
@@ -165,10 +166,10 @@
.search:focus { border-color: var(--border-strong); } .search:focus { border-color: var(--border-strong); }
.grid, .loading-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(100px,14vw,140px),1fr)); gap: var(--sp-4); padding: var(--sp-5) var(--sp-6); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; -webkit-overflow-scrolling: touch; contain: layout style; } .grid, .loading-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(100px,14vw,140px),1fr)); gap: var(--sp-4); padding: var(--sp-5) var(--sp-6); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; -webkit-overflow-scrolling: touch; contain: layout style; }
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; } .card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
.card:hover .cover { filter: brightness(1.06); } .card:hover :global(.cover) { filter: brightness(1.06); }
.card:hover .title { color: var(--text-primary); } .card:hover .title { color: var(--text-primary); }
.cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); } .cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); 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); will-change: filter; } :global(.cover) { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); will-change: filter; }
.in-library-badge { position: absolute; bottom: var(--sp-1); left: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 2px 5px; border-radius: var(--radius-sm); } .in-library-badge { position: absolute; bottom: var(--sp-1); left: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 2px 5px; border-radius: var(--radius-sm); }
.title { margin-top: var(--sp-2); 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); } .title { margin-top: var(--sp-2); 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); }
.card-skeleton { padding: 0; } .card-skeleton { padding: 0; }
+3 -3
View File
@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onMount } from "svelte"; import { onMount } from "svelte";
import { MagnifyingGlass, CircleNotch, CaretDown, CaretRight } from "phosphor-svelte"; import { MagnifyingGlass, CircleNotch, CaretDown, CaretRight } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql } from "../../lib/client";
import Thumbnail from "../shared/Thumbnail.svelte";
import { GET_SOURCES } from "../../lib/queries"; import { GET_SOURCES } from "../../lib/queries";
import { store } from "../../store/state.svelte"; import { store } from "../../store/state.svelte";
import type { Source } from "../../lib/types"; import type { Source } from "../../lib/types";
@@ -74,8 +75,7 @@
{@const open = expanded.has(g.name)} {@const open = expanded.has(g.name)}
<div> <div>
<button class="row" onclick={() => single ? store.activeSource = g.sources[0] : toggleGroup(g.name)}> <button class="row" onclick={() => single ? store.activeSource = g.sources[0] : toggleGroup(g.name)}>
<img src={thumbUrl(g.icon)} alt={g.name} class="icon" <Thumbnail src={g.icon} alt={g.name} class="icon" onerror={(e) => ((e.target as HTMLImageElement).style.display = "none")} />
onerror={(e) => ((e.target as HTMLImageElement).style.display = "none")} />
<div class="info"> <div class="info">
<span class="name">{g.name}</span> <span class="name">{g.name}</span>
<span class="meta">{single ? `${g.sources[0].lang.toUpperCase()}${g.sources[0].isNsfw ? " · NSFW" : ""}` : `${g.sources.length} languages`}</span> <span class="meta">{single ? `${g.sources[0].lang.toUpperCase()}${g.sources[0].isNsfw ? " · NSFW" : ""}` : `${g.sources.length} languages`}</span>
+61
View File
@@ -0,0 +1,61 @@
<script lang="ts">
import { onDestroy } from "svelte";
import { thumbUrl } from "../../lib/client";
import { fetchAuthenticated } from "../../lib/auth";
import { store } from "../../store/state.svelte";
let {
src,
alt = "",
class: className = "",
loading = "lazy",
decoding = "async",
onerror = undefined,
...rest
}: {
src: string;
alt?: string;
class?: string;
loading?: string;
decoding?: string;
onerror?: ((e: Event) => void) | undefined;
[key: string]: any;
} = $props();
const blobCache = new Map<string, string>();
let resolved = $state("");
let current = "";
$effect(() => {
const path = src;
const mode = store.settings.serverAuthMode ?? "NONE";
if (path === current) return;
current = path;
if (!path) { resolved = ""; return; }
if (mode !== "BASIC_AUTH") {
resolved = thumbUrl(path);
return;
}
if (blobCache.has(path)) {
resolved = blobCache.get(path)!;
return;
}
resolved = "";
fetchAuthenticated(thumbUrl(path), { method: "GET" })
.then(r => r.blob())
.then(blob => {
const url = URL.createObjectURL(blob);
blobCache.set(path, url);
if (current === path) resolved = url;
})
.catch(() => {});
});
</script>
<img src={resolved} {alt} class={className} {loading} {decoding} {onerror} {...rest} />
+94 -105
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { CircleNotch, X, MagnifyingGlass, ArrowSquareOut, Lock, LockOpen, ArrowsClockwise } from "phosphor-svelte"; import { CircleNotch, X, MagnifyingGlass, ArrowSquareOut, Lock, LockOpen, ArrowsClockwise } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql } from "../../lib/client";
import Thumbnail from "../shared/Thumbnail.svelte";
import { import {
GET_TRACKERS, GET_TRACKERS,
GET_MANGA_TRACK_RECORDS, GET_MANGA_TRACK_RECORDS,
@@ -260,7 +261,7 @@
class:tab-active={activeTab === t.id} class:tab-active={activeTab === t.id}
onclick={() => { activeTab = t.id; searchResults = []; }} onclick={() => { activeTab = t.id; searchResults = []; }}
> >
<img src={thumbUrl(t.icon)} alt={t.name} class="tab-icon" /> <Thumbnail src={t.icon} alt={t.name} class="tab-icon" />
{t.name} {t.name}
{#if rec}<span class="tab-dot"></span>{/if} {#if rec}<span class="tab-dot"></span>{/if}
</button> </button>
@@ -278,25 +279,50 @@
{#each records as record (record.id)} {#each records as record (record.id)}
{@const tracker = trackerFor(record.trackerId)} {@const tracker = trackerFor(record.trackerId)}
{@const isBusy = updatingRecord === record.id} {@const isBusy = updatingRecord === record.id}
<div class="record-row" class:record-busy={isBusy}> <div class="record-card" class:record-busy={isBusy}>
<div class="record-identity"> <!-- Title row -->
<div class="record-head">
<div class="record-source">
{#if tracker} {#if tracker}
<img src={thumbUrl(tracker.icon)} alt={tracker.name} class="record-tracker-icon" /> <Thumbnail src={tracker.icon} alt={tracker.name} class="record-tracker-icon" />
{/if} {/if}
<span class="record-source-name">{tracker?.name ?? "Tracker"}</span>
</div>
<div class="record-head-actions">
{#if tracker?.supportsPrivateTracking}
<button
class="record-icon-btn"
class:icon-active={record.private}
title={record.private ? "Private — click to make public" : "Public"}
disabled={isBusy}
onclick={() => togglePrivate(record)}
>
{#if record.private}<Lock size={11} weight="fill" />{:else}<LockOpen size={11} weight="light" />{/if}
</button>
{/if}
<button class="record-icon-btn" title="Sync from tracker" disabled={syncing === record.id} onclick={() => syncRecord(record)}>
<ArrowsClockwise size={11} weight="light" class={syncing === record.id ? "anim-spin" : ""} />
</button>
<button class="record-icon-btn icon-danger" title="Unlink" disabled={isBusy} onclick={() => unbind(record)}>
<X size={11} weight="bold" />
</button>
</div>
</div>
<!-- Linked title -->
{#if record.remoteUrl} {#if record.remoteUrl}
<a href={record.remoteUrl} target="_blank" rel="noreferrer" class="record-title"> <a href={record.remoteUrl} target="_blank" rel="noreferrer" class="record-title">
{record.title} {record.title} <ArrowSquareOut size={10} weight="light" />
<ArrowSquareOut size={10} weight="light" />
</a> </a>
{:else} {:else}
<span class="record-title-plain">{record.title}</span> <span class="record-title-plain">{record.title}</span>
{/if} {/if}
</div>
<div class="record-controls"> <!-- Status + score row -->
<div class="record-selects">
<select <select
class="record-select" class="record-select record-select-status"
value={record.status} value={record.status}
disabled={isBusy} disabled={isBusy}
onchange={(e) => updateStatus(record, parseInt((e.target as HTMLSelectElement).value))} onchange={(e) => updateStatus(record, parseInt((e.target as HTMLSelectElement).value))}
@@ -305,7 +331,6 @@
<option value={s.value}>{s.name}</option> <option value={s.value}>{s.name}</option>
{/each} {/each}
</select> </select>
<select <select
class="record-select record-select-score" class="record-select record-select-score"
value={record.displayScore} value={record.displayScore}
@@ -316,58 +341,19 @@
<option value={s}> {s}</option> <option value={s}> {s}</option>
{/each} {/each}
</select> </select>
{#if tracker?.supportsPrivateTracking}
<button
class="record-icon-btn"
class:icon-active={record.private}
title={record.private ? "Private — click to make public" : "Public — click to make private"}
disabled={isBusy}
onclick={() => togglePrivate(record)}
>
{#if record.private}
<Lock size={12} weight="fill" />
{:else}
<LockOpen size={12} weight="light" />
{/if}
</button>
{/if}
<button
class="record-icon-btn"
title="Sync from tracker"
disabled={syncing === record.id}
onclick={() => syncRecord(record)}
>
<ArrowsClockwise size={12} weight="light" class={syncing === record.id ? "anim-spin" : ""} />
</button>
<button
class="record-icon-btn icon-danger"
title="Unlink"
disabled={isBusy}
onclick={() => unbind(record)}
>
<X size={12} weight="bold" />
</button>
</div> </div>
<!-- Chapter progress -->
{#if editingChapter === record.id} {#if editingChapter === record.id}
<div class="chapter-editor"> <div class="chapter-editor">
<div class="chapter-editor-top"> <div class="chapter-editor-top">
<span class="chapter-editor-label">Chapter read</span> <span class="chapter-editor-label">Chapter read</span>
<div class="chapter-input-wrap"> <div class="chapter-input-wrap">
<input <input
type="number" type="number" class="chapter-input"
class="chapter-input" min="0" max={record.totalChapters > 0 ? record.totalChapters : undefined}
min="0" step="0.5" bind:value={chapterDraft}
max={record.totalChapters > 0 ? record.totalChapters : undefined} onkeydown={(e) => { if (e.key === "Enter") submitChapter(record); if (e.key === "Escape") cancelChapterEditor(); }}
step="0.5"
bind:value={chapterDraft}
onkeydown={(e) => {
if (e.key === "Enter") submitChapter(record);
if (e.key === "Escape") cancelChapterEditor();
}}
use:autoFocus use:autoFocus
/> />
{#if record.totalChapters > 0} {#if record.totalChapters > 0}
@@ -376,40 +362,36 @@
</div> </div>
</div> </div>
{#if record.totalChapters > 0} {#if record.totalChapters > 0}
<input <input type="range" class="chapter-slider" min="0" max={record.totalChapters} step="1" bind:value={chapterDraft} />
type="range"
class="chapter-slider"
min="0"
max={record.totalChapters}
step="1"
bind:value={chapterDraft}
/>
{/if} {/if}
<div class="chapter-editor-actions"> <div class="chapter-editor-actions">
<button class="chapter-save-btn" onclick={() => submitChapter(record)}>Save</button>
<button class="chapter-cancel-btn" onclick={cancelChapterEditor}>Cancel</button> <button class="chapter-cancel-btn" onclick={cancelChapterEditor}>Cancel</button>
</div> <button class="chapter-save-btn" onclick={() => submitChapter(record)}>Save</button>
</div>
{:else if record.totalChapters > 0}
<div class="record-progress clickable" role="button" tabindex="0"
onclick={() => openChapterEditor(record)}
onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)}
title="Click to edit"
>
<span class="record-progress-label">Ch. {record.lastChapterRead} / {record.totalChapters} <span class="edit-hint"></span></span>
<div class="record-progress-track">
<div class="record-progress-fill" style="width:{Math.min(100,(record.lastChapterRead/record.totalChapters)*100)}%"></div>
</div> </div>
</div> </div>
{:else} {:else}
<div class="record-progress clickable" role="button" tabindex="0" <div class="record-progress clickable" role="button" tabindex="0"
onclick={() => openChapterEditor(record)} onclick={() => openChapterEditor(record)}
onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)} onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)}
title="Click to set chapter" title="Click to edit"
> >
<div class="record-progress-header">
<span class="record-progress-label"> <span class="record-progress-label">
{record.lastChapterRead > 0 ? `Ch. ${record.lastChapterRead} read` : "Set chapter…"} <span class="edit-hint"></span> {#if record.totalChapters > 0}
Ch. {record.lastChapterRead} / {record.totalChapters}
{:else if record.lastChapterRead > 0}
Ch. {record.lastChapterRead} read
{:else}
Set chapter…
{/if}
</span> </span>
<span class="edit-hint">Edit</span>
</div>
{#if record.totalChapters > 0}
<div class="record-progress-track">
<div class="record-progress-fill" style="width:{Math.min(100,(record.lastChapterRead/record.totalChapters)*100)}%"></div>
</div>
{/if}
</div> </div>
{/if} {/if}
@@ -540,60 +522,67 @@
} }
.tab:hover { color: var(--text-muted); } .tab:hover { color: var(--text-muted); }
.tab-active { color: var(--text-secondary); border-bottom-color: var(--accent); } .tab-active { color: var(--text-secondary); border-bottom-color: var(--accent); }
.tab-icon { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; } :global(.tab-icon) { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; }
.tab-badge { font-size: 10px; padding: 0 4px; border-radius: var(--radius-full); background: var(--bg-overlay); color: var(--text-faint); min-width: 16px; text-align: center; line-height: 16px; } .tab-badge { font-size: 10px; padding: 0 4px; border-radius: var(--radius-full); background: var(--bg-overlay); color: var(--text-faint); min-width: 16px; text-align: center; line-height: 16px; }
.tab-active .tab-badge { background: var(--accent-muted); color: var(--accent-fg); } .tab-active .tab-badge { background: var(--accent-muted); color: var(--accent-fg); }
.tab-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--accent); flex-shrink: 0; } .tab-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--accent); flex-shrink: 0; }
/* Records */ /* Records */
.tab-body { flex: 1; overflow-y: auto; padding: var(--sp-3) var(--sp-4); scrollbar-width: none; display: flex; flex-direction: column; gap: 2px; } .tab-body { flex: 1; overflow-y: auto; padding: var(--sp-3); scrollbar-width: none; display: flex; flex-direction: column; gap: var(--sp-2); }
.tab-body::-webkit-scrollbar { display: none; } .tab-body::-webkit-scrollbar { display: none; }
.record-row { .record-card {
display: flex; flex-direction: column; gap: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4); padding: var(--sp-4);
border-radius: var(--radius-md); border-radius: var(--radius-lg);
border: 1px solid var(--border-dim);
background: var(--bg-raised); background: var(--bg-raised);
transition: opacity var(--t-base); transition: opacity var(--t-base), border-color var(--t-base);
} }
.record-row:hover { background: var(--bg-overlay); } .record-card:hover { border-color: var(--border-strong); }
.record-busy { opacity: 0.45; pointer-events: none; } .record-busy { opacity: 0.4; pointer-events: none; }
.record-identity { display: flex; align-items: center; gap: var(--sp-2); min-width: 0; } .record-head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); }
.record-tracker-icon { width: 14px; height: 14px; border-radius: 2px; flex-shrink: 0; object-fit: contain; opacity: 0.8; } .record-source { display: flex; align-items: center; gap: var(--sp-2); }
.record-title { display: inline-flex; align-items: center; gap: 3px; font-size: var(--text-sm); color: var(--accent-fg); text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; transition: opacity var(--t-base); } :global(.record-tracker-icon) { width: 14px; height: 14px; border-radius: 2px; flex-shrink: 0; object-fit: contain; opacity: 0.75; }
.record-title:hover { opacity: 0.75; } .record-source-name { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
.record-title-plain { font-size: var(--text-sm); color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; } .record-head-actions { display: flex; align-items: center; gap: 2px; }
.record-controls { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; } .record-title { display: inline-flex; align-items: center; gap: 4px; font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); text-decoration: none; line-height: var(--leading-snug); transition: color var(--t-base); }
.record-title:hover { color: var(--accent-fg); }
.record-title-plain { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); line-height: var(--leading-snug); }
.record-selects { display: flex; gap: var(--sp-2); flex-wrap: wrap; }
.record-select { .record-select {
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
padding: 3px 22px 3px 8px; border-radius: var(--radius-sm); padding: 5px 24px 5px 10px; border-radius: var(--radius-md);
border: 1px solid transparent; background: var(--bg-overlay); border: 1px solid var(--border-dim); background: var(--bg-surface);
color: var(--text-faint); outline: none; cursor: pointer; color: var(--text-muted); outline: none; cursor: pointer; flex: 1; min-width: 0;
appearance: none; -webkit-appearance: none; appearance: none; -webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23555' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23555' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 6px center; background-repeat: no-repeat; background-position: right 8px center;
transition: border-color var(--t-base), color var(--t-base), background var(--t-base); transition: border-color var(--t-base), color var(--t-base);
} }
.record-select:hover:not(:disabled) { border-color: var(--border-dim); color: var(--text-secondary); background: var(--bg-raised); } .record-select:hover:not(:disabled) { border-color: var(--border-strong); color: var(--text-secondary); }
.record-select:focus { border-color: var(--border-strong); outline: none; color: var(--text-secondary); } .record-select:focus { border-color: var(--accent-dim); color: var(--text-secondary); }
.record-select:disabled { opacity: 0.35; cursor: default; } .record-select:disabled { opacity: 0.35; cursor: default; }
.record-select option { background: var(--bg-surface); color: var(--text-secondary); } .record-select option { background: var(--bg-surface); color: var(--text-secondary); }
.record-select-score { max-width: 90px; } .record-select-score { flex: 0 0 auto; min-width: 80px; }
.record-select-status { flex: 1; }
.record-icon-btn { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; } .record-icon-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
.record-icon-btn:hover:not(:disabled) { color: var(--text-muted); background: var(--bg-overlay); } .record-icon-btn:hover:not(:disabled) { color: var(--text-muted); background: var(--bg-overlay); }
.record-icon-btn.icon-active { color: var(--accent-fg); } .record-icon-btn.icon-active { color: var(--accent-fg); }
.record-icon-btn.icon-danger:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); } .record-icon-btn.icon-danger:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); }
.record-icon-btn:disabled { opacity: 0.3; cursor: default; } .record-icon-btn:disabled { opacity: 0.3; cursor: default; }
.record-progress { display: flex; flex-direction: column; gap: 4px; } .record-progress { display: flex; flex-direction: column; gap: 6px; }
.record-progress.clickable { cursor: pointer; border-radius: var(--radius-sm); padding: 3px 4px; margin: -3px -4px; transition: background var(--t-fast); } .record-progress.clickable { cursor: pointer; border-radius: var(--radius-sm); padding: 4px 6px; margin: -4px -6px; transition: background var(--t-fast); }
.record-progress.clickable:hover { background: var(--bg-overlay); } .record-progress.clickable:hover { background: var(--bg-overlay); }
.record-progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); display: flex; align-items: center; gap: var(--sp-1); } .record-progress-header { display: flex; align-items: center; justify-content: space-between; }
.edit-hint { opacity: 0; font-size: 10px; transition: opacity var(--t-fast); color: var(--text-faint); } .record-progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.record-progress.clickable:hover .edit-hint { opacity: 1; } .edit-hint { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); opacity: 0; transition: opacity var(--t-fast); }
.record-progress.clickable:hover .edit-hint { opacity: 0.6; }
.record-progress-track { height: 2px; background: var(--border-strong); border-radius: var(--radius-full); overflow: hidden; } .record-progress-track { height: 2px; background: var(--border-strong); border-radius: var(--radius-full); overflow: hidden; }
.record-progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; } .record-progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
+1 -3
View File
@@ -80,9 +80,7 @@ export async function probeServer(): Promise<"ok" | "auth_required" | "unsupport
}); });
if (res.ok) { if (res.ok) {
if (mode === "SIMPLE_LOGIN" || mode === "UI_LOGIN") { if (mode !== "NONE") updateSettings({ serverAuthMode: "NONE" });
updateSettings({ serverAuthMode: "NONE" });
}
return "ok"; return "ok";
} }