mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Fix: Auth Thumbnails on Windows (WIP)
This commit is contained in:
@@ -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)"
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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); }
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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} />
|
||||||
@@ -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 -->
|
||||||
{#if tracker}
|
<div class="record-head">
|
||||||
<img src={thumbUrl(tracker.icon)} alt={tracker.name} class="record-tracker-icon" />
|
<div class="record-source">
|
||||||
{/if}
|
{#if tracker}
|
||||||
{#if record.remoteUrl}
|
<Thumbnail src={tracker.icon} alt={tracker.name} class="record-tracker-icon" />
|
||||||
<a href={record.remoteUrl} target="_blank" rel="noreferrer" class="record-title">
|
{/if}
|
||||||
{record.title}
|
<span class="record-source-name">{tracker?.name ?? "Tracker"}</span>
|
||||||
<ArrowSquareOut size={10} weight="light" />
|
</div>
|
||||||
</a>
|
<div class="record-head-actions">
|
||||||
{:else}
|
{#if tracker?.supportsPrivateTracking}
|
||||||
<span class="record-title-plain">{record.title}</span>
|
<button
|
||||||
{/if}
|
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>
|
||||||
|
|
||||||
<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
|
<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"
|
||||||
>
|
>
|
||||||
<span class="record-progress-label">
|
<div class="record-progress-header">
|
||||||
{record.lastChapterRead > 0 ? `Ch. ${record.lastChapterRead} read` : "Set chapter…"} <span class="edit-hint">✎</span>
|
<span class="record-progress-label">
|
||||||
</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 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
@@ -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";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user