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
+3 -3
View File
@@ -1,6 +1,6 @@
<script lang="ts">
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 type { HistoryEntry } from "../../store/state.svelte";
@@ -192,7 +192,7 @@
{#each items as session (session.latestChapterId)}
<button class="session-row" onclick={() => resume(session)}>
<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}
<span class="session-count">{session.chapterCount}</span>
{/if}
@@ -290,7 +290,7 @@
.session-row:hover .play-pill { opacity: 1; transform: translateX(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 {
position: absolute; bottom: -4px; right: -6px;
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}
<div class="toaster" aria-live="polite">
{#each store.toasts as t (t.id)}
<button
class="toast toast-{t.kind}"
role="alert"
onclick={() => dismissToast(t.id)}
>
<div role="alert" class="toast toast-{t.kind}" onclick={() => dismissToast(t.id)}>
<div class="accent-bar"></div>
<span class="icon">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none"
@@ -43,7 +39,7 @@
<p class="title">{t.title}</p>
{#if t.body}<p class="sub">{t.body}</p>{/if}
</div>
</button>
</div>
{/each}
</div>
{/if}
+11 -36
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { onDestroy } from "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 { cache, CACHE_KEYS } from "../../lib/cache";
import { dedupeSources, dedupeMangaByTitle, dedupeMangaById, shouldHideNsfw, shouldHideSource } from "../../lib/util";
@@ -10,13 +10,13 @@
import ContextMenu from "../shared/ContextMenu.svelte";
import type { MenuEntry } from "../shared/ContextMenu.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 GRID_LIMIT = 200;
const CONCURRENCY = 6;
const PAGES_INIT = 3; // pages per source on All tab
const PAGES_GENRE = 2; // pages per source on genre tabs
const PAGES_INIT = 3;
const PAGES_GENRE = 2;
const EXPLORE_ALL_MANGA = `
query ExploreAllManga {
@@ -37,7 +37,6 @@
return `${srcId}|${type}|${genre}:p${page}`;
}
// ── Local component state ─────────────────────────────────────────────────
let allSources: Source[] = $state([]);
let loadingLib = $state(true);
let loadError = $state(false);
@@ -54,7 +53,6 @@
const isLoading = $derived(genreLoading || refreshing || (currentGenre === "All" && loadingLib));
const visibleGrid = $derived(genreResults.get(currentGenre) ?? []);
// ── Helpers ───────────────────────────────────────────────────────────────
function dedup(items: Manga[]): Manga[] {
return dedupeMangaByTitle(dedupeMangaById(items), store.settings.mangaLinks);
}
@@ -87,7 +85,6 @@
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[]) {
const filtered = filterOut(incoming);
if (!filtered.length) return;
@@ -96,7 +93,6 @@
genreResults = new Map(genreResults);
}
// ── Source fan-out ────────────────────────────────────────────────────────
async function fanOut(genre: string, ctrl: AbortController) {
const srcs = rotatedSources();
if (!srcs.length) return;
@@ -115,7 +111,6 @@
let hasNextPage = false;
if (store.discoverCache.has(key)) {
// Cache hit — no network call needed
mangas = store.discoverCache.get(key)!;
} else {
const result = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
@@ -141,13 +136,11 @@
pushToGrid(genre, matching.length ? matching : mangas);
}
// Stop paging early if source is exhausted
if (!hasNextPage) return;
}
}, ctrl.signal);
}
// ── Tab switch ────────────────────────────────────────────────────────────
async function switchGenre(genre: string) {
if (currentGenre === genre) return;
@@ -158,7 +151,6 @@
activeCtrl = ctrl;
if (genre === "All") {
// Already have results from this session — show instantly, re-fan in background
if ((genreResults.get("All") ?? []).length > 0) {
genreLoading = false;
fanOut("All", ctrl).catch(() => {});
@@ -172,7 +164,6 @@
return;
}
// Genre tab: serve cached local results instantly, always fan out too
const localKey = `local|${genre}`;
if (store.discoverCache.has(localKey)) {
genreResults.set(genre, store.discoverCache.get(localKey)!);
@@ -188,9 +179,7 @@
);
if (ctrl.signal.aborted) return;
const local = dedup(
d.mangas.nodes.filter(m => !shouldHideNsfw(m, store.settings))
);
const local = dedup(d.mangas.nodes.filter(m => !shouldHideNsfw(m, store.settings)));
store.discoverCache.set(localKey, local);
genreResults.set(genre, local.slice(0, GRID_LIMIT));
genreResults = new Map(genreResults);
@@ -203,10 +192,9 @@
}
}
// ── Refresh ───────────────────────────────────────────────────────────────
async function refresh() {
activeCtrl?.abort();
clearDiscoverCache(); // wipes store.discoverCache + bumps discoverSrcOffset
clearDiscoverCache();
genreResults = new Map();
refreshing = true;
genreLoading = true;
@@ -217,17 +205,14 @@
refreshing = false;
}
// ── Initial load ──────────────────────────────────────────────────────────
function loadAll() {
loadingLib = true;
loadError = false;
// Already have a session grid — show it immediately
if ((genreResults.get("All") ?? []).length > 0) {
loadingLib = false;
}
// Refresh library ID set so newly-added manga get filtered out
cache.get(CACHE_KEYS.DISCOVER, () =>
gql<{ mangas: { nodes: Manga[] } }>(EXPLORE_ALL_MANGA).then(d => d.mangas.nodes)
).then(m => {
@@ -237,7 +222,6 @@
}).catch(e => { console.error(e); loadError = true; })
.finally(() => { loadingLib = false; });
// Load sources then kick off All tab fan-out (only if grid is empty)
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then(d => {
allSources = d.sources.nodes;
@@ -258,7 +242,6 @@
loadAll();
// ── Context menu ──────────────────────────────────────────────────────────
function openCtx(e: MouseEvent, m: Manga) {
e.preventDefault(); e.stopPropagation();
ctx = { x: e.clientX, y: e.clientY, manga: m };
@@ -316,11 +299,7 @@
<span class="heading">Discover</span>
<div class="tab-strip">
{#each GENRE_TABS as tab (tab)}
<button
class="genre-tab"
class:active={currentGenre === tab}
onclick={() => switchGenre(tab)}
>
<button class="genre-tab" class:active={currentGenre === tab} onclick={() => switchGenre(tab)}>
{#if tab === "All"}<Sparkle size={10} weight="fill" />{/if}
{tab}
</button>
@@ -351,13 +330,9 @@
{:else}
<div class="manga-grid">
{#each visibleGrid as m (m.id)}
<button
class="manga-card"
onclick={() => setPreviewManga(m)}
oncontextmenu={(e) => openCtx(e, m)}
>
<button class="manga-card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => openCtx(e, m)}>
<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>
{#if m.inLibrary}<span class="lib-badge">Saved</span>{/if}
<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; }
.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: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 { 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 { 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; }
.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; }
+4 -3
View File
@@ -1,6 +1,7 @@
<script lang="ts">
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 { store, setActiveDownloads } from "../../store/state.svelte";
import type { DownloadStatus } from "../../lib/types";
@@ -114,7 +115,7 @@
<div class="row" class:row-active={isActive} class:row-removing={isRemoving}>
{#if manga?.thumbnailUrl}
<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>
{/if}
<div class="info">
@@ -165,7 +166,7 @@
.row.row-active { border-color: var(--accent-dim); }
.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-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; }
.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; }
+4 -3
View File
@@ -1,7 +1,8 @@
<script lang="ts">
import { untrack } from "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 { store } from "../../store/state.svelte";
import type { Extension } from "../../lib/types";
@@ -225,7 +226,7 @@
{@const hasVariants = variants.length > 0}
<div class="group">
<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">
<span class="name">{base}</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; }
.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); }
.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; }
.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); }
+5 -4
View File
@@ -1,7 +1,8 @@
<script lang="ts">
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "phosphor-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 { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
import { dedupeSources, dedupeMangaById, shouldHideNsfw } from "../../lib/util";
@@ -217,7 +218,7 @@
{#each visibleItems as m (m.id)}
<button class="card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => { e.stopPropagation(); openCtx(e, m); }}>
<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}
</div>
<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); }
.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:hover .cover { filter: brightness(1.06); }
.card:hover :global(.cover) { filter: brightness(1.06); }
.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 { 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); }
.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; }
+31 -8
View File
@@ -2,6 +2,8 @@
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 { 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 { cache, CACHE_KEYS } from "../../lib/cache";
import { store, openReader, setHeroSlot, setActiveManga, setPreviewManga, setNavPage, setGenreFilter, setLibraryFilter } from "../../store/state.svelte";
@@ -114,7 +116,28 @@
let activeIdx = $state(0);
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 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);
@@ -387,7 +410,7 @@
{#if recentHistory.length > 0}
{#each recentHistory as entry (entry.chapterId)}
<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">
<span class="activity-title">{entry.mangaTitle}</span>
<span class="activity-sub">{entry.chapterName}{entry.pageNumber > 1 ? ` · p.${entry.pageNumber}` : ""}</span>
@@ -431,7 +454,7 @@
{#each completedManga as m (m.id)}
<button class="mini-card" onclick={() => store.previewManga = m}>
<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-footer">
<p class="mini-card-title">{m.title}</p>
@@ -487,7 +510,7 @@
{:else}
{#each pickerResults as m (m.id)}
<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">
<span class="picker-manga-title">{m.title}</span>
{#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:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.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-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; }
@@ -594,10 +617,10 @@
.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: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-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-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); }
@@ -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-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-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-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); }
+11 -12
View File
@@ -1,7 +1,8 @@
<script lang="ts">
import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning, Sparkle } from "phosphor-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 { store } from "../../store/state.svelte";
import type { Manga, Source, Chapter } from "../../lib/types";
@@ -253,8 +254,7 @@
class="source-row"
class:source-row-active={selectedSource?.id === src.id}
onclick={() => pickSource(src)}>
<img src={thumbUrl(src.iconUrl)} alt={src.name} class="source-icon"
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<Thumbnail src={src.iconUrl} alt={src.name} class="source-icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
<div class="source-info">
<span class="source-name">{src.displayName}</span>
<span class="source-meta">{src.lang.toUpperCase()}{src.isNsfw ? " · NSFW" : ""}</span>
@@ -272,8 +272,7 @@
<!-- Source context pill -->
{#if selectedSource}
<div class="search-context">
<img src={thumbUrl(selectedSource.iconUrl)} alt="" class="search-context-icon"
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<Thumbnail src={selectedSource.iconUrl} alt="" class="search-context-icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
<span class="search-context-name">{selectedSource.displayName}</span>
<button class="search-context-change" onclick={() => { step = "source"; results = []; }}>Change</button>
</div>
@@ -316,7 +315,7 @@
onclick={() => selectMatch(m, similarity)}
disabled={loadingMatchId !== null}>
<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 class="result-info">
<span class="result-title">{m.title}</span>
@@ -350,7 +349,7 @@
<div class="confirm-row">
<div class="confirm-manga">
<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>
<p class="confirm-title">{manga.title}</p>
<p class="confirm-source">{manga.source?.displayName ?? "Unknown"}</p>
@@ -363,7 +362,7 @@
<div class="confirm-manga">
<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>
<p class="confirm-title">{selectedMatch.manga.title}</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:hover { background: var(--bg-raised); border-color: var(--border-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-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); }
@@ -477,7 +476,7 @@
/* 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-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-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; }
@@ -495,7 +494,7 @@
.result-row:hover:not(:disabled) { background: var(--bg-raised); }
.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 { 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-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); }
@@ -515,7 +514,7 @@
.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-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-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; }
+12 -14
View File
@@ -1,6 +1,7 @@
<script lang="ts">
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 { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
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)}
<div class="sourceSection">
<div class="sourceHeader">
<img src={thumbUrl(source.iconUrl)} alt={source.displayName} class="sourceIcon"
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<Thumbnail src={source.iconUrl} alt={source.displayName} class="sourceIcon" onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<span class="sourceName">{source.displayName}</span>
{#if hasMultipleLangs}<span class="sourceLang">{source.lang.toUpperCase()}</span>{/if}
{#if loading}
@@ -757,7 +757,7 @@
{#each mangas.slice(0, (store.settings.renderLimit ?? 48)) as m (m.id)}
<button class="card" onclick={() => setPreviewManga(m)}>
<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}
</div>
<p class="cardTitle">{m.title}</p>
@@ -902,7 +902,7 @@
{#each tag_mergedResults as m (m.id)}
<button class="card" onclick={() => setPreviewManga(m)}>
<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}
</div>
<p class="cardTitle">{m.title}</p>
@@ -961,8 +961,7 @@
<div class="splitList">
{#each src_visibleSources as src (src.id)}
<button class="splitItem splitItemSource" class:splitItemActive={src_activeSource?.id === src.id} onclick={() => srcSelectSource(src)}>
<img src={thumbUrl(src.iconUrl)} alt="" class="splitSourceIcon"
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<Thumbnail src={src.iconUrl} alt="" class="splitSourceIcon" onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<span class="splitItemLabel">{src.name}</span>
{#if src_selectedLang === "all"}
<span class="sourceLang" style="margin-left:auto;margin-right:4px">{src.lang.toUpperCase()}</span>
@@ -989,8 +988,7 @@
{:else}
<div class="splitContentHeader">
<div class="splitSourceTitle">
<img src={thumbUrl(src_activeSource.iconUrl)} alt="" class="splitSourceIcon"
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<Thumbnail src={src_activeSource.iconUrl} alt="" class="splitSourceIcon" onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<span class="splitContentTitle">{src_activeSource.displayName}</span>
{#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">
@@ -1030,7 +1028,7 @@
{#each src_browseResults as m (m.id)}
<button class="card" onclick={() => setPreviewManga(m)}>
<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}
</div>
<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:last-child { border-bottom: none; }
.sourceHeader { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) 0; }
.sourceIcon { width: 18px; height: 18px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
: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); }
.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); }
@@ -1125,10 +1123,10 @@
.sourceRow { display: flex; gap: var(--sp-3); overflow-x: auto; padding-bottom: var(--sp-1); scrollbar-width: 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:hover .cover { filter: brightness(1.06); }
.card:hover :global(.cover) { filter: brightness(1.06); }
.card:hover .cardTitle { color: var(--text-primary); }
.coverWrap { position: relative; width: 100%; aspect-ratio: 2 / 3; border-radius: var(--radius-md); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); }
.cover { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); }
: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); }
.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; }
@@ -1162,7 +1160,7 @@
.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); }
.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; }
.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); }
+6 -5
View File
@@ -1,7 +1,8 @@
<script lang="ts">
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 { 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 { cache, CACHE_KEYS, recordSourceAccess } from "../../lib/cache";
import { dedupeMangaById, dedupeMangaByTitle } from "../../lib/util";
@@ -505,7 +506,7 @@
</button>
<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>
{#if loadingManga}
@@ -892,7 +893,7 @@
{#each linkPickerResults as m (m.id)}
{@const isLinked = linkedIds.includes(m.id)}
<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">
<span class="link-manga-title">{m.title}</span>
{#if m.source?.displayName}<span class="link-source">{m.source.displayName}</span>{/if}
@@ -916,7 +917,7 @@
.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 { 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); }
.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:hover { background: var(--bg-raised); }
.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-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); }
File diff suppressed because it is too large Load Diff
+13 -2
View File
@@ -7,6 +7,8 @@
Bookmark, BookOpen, MonitorPlay, MapPin, Check,
} from "phosphor-svelte";
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 { store, closeReader, openReader, addHistory, updateSettings, checkAndMarkCompleted, setSettingsOpen, addBookmark, removeBookmark, addMarker, removeMarker, updateMarker } from "../../store/state.svelte";
import { matchesKeybind, toggleFullscreen, DEFAULT_KEYBINDS } from "../../lib/keybinds";
@@ -40,8 +42,17 @@
if (signal?.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
if (!inflight.has(chapterId)) {
const p = gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId })
.then(d => {
const urls = d.fetchChapterPages.pages.map(thumbUrl);
.then(async d => {
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);
return urls;
})
+1 -1
View File
@@ -147,7 +147,7 @@
<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
class="te-shell"
role="dialog"
+6 -5
View File
@@ -1,7 +1,8 @@
<script lang="ts">
import { onMount, onDestroy } from "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_ALL_MANGA } from "../../lib/queries";
import { cache, CACHE_KEYS } from "../../lib/cache";
@@ -248,7 +249,7 @@
<div class="cover-col">
<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}
<div class="cover-spinner"><CircleNotch size={18} weight="light" class="anim-spin" /></div>
{/if}
@@ -437,7 +438,7 @@
{#each linkPickerResults as m (m.id)}
{@const isLinked = linkedIds.includes(m.id)}
<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">
<span class="link-manga-title">{m.title}</span>
{#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); }
.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 { 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-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); }
@@ -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:hover { background: var(--bg-raised); }
.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-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); }
+5 -4
View File
@@ -1,6 +1,7 @@
<script lang="ts">
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 { store, setActiveSource, setActiveManga, setNavPage } from "../../store/state.svelte";
import type { Manga, Category } from "../../lib/types";
@@ -120,7 +121,7 @@
<button class="card" onclick={() => { setActiveManga(m); setNavPage("library"); }}
oncontextmenu={(e) => openCtx(e, m)}>
<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}
</div>
<p class="title">{m.title}</p>
@@ -165,10 +166,10 @@
.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; }
.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); }
.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); }
.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; }
+3 -3
View File
@@ -1,7 +1,8 @@
<script lang="ts">
import { onMount } from "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 { store } from "../../store/state.svelte";
import type { Source } from "../../lib/types";
@@ -74,8 +75,7 @@
{@const open = expanded.has(g.name)}
<div>
<button class="row" onclick={() => single ? store.activeSource = g.sources[0] : toggleGroup(g.name)}>
<img src={thumbUrl(g.icon)} alt={g.name} class="icon"
onerror={(e) => ((e.target as HTMLImageElement).style.display = "none")} />
<Thumbnail src={g.icon} alt={g.name} class="icon" onerror={(e) => ((e.target as HTMLImageElement).style.display = "none")} />
<div class="info">
<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>
+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} />
+103 -114
View File
@@ -1,6 +1,7 @@
<script lang="ts">
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 {
GET_TRACKERS,
GET_MANGA_TRACK_RECORDS,
@@ -260,7 +261,7 @@
class:tab-active={activeTab === t.id}
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}
{#if rec}<span class="tab-dot"></span>{/if}
</button>
@@ -278,25 +279,50 @@
{#each records as record (record.id)}
{@const tracker = trackerFor(record.trackerId)}
{@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">
{#if tracker}
<img src={thumbUrl(tracker.icon)} alt={tracker.name} class="record-tracker-icon" />
{/if}
{#if record.remoteUrl}
<a href={record.remoteUrl} target="_blank" rel="noreferrer" class="record-title">
{record.title}
<ArrowSquareOut size={10} weight="light" />
</a>
{:else}
<span class="record-title-plain">{record.title}</span>
{/if}
<!-- Title row -->
<div class="record-head">
<div class="record-source">
{#if tracker}
<Thumbnail src={tracker.icon} alt={tracker.name} class="record-tracker-icon" />
{/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>
<div class="record-controls">
<!-- Linked title -->
{#if record.remoteUrl}
<a href={record.remoteUrl} target="_blank" rel="noreferrer" class="record-title">
{record.title} <ArrowSquareOut size={10} weight="light" />
</a>
{:else}
<span class="record-title-plain">{record.title}</span>
{/if}
<!-- Status + score row -->
<div class="record-selects">
<select
class="record-select"
class="record-select record-select-status"
value={record.status}
disabled={isBusy}
onchange={(e) => updateStatus(record, parseInt((e.target as HTMLSelectElement).value))}
@@ -305,7 +331,6 @@
<option value={s.value}>{s.name}</option>
{/each}
</select>
<select
class="record-select record-select-score"
value={record.displayScore}
@@ -316,58 +341,19 @@
<option value={s}> {s}</option>
{/each}
</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>
<!-- Chapter progress -->
{#if editingChapter === record.id}
<div class="chapter-editor">
<div class="chapter-editor-top">
<span class="chapter-editor-label">Chapter read</span>
<div class="chapter-input-wrap">
<input
type="number"
class="chapter-input"
min="0"
max={record.totalChapters > 0 ? record.totalChapters : undefined}
step="0.5"
bind:value={chapterDraft}
onkeydown={(e) => {
if (e.key === "Enter") submitChapter(record);
if (e.key === "Escape") cancelChapterEditor();
}}
type="number" class="chapter-input"
min="0" max={record.totalChapters > 0 ? record.totalChapters : undefined}
step="0.5" bind:value={chapterDraft}
onkeydown={(e) => { if (e.key === "Enter") submitChapter(record); if (e.key === "Escape") cancelChapterEditor(); }}
use:autoFocus
/>
{#if record.totalChapters > 0}
@@ -376,40 +362,36 @@
</div>
</div>
{#if record.totalChapters > 0}
<input
type="range"
class="chapter-slider"
min="0"
max={record.totalChapters}
step="1"
bind:value={chapterDraft}
/>
<input type="range" class="chapter-slider" min="0" max={record.totalChapters} step="1" bind:value={chapterDraft} />
{/if}
<div class="chapter-editor-actions">
<button class="chapter-save-btn" onclick={() => submitChapter(record)}>Save</button>
<button class="chapter-cancel-btn" onclick={cancelChapterEditor}>Cancel</button>
</div>
</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>
<button class="chapter-save-btn" onclick={() => submitChapter(record)}>Save</button>
</div>
</div>
{:else}
<div class="record-progress clickable" role="button" tabindex="0"
onclick={() => openChapterEditor(record)}
onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)}
title="Click to set chapter"
title="Click to edit"
>
<span class="record-progress-label">
{record.lastChapterRead > 0 ? `Ch. ${record.lastChapterRead} read` : "Set chapter…"} <span class="edit-hint"></span>
</span>
<div class="record-progress-header">
<span class="record-progress-label">
{#if record.totalChapters > 0}
Ch. {record.lastChapterRead} / {record.totalChapters}
{:else if record.lastChapterRead > 0}
Ch. {record.lastChapterRead} read
{:else}
Set chapter…
{/if}
</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>
{/if}
@@ -540,60 +522,67 @@
}
.tab:hover { color: var(--text-muted); }
.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-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; }
/* 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; }
.record-row {
.record-card {
display: flex; flex-direction: column; gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
border-radius: var(--radius-md);
padding: var(--sp-4);
border-radius: var(--radius-lg);
border: 1px solid var(--border-dim);
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-busy { opacity: 0.45; pointer-events: none; }
.record-card:hover { border-color: var(--border-strong); }
.record-busy { opacity: 0.4; pointer-events: none; }
.record-identity { display: flex; align-items: center; gap: var(--sp-2); min-width: 0; }
.record-tracker-icon { width: 14px; height: 14px; border-radius: 2px; flex-shrink: 0; object-fit: contain; opacity: 0.8; }
.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); }
.record-title:hover { opacity: 0.75; }
.record-title-plain { font-size: var(--text-sm); color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
.record-head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); }
.record-source { display: flex; align-items: center; gap: var(--sp-2); }
:global(.record-tracker-icon) { width: 14px; height: 14px; border-radius: 2px; flex-shrink: 0; object-fit: contain; 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-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 {
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);
border: 1px solid transparent; background: var(--bg-overlay);
color: var(--text-faint); outline: none; cursor: pointer;
padding: 5px 24px 5px 10px; border-radius: var(--radius-md);
border: 1px solid var(--border-dim); background: var(--bg-surface);
color: var(--text-muted); outline: none; cursor: pointer; flex: 1; min-width: 0;
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-repeat: no-repeat; background-position: right 6px center;
transition: border-color var(--t-base), color var(--t-base), background var(--t-base);
background-repeat: no-repeat; background-position: right 8px center;
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:focus { border-color: var(--border-strong); outline: none; color: var(--text-secondary); }
.record-select:hover:not(:disabled) { border-color: var(--border-strong); 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 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.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:disabled { opacity: 0.3; cursor: default; }
.record-progress { display: flex; flex-direction: column; gap: 4px; }
.record-progress.clickable { cursor: pointer; border-radius: var(--radius-sm); padding: 3px 4px; margin: -3px -4px; transition: background var(--t-fast); }
.record-progress { display: flex; flex-direction: column; gap: 6px; }
.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-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); }
.edit-hint { opacity: 0; font-size: 10px; transition: opacity var(--t-fast); color: var(--text-faint); }
.record-progress.clickable:hover .edit-hint { opacity: 1; }
.record-progress-header { display: flex; align-items: center; justify-content: space-between; }
.record-progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.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-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }