mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Feat: Implemented Basic Tracker Support (Anilist, Mal, Etc)
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
import GenreDrillPage from "../pages/GenreDrillPage.svelte";
|
||||
import Downloads from "../pages/Downloads.svelte";
|
||||
import Extensions from "../pages/Extensions.svelte";
|
||||
import Tracking from "../pages/Tracking.svelte";
|
||||
</script>
|
||||
|
||||
<div class="root">
|
||||
@@ -33,6 +34,8 @@
|
||||
<Downloads />
|
||||
{:else if store.navPage === "extensions"}
|
||||
<Extensions />
|
||||
{:else if store.navPage === "tracking"}
|
||||
<Tracking />
|
||||
{:else}
|
||||
<Home />
|
||||
{/if}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { House, Books, MagnifyingGlass, ClockCounterClockwise, Compass, DownloadSimple, PuzzlePiece, GearSix } from "phosphor-svelte";
|
||||
import { House, Books, MagnifyingGlass, ClockCounterClockwise, Compass, DownloadSimple, PuzzlePiece, GearSix, ChartLineUp } from "phosphor-svelte";
|
||||
import { store, setNavPage, setActiveManga, setActiveSource, setLibraryFilter, setGenreFilter, setSettingsOpen } from "../../store/state.svelte";
|
||||
import type { NavPage } from "../../store/state.svelte";
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
{ id: "explore", label: "Discover", icon: Compass },
|
||||
{ id: "downloads", label: "Downloads", icon: DownloadSimple },
|
||||
{ id: "extensions", label: "Extensions", icon: PuzzlePiece },
|
||||
{ id: "tracking", label: "Tracking", icon: ChartLineUp },
|
||||
];
|
||||
|
||||
function navigate(id: NavPage) {
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { onMount, untrack } from "svelte";
|
||||
import { ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle, ArrowSquareOut, CircleNotch, Play, SortAscending, SortDescending, CaretDown, ArrowsClockwise, List, SquaresFour, FolderSimplePlus, Trash, DownloadSimple, X } from "phosphor-svelte";
|
||||
import { ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle, ArrowSquareOut, CircleNotch, Play, SortAscending, SortDescending, CaretDown, ArrowsClockwise, List, SquaresFour, FolderSimplePlus, Trash, DownloadSimple, X, LinkSimpleHorizontalBreak, ChartLineUp } from "phosphor-svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_MANGA, GET_CHAPTERS, FETCH_CHAPTERS, ENQUEUE_DOWNLOAD, UPDATE_MANGA, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS, ENQUEUE_CHAPTERS_DOWNLOAD } 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 } from "../../lib/queries";
|
||||
import { cache, CACHE_KEYS, recordSourceAccess } from "../../lib/cache";
|
||||
import { store, addToast, updateSettings, addFolder, assignMangaToFolder, removeMangaFromFolder, getMangaFolders, openReader, checkAndMarkCompleted, setActiveManga, setGenreFilter, setNavPage} from "../../store/state.svelte";
|
||||
import { store, addToast, updateSettings, addFolder, assignMangaToFolder, removeMangaFromFolder, getMangaFolders, openReader, checkAndMarkCompleted, setActiveManga, setGenreFilter, setNavPage, linkManga, unlinkManga } from "../../store/state.svelte";
|
||||
import type { Manga, Chapter } from "../../lib/types";
|
||||
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
||||
import MigrateModal from "./MigrateModal.svelte";
|
||||
import TrackingPanel from "../shared/TrackingPanel.svelte";
|
||||
|
||||
const CHAPTERS_PER_PAGE = 25;
|
||||
const MANGA_TTL_MS = 5 * 60 * 1000;
|
||||
@@ -43,6 +44,15 @@
|
||||
let dlDropRef: HTMLDivElement | undefined = $state();
|
||||
let folderPickerRef: HTMLDivElement | undefined = $state();
|
||||
|
||||
// Series link state
|
||||
let linkPickerOpen: boolean = $state(false);
|
||||
let linkSearch: string = $state("");
|
||||
let allMangaForLink: Manga[] = $state([]);
|
||||
let loadingLinkList: boolean = $state(false);
|
||||
|
||||
// Tracking modal
|
||||
let trackingOpen: boolean = $state(false);
|
||||
|
||||
let mangaAbort: AbortController | null = null;
|
||||
let chapterAbort: AbortController | null = null;
|
||||
let loadingFor: number | null = null;
|
||||
@@ -287,6 +297,40 @@
|
||||
}
|
||||
|
||||
onMount(() => () => { mangaAbort?.abort(); chapterAbort?.abort(); });
|
||||
|
||||
// ── Series link ───────────────────────────────────────────────────────────
|
||||
|
||||
const linkedIds = $derived(
|
||||
store.activeManga ? (store.settings.mangaLinks?.[store.activeManga.id] ?? []) : []
|
||||
);
|
||||
|
||||
const linkPickerResults = $derived.by(() => {
|
||||
const id = store.activeManga?.id;
|
||||
const others = allMangaForLink.filter(m => m.id !== id);
|
||||
const q = linkSearch.trim().toLowerCase();
|
||||
const filtered = q ? others.filter(m => m.title.toLowerCase().includes(q)) : others;
|
||||
const linked = filtered.filter(m => linkedIds.includes(m.id));
|
||||
const rest = filtered.filter(m => !linkedIds.includes(m.id)).slice(0, 30);
|
||||
return [...linked, ...rest];
|
||||
});
|
||||
|
||||
async function openLinkPicker() {
|
||||
linkPickerOpen = true; linkSearch = "";
|
||||
if (allMangaForLink.length) return;
|
||||
loadingLinkList = true;
|
||||
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA)
|
||||
.then(d => { allMangaForLink = d.mangas.nodes; })
|
||||
.catch(console.error)
|
||||
.finally(() => { loadingLinkList = false; });
|
||||
}
|
||||
|
||||
function closeLinkPicker() { linkPickerOpen = false; linkSearch = ""; }
|
||||
|
||||
function handleLink(other: Manga) {
|
||||
if (!store.activeManga) return;
|
||||
if (linkedIds.includes(other.id)) unlinkManga(store.activeManga.id, other.id);
|
||||
else linkManga(store.activeManga.id, other.id);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if store.activeManga}
|
||||
@@ -369,6 +413,26 @@
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<div class="tools-row">
|
||||
<button
|
||||
class="tool-btn"
|
||||
class:tool-btn-active={linkedIds.length > 0}
|
||||
onclick={openLinkPicker}
|
||||
title="Series Link"
|
||||
>
|
||||
<LinkSimpleHorizontalBreak size={12} weight={linkedIds.length > 0 ? "fill" : "light"} />
|
||||
<span>Series Link</span>
|
||||
</button>
|
||||
<button
|
||||
class="tool-btn"
|
||||
onclick={() => trackingOpen = true}
|
||||
title="Tracking"
|
||||
>
|
||||
<ChartLineUp size={12} weight="light" />
|
||||
<span>Tracking</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="chapter-count">{totalCount} {totalCount === 1 ? "chapter" : "chapters"}{readCount > 0 ? ` · ${readCount} read` : ""}</p>
|
||||
|
||||
{#if !loadingManga && manga?.source}
|
||||
@@ -607,12 +671,59 @@
|
||||
onMigrated={(newManga) => { setActiveManga(newManga); migrateOpen = false; cache.clear(CACHE_KEYS.LIBRARY); }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if trackingOpen && store.activeManga}
|
||||
<TrackingPanel
|
||||
mangaId={store.activeManga.id}
|
||||
mangaTitle={store.activeManga.title}
|
||||
onClose={() => trackingOpen = false}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if linkPickerOpen}
|
||||
<div
|
||||
class="link-backdrop"
|
||||
role="presentation"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) closeLinkPicker(); }}
|
||||
onkeydown={(e) => e.key === "Escape" && closeLinkPicker()}
|
||||
>
|
||||
<div class="link-modal">
|
||||
<div class="link-header">
|
||||
<span class="link-title">Link as same series</span>
|
||||
<button class="link-close" onclick={closeLinkPicker}><X size={14} weight="light" /></button>
|
||||
</div>
|
||||
<p class="link-hint">Mark two manga as the same series so duplicates are merged in search and discover. Click a linked entry again to unlink.</p>
|
||||
<div class="link-search-wrap">
|
||||
<input class="link-search" placeholder="Search your library…" bind:value={linkSearch} use:focusOnMount />
|
||||
</div>
|
||||
<div class="link-list">
|
||||
{#if loadingLinkList}
|
||||
<p class="link-empty">Loading…</p>
|
||||
{:else if linkPickerResults.length === 0}
|
||||
<p class="link-empty">No results</p>
|
||||
{:else}
|
||||
{#each linkPickerResults as m (m.id)}
|
||||
{@const isLinked = linkedIds.includes(m.id)}
|
||||
<button class="link-row" class:link-row-linked={isLinked} onclick={() => handleLink(m)}>
|
||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="link-thumb" loading="lazy" decoding="async" />
|
||||
<div class="link-info">
|
||||
<span class="link-manga-title">{m.title}</span>
|
||||
{#if m.source?.displayName}<span class="link-source">{m.source.displayName}</span>{/if}
|
||||
</div>
|
||||
<span class="link-status">{isLinked ? "✓ Linked" : "Link"}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
|
||||
<style>
|
||||
.root { display: flex; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||
.sidebar { width: 200px; flex-shrink: 0; padding: var(--sp-5); border-right: 1px solid var(--border-dim); overflow-y: auto; display: flex; flex-direction: column; gap: var(--sp-4); background: var(--bg-base); }
|
||||
.sidebar { width: 240px; flex-shrink: 0; padding: var(--sp-5); border-right: 1px solid var(--border-dim); overflow-y: auto; display: flex; flex-direction: column; gap: var(--sp-4); background: var(--bg-base); }
|
||||
.back { display: flex; align-items: center; gap: var(--sp-2); color: var(--text-muted); font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); text-transform: uppercase; transition: color var(--t-base); }
|
||||
.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; }
|
||||
@@ -646,8 +757,37 @@
|
||||
.library-btn:hover { border-color: var(--accent); color: var(--accent-fg); }
|
||||
.library-btn.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
||||
.library-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
.external-link { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base); }
|
||||
.external-link { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.external-link:hover { color: var(--text-muted); border-color: var(--border-strong); }
|
||||
.external-link.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
/* ── Series link modal ───────────────────────────────────────────────────── */
|
||||
.link-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.72); z-index: var(--z-settings); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); animation: fadeIn 0.12s ease both; }
|
||||
.link-modal { width: min(460px, calc(100vw - 48px)); max-height: 70vh; display: flex; flex-direction: column; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; box-shadow: 0 24px 64px rgba(0,0,0,0.6); animation: scaleIn 0.14s ease both; }
|
||||
.link-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.link-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); }
|
||||
.link-close { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); }
|
||||
.link-close:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
.link-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); padding: var(--sp-3) var(--sp-5) 0; flex-shrink: 0; }
|
||||
.link-search-wrap { padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.link-search { width: 100%; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 6px 10px; color: var(--text-primary); font-size: var(--text-sm); outline: none; transition: border-color var(--t-base); }
|
||||
.link-search:focus { border-color: var(--border-strong); }
|
||||
.link-list { flex: 1; overflow-y: auto; padding: var(--sp-2); scrollbar-width: none; }
|
||||
.link-list::-webkit-scrollbar { display: none; }
|
||||
.link-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; letter-spacing: var(--tracking-wide); }
|
||||
.link-row { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
|
||||
.link-row:hover { background: var(--bg-raised); }
|
||||
.link-row-linked { background: var(--accent-muted) !important; }
|
||||
.link-thumb { width: 34px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
|
||||
.link-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
||||
.link-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.link-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.link-status { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); flex-shrink: 0; padding: 2px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); }
|
||||
.link-row-linked .link-status { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
.tools-row { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.tool-btn { display: flex; align-items: center; gap: var(--sp-2); width: 100%; justify-content: center; padding: 6px var(--sp-2); border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.tool-btn:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||
.tool-btn-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
.tool-btn-active:hover { color: var(--accent-fg); border-color: var(--accent); }
|
||||
.read-btn { display: flex; align-items: center; justify-content: center; gap: var(--sp-2); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); background: var(--accent-dim); border: 1px solid var(--accent); color: var(--accent-fg); font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); cursor: pointer; transition: background var(--t-base), border-color var(--t-base); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.read-btn:hover { background: var(--accent-muted); border-color: var(--accent-bright); }
|
||||
.chapter-count { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-transform: uppercase; }
|
||||
|
||||
@@ -0,0 +1,521 @@
|
||||
<script lang="ts">
|
||||
import { CircleNotch, ArrowSquareOut, ArrowsClockwise, X, Lock, MagnifyingGlass, Funnel } from "phosphor-svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import {
|
||||
GET_ALL_TRACKER_RECORDS,
|
||||
UPDATE_TRACK,
|
||||
UNBIND_TRACK,
|
||||
FETCH_TRACK,
|
||||
} from "../../lib/queries";
|
||||
import { addToast, setActiveManga, setNavPage } from "../../store/state.svelte";
|
||||
import type { Tracker, TrackRecord } from "../../lib/types";
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface TrackerWithRecords extends Tracker {
|
||||
trackRecords: { nodes: TrackRecord[] };
|
||||
}
|
||||
|
||||
interface FlatRecord extends TrackRecord {
|
||||
tracker: Tracker;
|
||||
}
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
|
||||
let trackers: TrackerWithRecords[] = $state([]);
|
||||
let loading: boolean = $state(true);
|
||||
let error: string | null = $state(null);
|
||||
|
||||
// Filter/view state
|
||||
let activeTrackerId: number | "all" = $state("all");
|
||||
let statusFilter: number | "all" = $state("all");
|
||||
let searchQuery: string = $state("");
|
||||
let sortBy: "title" | "status" | "score" | "progress" = $state("title");
|
||||
|
||||
// Mutation state
|
||||
let updatingId: number | null = $state(null);
|
||||
let syncingId: number | null = $state(null);
|
||||
|
||||
// ── Load ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function load() {
|
||||
loading = true; error = null;
|
||||
try {
|
||||
const res = await gql<{ trackers: { nodes: TrackerWithRecords[] } }>(GET_ALL_TRACKER_RECORDS);
|
||||
trackers = res.trackers.nodes;
|
||||
} catch (e: any) {
|
||||
error = e?.message ?? "Failed to load tracking data";
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => { load(); });
|
||||
|
||||
// ── Derived data ───────────────────────────────────────────────────────────
|
||||
|
||||
const loggedInTrackers = $derived(trackers.filter(t => t.isLoggedIn));
|
||||
|
||||
const allRecords: FlatRecord[] = $derived(
|
||||
loggedInTrackers.flatMap(t =>
|
||||
t.trackRecords.nodes.map(r => ({
|
||||
...r,
|
||||
trackerId: r.trackerId ?? t.id, // fallback in case field is missing
|
||||
tracker: t as Tracker,
|
||||
}))
|
||||
)
|
||||
);
|
||||
|
||||
const totalCount = $derived(allRecords.length);
|
||||
|
||||
// Status options across active tracker
|
||||
const statusOptions = $derived.by(() => {
|
||||
if (activeTrackerId === "all") {
|
||||
// Merge all statuses, dedupe by value+name
|
||||
const seen = new Map<string, { value: number; name: string }>();
|
||||
for (const t of loggedInTrackers) {
|
||||
for (const s of t.statuses ?? []) seen.set(`${s.value}:${s.name}`, s);
|
||||
}
|
||||
return [...seen.values()];
|
||||
}
|
||||
return loggedInTrackers.find(t => t.id === activeTrackerId)?.statuses ?? [];
|
||||
});
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
let list = activeTrackerId === "all"
|
||||
? allRecords
|
||||
: allRecords.filter(r => Number(r.trackerId) === Number(activeTrackerId));
|
||||
|
||||
if (statusFilter !== "all")
|
||||
list = list.filter(r => Number(r.status) === Number(statusFilter));
|
||||
|
||||
if (searchQuery.trim())
|
||||
list = list.filter(r =>
|
||||
r.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
r.manga?.title?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
return [...list].sort((a, b) => {
|
||||
if (sortBy === "title") return a.title.localeCompare(b.title);
|
||||
if (sortBy === "status") return a.status - b.status;
|
||||
if (sortBy === "score") return parseFloat(b.displayScore ?? "0") - parseFloat(a.displayScore ?? "0");
|
||||
if (sortBy === "progress") {
|
||||
const ap = a.totalChapters > 0 ? a.lastChapterRead / a.totalChapters : 0;
|
||||
const bp = b.totalChapters > 0 ? b.lastChapterRead / b.totalChapters : 0;
|
||||
return bp - ap;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
});
|
||||
|
||||
// ── Mutations ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function updateStatus(record: FlatRecord, status: number) {
|
||||
updatingId = record.id;
|
||||
try {
|
||||
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
|
||||
UPDATE_TRACK, { recordId: record.id, status }
|
||||
);
|
||||
patchRecord(record.trackerId, res.updateTrack.trackRecord);
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||
} finally {
|
||||
updatingId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateScore(record: FlatRecord, scoreString: string) {
|
||||
updatingId = record.id;
|
||||
try {
|
||||
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
|
||||
UPDATE_TRACK, { recordId: record.id, scoreString }
|
||||
);
|
||||
patchRecord(record.trackerId, res.updateTrack.trackRecord);
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||
} finally {
|
||||
updatingId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function syncRecord(record: FlatRecord) {
|
||||
syncingId = record.id;
|
||||
try {
|
||||
const res = await gql<{ fetchTrack: { trackRecord: TrackRecord } }>(
|
||||
FETCH_TRACK, { recordId: record.id }
|
||||
);
|
||||
patchRecord(record.trackerId, res.fetchTrack.trackRecord);
|
||||
addToast({ kind: "success", title: "Synced from tracker" });
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Sync failed", body: e?.message });
|
||||
} finally {
|
||||
syncingId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function unbind(record: FlatRecord) {
|
||||
updatingId = record.id;
|
||||
try {
|
||||
await gql(UNBIND_TRACK, { recordId: record.id });
|
||||
trackers = trackers.map(t =>
|
||||
t.id !== record.trackerId ? t : {
|
||||
...t,
|
||||
trackRecords: { nodes: t.trackRecords.nodes.filter(r => r.id !== record.id) }
|
||||
}
|
||||
);
|
||||
addToast({ kind: "info", title: "Unlinked from " + record.tracker.name });
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Unbind failed", body: e?.message });
|
||||
} finally {
|
||||
updatingId = null;
|
||||
}
|
||||
}
|
||||
|
||||
function patchRecord(trackerId: number, updated: Partial<TrackRecord> & { id: number }) {
|
||||
trackers = trackers.map(t =>
|
||||
t.id !== trackerId ? t : {
|
||||
...t,
|
||||
trackRecords: {
|
||||
nodes: t.trackRecords.nodes.map(r => r.id === updated.id ? { ...r, ...updated } : r)
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function openManga(record: FlatRecord) {
|
||||
if (!record.manga) return;
|
||||
setActiveManga(record.manga as any);
|
||||
setNavPage("library");
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<!-- ── Header ──────────────────────────────────────────────────────────── -->
|
||||
<div class="page-header">
|
||||
<div class="header-top">
|
||||
<h1 class="page-title">Tracking</h1>
|
||||
<div class="header-actions">
|
||||
<button class="icon-btn" onclick={load} disabled={loading} title="Refresh all">
|
||||
<ArrowsClockwise size={14} weight="light" class={loading ? "anim-spin" : ""} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tracker filter tabs -->
|
||||
{#if !loading && loggedInTrackers.length > 0}
|
||||
<div class="tracker-tabs">
|
||||
<button
|
||||
class="tracker-tab"
|
||||
class:tab-active={activeTrackerId === "all"}
|
||||
onclick={() => { activeTrackerId = "all"; statusFilter = "all"; }}
|
||||
>
|
||||
All
|
||||
<span class="tab-count">{totalCount}</span>
|
||||
</button>
|
||||
{#each loggedInTrackers as t}
|
||||
{@const count = t.trackRecords.nodes.length}
|
||||
<button
|
||||
class="tracker-tab"
|
||||
class:tab-active={activeTrackerId === t.id}
|
||||
onclick={() => { activeTrackerId = Number(t.id); statusFilter = "all"; }}
|
||||
>
|
||||
<img src={thumbUrl(t.icon)} alt={t.name} class="tab-tracker-icon" />
|
||||
{t.name}
|
||||
<span class="tab-count">{count}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Filter + sort bar -->
|
||||
<div class="filter-bar">
|
||||
<div class="search-wrap">
|
||||
<MagnifyingGlass size={12} weight="light" class="search-ico" />
|
||||
<input
|
||||
class="filter-search"
|
||||
placeholder="Search titles…"
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="filter-right">
|
||||
<Funnel size={12} weight="light" style="color:var(--text-faint);flex-shrink:0" />
|
||||
<select class="filter-select" bind:value={statusFilter}
|
||||
onchange={(e) => {
|
||||
const v = (e.target as HTMLSelectElement).value;
|
||||
statusFilter = v === "all" ? "all" : parseInt(v);
|
||||
}}>
|
||||
<option value="all">All statuses</option>
|
||||
{#each statusOptions as s}
|
||||
<option value={s.value}>{s.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<select class="filter-select" bind:value={sortBy}>
|
||||
<option value="title">Sort: Title</option>
|
||||
<option value="status">Sort: Status</option>
|
||||
<option value="score">Sort: Score</option>
|
||||
<option value="progress">Sort: Progress</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ── Body ────────────────────────────────────────────────────────────── -->
|
||||
<div class="page-body">
|
||||
|
||||
{#if loading}
|
||||
<div class="state-center">
|
||||
<CircleNotch size={22} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
||||
<span class="state-label">Loading tracking data…</span>
|
||||
</div>
|
||||
|
||||
{:else if error}
|
||||
<div class="state-center">
|
||||
<p class="state-error">{error}</p>
|
||||
<button class="retry-btn" onclick={load}>Retry</button>
|
||||
</div>
|
||||
|
||||
{:else if loggedInTrackers.length === 0}
|
||||
<div class="state-center">
|
||||
<p class="state-text">No trackers connected.</p>
|
||||
<p class="state-hint">Go to Settings → Tracking to log in to AniList, MAL, or others.</p>
|
||||
</div>
|
||||
|
||||
{:else if filtered.length === 0}
|
||||
<div class="state-center">
|
||||
<p class="state-text">{searchQuery || statusFilter !== "all" ? "No results match your filters." : "Nothing tracked yet."}</p>
|
||||
{#if searchQuery || statusFilter !== "all"}
|
||||
<button class="retry-btn" onclick={() => { searchQuery = ""; statusFilter = "all"; }}>Clear filters</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<div class="records-list">
|
||||
{#each filtered as record (record.tracker.id + ":" + record.id)}
|
||||
{@const tracker = record.tracker}
|
||||
{@const isBusy = updatingId === record.id}
|
||||
{@const isSyncing = syncingId === record.id}
|
||||
{@const progress = record.totalChapters > 0
|
||||
? Math.min(100, (record.lastChapterRead / record.totalChapters) * 100)
|
||||
: null}
|
||||
|
||||
<div class="record-card" class:record-busy={isBusy}>
|
||||
|
||||
<!-- Cover -->
|
||||
<div class="record-cover-wrap" role="button" tabindex="0"
|
||||
onclick={() => openManga(record)}
|
||||
onkeydown={(e) => e.key === "Enter" && openManga(record)}
|
||||
>
|
||||
{#if record.manga?.thumbnailUrl}
|
||||
<img src={thumbUrl(record.manga.thumbnailUrl)} alt={record.title} class="record-cover" loading="lazy" />
|
||||
{:else}
|
||||
<div class="record-cover record-cover-empty"></div>
|
||||
{/if}
|
||||
<!-- Tracker badge -->
|
||||
<img src={thumbUrl(tracker.icon)} alt={tracker.name} class="record-tracker-badge" />
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="record-body">
|
||||
<div class="record-top">
|
||||
<div class="record-titles" role="button" tabindex="0"
|
||||
onclick={() => openManga(record)}
|
||||
onkeydown={(e) => e.key === "Enter" && openManga(record)}
|
||||
>
|
||||
<span class="record-title">{record.title}</span>
|
||||
{#if record.manga?.title && record.manga.title !== record.title}
|
||||
<span class="record-local-title">{record.manga.title}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="record-header-actions">
|
||||
{#if activeTrackerId === "all"}
|
||||
<span class="record-tracker-label">
|
||||
<img src={thumbUrl(record.tracker.icon)} alt={record.tracker.name} class="record-tracker-label-icon" />
|
||||
{record.tracker.name}
|
||||
</span>
|
||||
{/if}
|
||||
{#if isSyncing}
|
||||
<CircleNotch size={12} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
||||
{:else}
|
||||
<button class="card-icon-btn" title="Sync from tracker" onclick={() => syncRecord(record)}>
|
||||
<ArrowsClockwise size={12} weight="light" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if record.remoteUrl}
|
||||
<a href={record.remoteUrl} target="_blank" rel="noreferrer" class="card-icon-btn" title="Open on {record.tracker.name}">
|
||||
<ArrowSquareOut size={12} weight="light" />
|
||||
</a>
|
||||
{/if}
|
||||
<button class="card-icon-btn danger" title="Unlink" onclick={() => unbind(record)} disabled={isBusy}>
|
||||
<X size={12} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controls row -->
|
||||
<div class="record-controls">
|
||||
<select
|
||||
class="record-select"
|
||||
value={record.status}
|
||||
disabled={isBusy}
|
||||
onchange={(e) => updateStatus(record, parseInt((e.target as HTMLSelectElement).value))}
|
||||
>
|
||||
{#each (tracker.statuses ?? []) as s}
|
||||
<option value={s.value}>{s.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<select
|
||||
class="record-select record-select-score"
|
||||
value={record.displayScore}
|
||||
disabled={isBusy}
|
||||
onchange={(e) => updateScore(record, (e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
{#each (tracker.scores ?? []) as s}
|
||||
<option value={s}>★ {s}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
{#if record.private}
|
||||
<span class="private-badge" title="Private"><Lock size={10} weight="fill" /></span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Progress -->
|
||||
{#if progress !== null}
|
||||
<div class="record-progress">
|
||||
<div class="progress-track">
|
||||
<div class="progress-fill" style="width:{progress}%"></div>
|
||||
</div>
|
||||
<span class="progress-label">Ch. {record.lastChapterRead} / {record.totalChapters}</span>
|
||||
</div>
|
||||
{:else if record.lastChapterRead > 0}
|
||||
<span class="progress-label">Ch. {record.lastChapterRead} read</span>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.page { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||
|
||||
/* ── Header ─────────────────────────────────────────────────────────────── */
|
||||
.page-header { flex-shrink: 0; border-bottom: 1px solid var(--border-dim); background: var(--bg-base); }
|
||||
.header-top { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-5) var(--sp-3); }
|
||||
.page-title { font-size: var(--text-lg); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); }
|
||||
.header-actions { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); color: var(--text-muted); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.icon-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
|
||||
.icon-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
/* ── Tracker tabs ───────────────────────────────────────────────────────── */
|
||||
.tracker-tabs { display: flex; align-items: center; gap: 2px; padding: 0 var(--sp-4); overflow-x: auto; scrollbar-width: none; }
|
||||
.tracker-tabs::-webkit-scrollbar { display: none; }
|
||||
.tracker-tab { display: flex; align-items: center; gap: var(--sp-2); padding: 8px 12px; border-bottom: 2px solid transparent; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border-radius: 0; cursor: pointer; white-space: nowrap; transition: color var(--t-base), border-color var(--t-base); }
|
||||
.tracker-tab:hover { color: var(--text-muted); }
|
||||
.tab-active { color: var(--text-secondary) !important; border-bottom-color: var(--accent) !important; }
|
||||
.tab-tracker-icon { width: 14px; height: 14px; border-radius: 2px; object-fit: contain; }
|
||||
.tab-count { font-size: 10px; padding: 1px 5px; border-radius: var(--radius-full); background: var(--bg-overlay); color: var(--text-faint); min-width: 18px; text-align: center; }
|
||||
|
||||
/* ── Filter bar ─────────────────────────────────────────────────────────── */
|
||||
.filter-bar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-2) var(--sp-5); border-top: 1px solid var(--border-dim); }
|
||||
.search-wrap { display: flex; align-items: center; gap: var(--sp-2); flex: 1; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px; }
|
||||
:global(.search-ico) { color: var(--text-faint); flex-shrink: 0; }
|
||||
.filter-search { flex: 1; background: none; border: none; outline: none; font-size: var(--text-sm); color: var(--text-primary); min-width: 0; }
|
||||
.filter-search::placeholder { color: var(--text-faint); }
|
||||
.filter-right { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
|
||||
.filter-select {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 5px 28px 5px 10px; border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-dim); background: var(--bg-raised);
|
||||
color: var(--text-muted); outline: none; cursor: pointer;
|
||||
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='%23888' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat; background-position: right 8px center;
|
||||
transition: border-color var(--t-base), color var(--t-base);
|
||||
}
|
||||
.filter-select:hover { border-color: var(--border-strong); color: var(--text-secondary); }
|
||||
.filter-select option { background: var(--bg-surface); color: var(--text-secondary); }
|
||||
|
||||
/* ── Body ───────────────────────────────────────────────────────────────── */
|
||||
.page-body { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-5); scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; }
|
||||
|
||||
/* ── States ─────────────────────────────────────────────────────────────── */
|
||||
.state-center { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-3); height: 100%; padding: var(--sp-10); text-align: center; }
|
||||
.state-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.state-text { font-size: var(--text-sm); color: var(--text-muted); }
|
||||
.state-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.state-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); letter-spacing: var(--tracking-wide); }
|
||||
.retry-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 6px 16px; border-radius: var(--radius-md); border: 1px solid var(--border-strong); background: none; color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
|
||||
.retry-btn:hover { color: var(--accent-fg); border-color: var(--accent-dim); }
|
||||
|
||||
/* ── Records list ───────────────────────────────────────────────────────── */
|
||||
.records-list { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
|
||||
.record-card {
|
||||
display: flex; align-items: flex-start; gap: var(--sp-4);
|
||||
padding: var(--sp-3) var(--sp-4);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-dim);
|
||||
background: var(--bg-raised);
|
||||
transition: border-color var(--t-base), opacity var(--t-base);
|
||||
}
|
||||
.record-card:hover { border-color: var(--border-strong); }
|
||||
.record-busy { opacity: 0.5; pointer-events: none; }
|
||||
|
||||
/* Cover */
|
||||
.record-cover-wrap { position: relative; flex-shrink: 0; cursor: pointer; }
|
||||
.record-cover { width: 48px; height: 68px; object-fit: cover; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); display: block; }
|
||||
.record-cover-empty { background: var(--bg-overlay); }
|
||||
.record-cover-wrap:hover .record-cover { opacity: 0.8; }
|
||||
.record-tracker-badge { position: absolute; bottom: -4px; right: -4px; width: 16px; height: 16px; border-radius: 3px; border: 1px solid var(--bg-raised); object-fit: contain; background: var(--bg-raised); }
|
||||
|
||||
/* Body */
|
||||
.record-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.record-top { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-3); }
|
||||
.record-titles { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; cursor: pointer; }
|
||||
.record-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: color var(--t-base); }
|
||||
.record-titles:hover .record-title { color: var(--accent-fg); }
|
||||
.record-local-title { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.record-header-actions { display: flex; align-items: center; gap: 2px; flex-shrink: 0; }
|
||||
.record-tracker-label { display: flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: 2px 6px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-overlay); margin-right: var(--sp-1); }
|
||||
.record-tracker-label-icon { width: 12px; height: 12px; border-radius: 2px; object-fit: contain; }
|
||||
.card-icon-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; text-decoration: none; transition: color var(--t-base), background var(--t-base); }
|
||||
.card-icon-btn:hover { color: var(--text-muted); background: var(--bg-overlay); }
|
||||
.card-icon-btn.danger:hover { color: var(--color-error); background: var(--color-error-bg); }
|
||||
.card-icon-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
/* Controls */
|
||||
.record-controls { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
|
||||
.record-select {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 3px 26px 3px 8px; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-strong); background: var(--bg-overlay);
|
||||
color: var(--text-secondary); outline: none; cursor: pointer;
|
||||
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='%23888' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat; background-position: right 7px center;
|
||||
transition: border-color var(--t-base);
|
||||
}
|
||||
.record-select:hover:not(:disabled) { border-color: var(--accent-dim); }
|
||||
.record-select:disabled { opacity: 0.4; cursor: default; }
|
||||
.record-select option { background: var(--bg-surface); color: var(--text-secondary); }
|
||||
.record-select-score { max-width: 90px; }
|
||||
.private-badge { display: flex; align-items: center; color: var(--text-faint); padding: 2px 4px; }
|
||||
|
||||
/* Progress */
|
||||
.record-progress { display: flex; align-items: center; gap: var(--sp-3); }
|
||||
.progress-track { flex: 1; height: 3px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; max-width: 160px; }
|
||||
.progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
|
||||
.progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; }
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
</style>
|
||||
@@ -1,19 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { tick } from "svelte";
|
||||
import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Plus, Pencil, Trash, Wrench, PaintBrush } from "phosphor-svelte";
|
||||
import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Plus, Pencil, Trash, Wrench, PaintBrush, ListChecks } from "phosphor-svelte";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getVersion } from "@tauri-apps/api/app";
|
||||
import { open as openUrl } from "@tauri-apps/plugin-shell";
|
||||
import { gql } from "../../lib/client";
|
||||
import { GET_DOWNLOADS_PATH } from "../../lib/queries";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { GET_DOWNLOADS_PATH, GET_TRACKERS, LOGIN_TRACKER_OAUTH, LOGIN_TRACKER_CREDENTIALS, LOGOUT_TRACKER, GET_TRACKER_RECORDS } from "../../lib/queries";
|
||||
import { store, updateSettings, resetKeybinds, addFolder, removeFolder, renameFolder, toggleFolderTab, clearHistory, wipeAllData, setSettingsOpen } from "../../store/state.svelte";
|
||||
import { cache } from "../../lib/cache";
|
||||
import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind } from "../../lib/keybinds";
|
||||
import type { Settings, FitMode, Theme } from "../../store/state.svelte";
|
||||
import type { Keybinds } from "../../lib/keybinds";
|
||||
import type { Tracker } from "../../lib/types";
|
||||
|
||||
type Tab = "general" | "appearance" | "reader" | "library" | "performance" | "keybinds" | "storage" | "folders" | "about" | "devtools";
|
||||
type Tab = "general" | "appearance" | "reader" | "library" | "performance" | "keybinds" | "storage" | "folders" | "tracking" | "about" | "devtools";
|
||||
|
||||
const TABS: { id: Tab; label: string; icon: any }[] = [
|
||||
{ id: "general", label: "General", icon: Gear },
|
||||
@@ -24,6 +25,7 @@
|
||||
{ id: "keybinds", label: "Keybinds", icon: Keyboard },
|
||||
{ id: "storage", label: "Storage", icon: HardDrives },
|
||||
{ id: "folders", label: "Folders", icon: FolderSimple },
|
||||
{ id: "tracking", label: "Tracking", icon: ListChecks },
|
||||
{ id: "about", label: "About", icon: Info },
|
||||
{ id: "devtools", label: "Dev Tools", icon: Wrench },
|
||||
];
|
||||
@@ -194,6 +196,115 @@
|
||||
|
||||
let splashTriggered = $state(false);
|
||||
|
||||
// ── Tracker state ─────────────────────────────────────────────────────────────
|
||||
|
||||
let trackers: Tracker[] = $state([]);
|
||||
let trackersLoading: boolean = $state(false);
|
||||
let trackersError: string|null = $state(null);
|
||||
|
||||
// OAuth flow state
|
||||
let oauthTrackerId: number|null = $state(null);
|
||||
let oauthCallbackInput: string = $state("");
|
||||
let oauthSubmitting: boolean = $state(false);
|
||||
|
||||
// Credentials flow state
|
||||
let credsTrackerId: number|null = $state(null);
|
||||
let credsUsername: string = $state("");
|
||||
let credsPassword: string = $state("");
|
||||
let credsSubmitting: boolean = $state(false);
|
||||
|
||||
// Logout state
|
||||
let loggingOut: number|null = $state(null); // trackerId being logged out
|
||||
|
||||
async function loadTrackers() {
|
||||
trackersLoading = true; trackersError = null;
|
||||
try {
|
||||
const res = await gql<{ trackers: { nodes: Tracker[] } }>(GET_TRACKERS);
|
||||
trackers = res.trackers.nodes;
|
||||
} catch (e: any) {
|
||||
trackersError = e?.message ?? "Failed to load trackers";
|
||||
} finally {
|
||||
trackersLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => { if (tab === "tracking" && trackers.length === 0 && !trackersLoading) loadTrackers(); });
|
||||
|
||||
// OAuth: trackers with an authUrl use a browser redirect flow.
|
||||
// User clicks "Connect", we open the auth URL in their browser.
|
||||
// Suwayomi's redirect lands at suwayomi.org/tracker-oauth which shows
|
||||
// the full callback URL. User pastes it back here.
|
||||
async function startOAuth(tracker: Tracker) {
|
||||
if (!tracker.authUrl) return;
|
||||
oauthTrackerId = tracker.id;
|
||||
oauthCallbackInput = "";
|
||||
await openUrl(tracker.authUrl);
|
||||
}
|
||||
|
||||
async function submitOAuth() {
|
||||
if (!oauthTrackerId || !oauthCallbackInput.trim()) return;
|
||||
oauthSubmitting = true;
|
||||
try {
|
||||
await gql(LOGIN_TRACKER_OAUTH, {
|
||||
trackerId: oauthTrackerId,
|
||||
callbackUrl: oauthCallbackInput.trim(),
|
||||
});
|
||||
await loadTrackers();
|
||||
oauthTrackerId = null;
|
||||
oauthCallbackInput = "";
|
||||
} catch (e: any) {
|
||||
trackersError = e?.message ?? "Login failed";
|
||||
} finally {
|
||||
oauthSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function cancelOAuth() { oauthTrackerId = null; oauthCallbackInput = ""; }
|
||||
|
||||
// Credentials flow (Kitsu, MangaUpdates)
|
||||
function startCredentials(tracker: Tracker) {
|
||||
credsTrackerId = tracker.id;
|
||||
credsUsername = "";
|
||||
credsPassword = "";
|
||||
}
|
||||
|
||||
async function submitCredentials() {
|
||||
if (!credsTrackerId || !credsUsername.trim() || !credsPassword.trim()) return;
|
||||
credsSubmitting = true;
|
||||
try {
|
||||
await gql(LOGIN_TRACKER_CREDENTIALS, {
|
||||
trackerId: credsTrackerId,
|
||||
username: credsUsername.trim(),
|
||||
password: credsPassword.trim(),
|
||||
});
|
||||
await loadTrackers();
|
||||
credsTrackerId = null;
|
||||
credsUsername = "";
|
||||
credsPassword = "";
|
||||
} catch (e: any) {
|
||||
trackersError = e?.message ?? "Login failed";
|
||||
} finally {
|
||||
credsSubmitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
function cancelCredentials() { credsTrackerId = null; credsUsername = ""; credsPassword = ""; }
|
||||
|
||||
async function logoutTracker(trackerId: number) {
|
||||
loggingOut = trackerId;
|
||||
try {
|
||||
await gql(LOGOUT_TRACKER, { trackerId });
|
||||
await loadTrackers();
|
||||
} catch (e: any) {
|
||||
trackersError = e?.message ?? "Logout failed";
|
||||
} finally {
|
||||
loggingOut = null;
|
||||
}
|
||||
}
|
||||
|
||||
// A tracker uses OAuth if it has an authUrl; otherwise credentials.
|
||||
function usesOAuth(t: Tracker): boolean { return !!t.authUrl; }
|
||||
|
||||
// ── About / Updater state ─────────────────────────────────────────────────────
|
||||
|
||||
interface ReleaseInfo {
|
||||
@@ -802,6 +913,116 @@
|
||||
</div>
|
||||
|
||||
|
||||
{:else if tab === "tracking"}
|
||||
<div class="panel">
|
||||
|
||||
<div class="section">
|
||||
<p class="section-title">Connected Trackers</p>
|
||||
<p class="toggle-desc" style="padding:0 var(--sp-3) var(--sp-2)">
|
||||
Log in to sync your reading progress with external tracking services.
|
||||
After connecting, use the Tracking panel inside any manga's detail page.
|
||||
</p>
|
||||
|
||||
{#if trackersError}
|
||||
<div class="tracker-error">{trackersError}</div>
|
||||
{/if}
|
||||
|
||||
{#if trackersLoading}
|
||||
<p class="storage-loading">Loading trackers…</p>
|
||||
{:else}
|
||||
<div class="tracker-list">
|
||||
{#each trackers as tracker}
|
||||
<div class="tracker-row" class:tracker-row-active={tracker.isLoggedIn}>
|
||||
|
||||
<!-- Icon + name -->
|
||||
<div class="tracker-identity">
|
||||
<img src={thumbUrl(tracker.icon)} alt={tracker.name} class="tracker-logo" />
|
||||
<div class="tracker-name-block">
|
||||
<span class="tracker-label">{tracker.name}</span>
|
||||
<span class="tracker-status-pill" class:pill-on={tracker.isLoggedIn}>
|
||||
{tracker.isLoggedIn ? "Connected" : "Not connected"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action area -->
|
||||
<div class="tracker-action">
|
||||
{#if tracker.isLoggedIn}
|
||||
<div class="tracker-connected-btns">
|
||||
<button
|
||||
class="danger-btn"
|
||||
onclick={() => logoutTracker(tracker.id)}
|
||||
disabled={loggingOut === tracker.id}
|
||||
>
|
||||
{loggingOut === tracker.id ? "Disconnecting…" : "Disconnect"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{:else if oauthTrackerId === tracker.id}
|
||||
<div class="oauth-flow">
|
||||
<p class="oauth-hint">
|
||||
Your browser opened the {tracker.name} login page. After authorising,
|
||||
you'll land on a Suwayomi page — <strong>copy the full URL from your browser's address bar</strong>
|
||||
(it starts with <code>https://suwayomi.org/...</code> and contains your token) and paste it below.
|
||||
</p>
|
||||
<input
|
||||
class="oauth-input"
|
||||
placeholder="https://suwayomi.org/tracker-oauth#access_token=…"
|
||||
bind:value={oauthCallbackInput}
|
||||
onkeydown={(e) => { if (e.key === "Enter") submitOAuth(); if (e.key === "Escape") cancelOAuth(); }}
|
||||
use:focusEl
|
||||
/>
|
||||
<div class="oauth-btns">
|
||||
<button class="step-btn" onclick={submitOAuth} disabled={oauthSubmitting || !oauthCallbackInput.trim()}>
|
||||
{oauthSubmitting ? "Connecting…" : "Connect"}
|
||||
</button>
|
||||
<button class="kb-reset" onclick={cancelOAuth}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else if credsTrackerId === tracker.id}
|
||||
<div class="oauth-flow">
|
||||
<input
|
||||
class="oauth-input"
|
||||
placeholder="Username / Email"
|
||||
bind:value={credsUsername}
|
||||
onkeydown={(e) => e.key === "Escape" && cancelCredentials()}
|
||||
use:focusEl
|
||||
/>
|
||||
<input
|
||||
class="oauth-input"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
bind:value={credsPassword}
|
||||
onkeydown={(e) => { if (e.key === "Enter") submitCredentials(); if (e.key === "Escape") cancelCredentials(); }}
|
||||
/>
|
||||
<div class="oauth-btns">
|
||||
<button class="step-btn" onclick={submitCredentials} disabled={credsSubmitting || !credsUsername.trim() || !credsPassword.trim()}>
|
||||
{credsSubmitting ? "Connecting…" : "Connect"}
|
||||
</button>
|
||||
<button class="kb-reset" onclick={cancelCredentials}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<button
|
||||
class="step-btn"
|
||||
style="width:auto;padding:0 var(--sp-4)"
|
||||
onclick={() => usesOAuth(tracker) ? startOAuth(tracker) : startCredentials(tracker)}
|
||||
>
|
||||
{usesOAuth(tracker) ? "Connect via browser →" : "Connect"}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{:else if tab === "about"}
|
||||
<div class="panel">
|
||||
|
||||
@@ -970,6 +1191,7 @@
|
||||
|
||||
<script module>
|
||||
function focusInput(node: HTMLElement) { node.focus(); }
|
||||
function focusEl(node: HTMLElement) { setTimeout(() => node.focus(), 0); }
|
||||
</script>
|
||||
|
||||
<style>
|
||||
@@ -1199,6 +1421,27 @@
|
||||
.update-ready-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--accent-fg); letter-spacing: var(--tracking-wide); flex: 1; }
|
||||
.update-error-row { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); }
|
||||
|
||||
/* ── Tracker styles ──────────────────────────────────────────────────────── */
|
||||
.tracker-list { display: flex; flex-direction: column; gap: var(--sp-2); padding: 0 var(--sp-3); }
|
||||
.tracker-row { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-4); padding: var(--sp-4); border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); transition: border-color var(--t-base); }
|
||||
.tracker-row-active { border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
.tracker-identity { display: flex; align-items: center; gap: var(--sp-3); flex-shrink: 0; }
|
||||
.tracker-logo { width: 28px; height: 28px; border-radius: var(--radius-sm); object-fit: contain; flex-shrink: 0; }
|
||||
.tracker-name-block { display: flex; flex-direction: column; gap: 3px; }
|
||||
.tracker-label { font-size: var(--text-sm); color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||
.tracker-status-pill { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: 2px 7px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); color: var(--text-faint); background: var(--bg-overlay); width: fit-content; }
|
||||
.tracker-status-pill.pill-on { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
|
||||
.tracker-action { display: flex; flex-direction: column; align-items: flex-end; gap: var(--sp-2); flex: 1; min-width: 0; }
|
||||
.tracker-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); background: var(--color-error-bg); border: 1px solid var(--color-error); border-radius: var(--radius-sm); padding: 6px var(--sp-3); margin: 0 var(--sp-3) var(--sp-2); letter-spacing: var(--tracking-wide); }
|
||||
.tracker-connected-btns { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; justify-content: flex-end; }
|
||||
.oauth-flow { display: flex; flex-direction: column; gap: var(--sp-2); width: 100%; align-items: flex-end; }
|
||||
.oauth-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); text-align: right; }
|
||||
.oauth-hint strong { color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||
.oauth-hint code { font-family: monospace; font-size: 10px; color: var(--text-muted); background: var(--bg-overlay); padding: 1px 4px; border-radius: 3px; }
|
||||
.oauth-input { width: 100%; background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 6px 10px; font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); outline: none; transition: border-color var(--t-base); }
|
||||
.oauth-input:focus { border-color: var(--border-focus); }
|
||||
.oauth-btns { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
@keyframes scaleIn { from { transform: scale(0.97); opacity: 0 } to { transform: scale(1); opacity: 1 } }
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,543 @@
|
||||
<script lang="ts">
|
||||
import { CircleNotch, X, MagnifyingGlass, ArrowSquareOut, Lock, LockOpen, ArrowsClockwise } from "phosphor-svelte";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import {
|
||||
GET_TRACKERS,
|
||||
GET_MANGA_TRACK_RECORDS,
|
||||
SEARCH_TRACKER,
|
||||
BIND_TRACK,
|
||||
UPDATE_TRACK,
|
||||
UNBIND_TRACK,
|
||||
FETCH_TRACK,
|
||||
} from "../../lib/queries";
|
||||
import { addToast } from "../../store/state.svelte";
|
||||
import type { Tracker, TrackRecord, TrackSearch } from "../../lib/types";
|
||||
|
||||
let { mangaId, mangaTitle, onClose }: {
|
||||
mangaId: number;
|
||||
mangaTitle: string;
|
||||
onClose: () => void;
|
||||
} = $props();
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
|
||||
type TabId = "records" | number;
|
||||
|
||||
let trackers: Tracker[] = $state([]);
|
||||
let records: TrackRecord[] = $state([]);
|
||||
let loading: boolean = $state(true);
|
||||
let activeTab: TabId = $state("records");
|
||||
|
||||
let searchQuery: string = $state("");
|
||||
let searchResults: TrackSearch[] = $state([]);
|
||||
let searching: boolean = $state(false);
|
||||
let searchInited: Set<number> = $state(new Set());
|
||||
|
||||
let binding: boolean = $state(false);
|
||||
let updatingRecord: number | null = $state(null);
|
||||
let syncing: number | null = $state(null);
|
||||
|
||||
// ── Load ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
const [tRes, rRes] = await Promise.all([
|
||||
gql<{ trackers: { nodes: Tracker[] } }>(GET_TRACKERS),
|
||||
gql<{ manga: { trackRecords: { nodes: TrackRecord[] } } }>(
|
||||
GET_MANGA_TRACK_RECORDS, { mangaId }
|
||||
),
|
||||
]);
|
||||
trackers = tRes.trackers.nodes;
|
||||
records = rRes.manga.trackRecords.nodes;
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Failed to load tracking", body: e?.message });
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => { load(); });
|
||||
|
||||
// Auto-search with manga title when switching to a tracker tab
|
||||
$effect(() => {
|
||||
const tab = activeTab;
|
||||
if (typeof tab !== "number") return;
|
||||
if (searchInited.has(tab)) return;
|
||||
searchQuery = mangaTitle;
|
||||
searchInited = new Set([...searchInited, tab]);
|
||||
doSearch(tab, mangaTitle);
|
||||
});
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function trackerFor(id: number) { return trackers.find(t => t.id === id); }
|
||||
function recordFor(trackerId: number){ return records.find(r => r.trackerId === trackerId); }
|
||||
const loggedInTrackers = $derived(trackers.filter(t => t.isLoggedIn));
|
||||
|
||||
// ── Search ─────────────────────────────────────────────────────────────────
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout>;
|
||||
|
||||
function onSearchInput() {
|
||||
clearTimeout(searchTimer);
|
||||
if (typeof activeTab !== "number") return;
|
||||
const tid = activeTab;
|
||||
if (!searchQuery.trim()) { searchResults = []; return; }
|
||||
searchTimer = setTimeout(() => doSearch(tid, searchQuery), 400);
|
||||
}
|
||||
|
||||
async function doSearch(trackerId: number, query: string) {
|
||||
if (!query.trim()) return;
|
||||
searching = true;
|
||||
searchResults = [];
|
||||
try {
|
||||
const res = await gql<{ searchTracker: { trackSearches: TrackSearch[] } }>(
|
||||
SEARCH_TRACKER, { trackerId, query: query.trim() }
|
||||
);
|
||||
searchResults = res.searchTracker.trackSearches;
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Search failed", body: e?.message });
|
||||
} finally {
|
||||
searching = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Bind / Unbind ──────────────────────────────────────────────────────────
|
||||
|
||||
async function bind(result: TrackSearch) {
|
||||
if (typeof activeTab !== "number") return;
|
||||
binding = true;
|
||||
try {
|
||||
const res = await gql<{ bindTrack: { trackRecord: TrackRecord } }>(
|
||||
BIND_TRACK, { mangaId, trackerId: activeTab, remoteId: result.remoteId }
|
||||
);
|
||||
records = [...records.filter(r => r.trackerId !== activeTab), res.bindTrack.trackRecord];
|
||||
addToast({ kind: "success", title: "Now tracking", body: result.title });
|
||||
activeTab = "records";
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Failed to bind", body: e?.message });
|
||||
} finally {
|
||||
binding = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function unbind(record: TrackRecord) {
|
||||
updatingRecord = record.id;
|
||||
try {
|
||||
await gql(UNBIND_TRACK, { recordId: record.id });
|
||||
records = records.filter(r => r.id !== record.id);
|
||||
addToast({ kind: "info", title: "Unlinked from " + trackerFor(record.trackerId)?.name });
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Failed to unlink", body: e?.message });
|
||||
} finally {
|
||||
updatingRecord = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Update ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function updateStatus(record: TrackRecord, status: number) {
|
||||
updatingRecord = record.id;
|
||||
try {
|
||||
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
|
||||
UPDATE_TRACK, { recordId: record.id, status }
|
||||
);
|
||||
patchRecord(res.updateTrack.trackRecord);
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||
} finally {
|
||||
updatingRecord = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateScore(record: TrackRecord, scoreString: string) {
|
||||
updatingRecord = record.id;
|
||||
try {
|
||||
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
|
||||
UPDATE_TRACK, { recordId: record.id, scoreString }
|
||||
);
|
||||
patchRecord(res.updateTrack.trackRecord);
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||
} finally {
|
||||
updatingRecord = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePrivate(record: TrackRecord) {
|
||||
updatingRecord = record.id;
|
||||
try {
|
||||
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(
|
||||
UPDATE_TRACK, { recordId: record.id, private: !record.private }
|
||||
);
|
||||
patchRecord(res.updateTrack.trackRecord);
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||
} finally {
|
||||
updatingRecord = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function syncRecord(record: TrackRecord) {
|
||||
syncing = record.id;
|
||||
try {
|
||||
const res = await gql<{ fetchTrack: { trackRecord: TrackRecord } }>(
|
||||
FETCH_TRACK, { recordId: record.id }
|
||||
);
|
||||
patchRecord(res.fetchTrack.trackRecord);
|
||||
addToast({ kind: "success", title: "Synced from tracker" });
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Sync failed", body: e?.message });
|
||||
} finally {
|
||||
syncing = null;
|
||||
}
|
||||
}
|
||||
|
||||
function patchRecord(updated: Partial<TrackRecord> & { id: number }) {
|
||||
records = records.map(r => r.id === updated.id ? { ...r, ...updated } : r);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={(e) => e.key === "Escape" && onClose()} />
|
||||
|
||||
<div
|
||||
class="backdrop"
|
||||
role="presentation"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div class="modal" role="dialog" aria-label="Tracking">
|
||||
|
||||
<!-- ── Header ─────────────────────────────────────────────────────────── -->
|
||||
<div class="modal-header">
|
||||
<div class="header-left">
|
||||
<span class="modal-title">Tracking</span>
|
||||
<span class="modal-subtitle">{mangaTitle}</span>
|
||||
</div>
|
||||
<button class="close-btn" onclick={onClose}><X size={15} weight="light" /></button>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="state-body">
|
||||
<CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
||||
<span class="state-label">Loading…</span>
|
||||
</div>
|
||||
|
||||
{:else if loggedInTrackers.length === 0}
|
||||
<div class="state-body">
|
||||
<p class="state-text">No trackers connected.</p>
|
||||
<p class="state-hint">Go to Settings → Tracking to log in.</p>
|
||||
</div>
|
||||
|
||||
{:else}
|
||||
<!-- ── Tabs ──────────────────────────────────────────────────────────── -->
|
||||
<div class="tabs">
|
||||
<button
|
||||
class="tab"
|
||||
class:tab-active={activeTab === "records"}
|
||||
onclick={() => activeTab = "records"}
|
||||
>
|
||||
My List
|
||||
{#if records.length > 0}
|
||||
<span class="tab-badge">{records.length}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{#each loggedInTrackers as t}
|
||||
{@const rec = recordFor(t.id)}
|
||||
<button
|
||||
class="tab"
|
||||
class:tab-active={activeTab === t.id}
|
||||
onclick={() => { activeTab = t.id; searchResults = []; }}
|
||||
>
|
||||
<img src={thumbUrl(t.icon)} alt={t.name} class="tab-icon" />
|
||||
{t.name}
|
||||
{#if rec}<span class="tab-dot"></span>{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- ── My List tab ───────────────────────────────────────────────────── -->
|
||||
{#if activeTab === "records"}
|
||||
<div class="tab-body">
|
||||
{#if records.length === 0}
|
||||
<div class="state-body">
|
||||
<p class="state-text">Not tracking this manga yet.</p>
|
||||
<p class="state-hint">Click a tracker tab above to search and add it.</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each records as record (record.id)}
|
||||
{@const tracker = trackerFor(record.trackerId)}
|
||||
{@const isBusy = updatingRecord === record.id}
|
||||
<div class="record-row" class:record-busy={isBusy}>
|
||||
|
||||
<div class="record-identity">
|
||||
{#if tracker}
|
||||
<img src={thumbUrl(tracker.icon)} alt={tracker.name} class="record-tracker-icon" />
|
||||
{/if}
|
||||
{#if record.remoteUrl}
|
||||
<a href={record.remoteUrl} target="_blank" rel="noreferrer" class="record-title">
|
||||
{record.title}
|
||||
<ArrowSquareOut size={10} weight="light" />
|
||||
</a>
|
||||
{:else}
|
||||
<span class="record-title-plain">{record.title}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="record-controls">
|
||||
<select
|
||||
class="record-select"
|
||||
value={record.status}
|
||||
disabled={isBusy}
|
||||
onchange={(e) => updateStatus(record, parseInt((e.target as HTMLSelectElement).value))}
|
||||
>
|
||||
{#each (tracker?.statuses ?? []) as s}
|
||||
<option value={s.value}>{s.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<select
|
||||
class="record-select record-select-score"
|
||||
value={record.displayScore}
|
||||
disabled={isBusy}
|
||||
onchange={(e) => updateScore(record, (e.target as HTMLSelectElement).value)}
|
||||
>
|
||||
{#each (tracker?.scores ?? []) as s}
|
||||
<option value={s}>★ {s}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
{#if tracker?.supportsPrivateTracking}
|
||||
<button
|
||||
class="record-icon-btn"
|
||||
class:icon-active={record.private}
|
||||
title={record.private ? "Private — click to make public" : "Public — click to make private"}
|
||||
disabled={isBusy}
|
||||
onclick={() => togglePrivate(record)}
|
||||
>
|
||||
{#if record.private}
|
||||
<Lock size={12} weight="fill" />
|
||||
{:else}
|
||||
<LockOpen size={12} weight="light" />
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="record-icon-btn"
|
||||
title="Sync from tracker"
|
||||
disabled={syncing === record.id}
|
||||
onclick={() => syncRecord(record)}
|
||||
>
|
||||
<ArrowsClockwise size={12} weight="light" class={syncing === record.id ? "anim-spin" : ""} />
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="record-icon-btn icon-danger"
|
||||
title="Unlink"
|
||||
disabled={isBusy}
|
||||
onclick={() => unbind(record)}
|
||||
>
|
||||
<X size={12} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if record.totalChapters > 0}
|
||||
<div class="record-progress">
|
||||
<span class="record-progress-label">Ch. {record.lastChapterRead} / {record.totalChapters}</span>
|
||||
<div class="record-progress-track">
|
||||
<div class="record-progress-fill" style="width:{Math.min(100,(record.lastChapterRead/record.totalChapters)*100)}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if record.lastChapterRead > 0}
|
||||
<span class="record-progress-label">Ch. {record.lastChapterRead} read</span>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ── Tracker search tab ─────────────────────────────────────────────── -->
|
||||
{:else}
|
||||
{@const tracker = trackerFor(activeTab as number)}
|
||||
{@const boundRecord = recordFor(activeTab as number)}
|
||||
<div class="search-bar">
|
||||
<MagnifyingGlass size={13} weight="light" class="search-icon" />
|
||||
<input
|
||||
class="search-input"
|
||||
placeholder="Search {tracker?.name}…"
|
||||
bind:value={searchQuery}
|
||||
oninput={onSearchInput}
|
||||
onkeydown={(e) => e.key === "Enter" && doSearch(activeTab as number, searchQuery)}
|
||||
use:autoFocus
|
||||
/>
|
||||
{#if searching}
|
||||
<CircleNotch size={13} weight="light" class="anim-spin search-icon" />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="search-results">
|
||||
{#if searching && searchResults.length === 0}
|
||||
<div class="state-body"><p class="state-hint">Searching…</p></div>
|
||||
{:else if !searching && searchQuery.trim() && searchResults.length === 0}
|
||||
<div class="state-body"><p class="state-text">No results for "{searchQuery}"</p></div>
|
||||
{:else if !searchQuery.trim()}
|
||||
<div class="state-body"><p class="state-hint">Type a title to search</p></div>
|
||||
{:else}
|
||||
{#each searchResults as result (result.trackerId + ":" + result.remoteId)}
|
||||
{@const isBound = boundRecord?.remoteId === result.remoteId}
|
||||
<button
|
||||
class="result-row"
|
||||
class:result-bound={isBound}
|
||||
onclick={() => isBound ? unbind(boundRecord!) : bind(result)}
|
||||
disabled={binding}
|
||||
>
|
||||
{#if result.coverUrl}
|
||||
<img
|
||||
src={result.coverUrl}
|
||||
alt={result.title}
|
||||
class="result-cover"
|
||||
loading="lazy"
|
||||
onerror={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
{:else}
|
||||
<div class="result-cover result-cover-empty"></div>
|
||||
{/if}
|
||||
<div class="result-info">
|
||||
<span class="result-title">{result.title}</span>
|
||||
<div class="result-meta">
|
||||
{#if result.publishingType}<span class="result-tag">{result.publishingType}</span>{/if}
|
||||
{#if result.publishingStatus}<span class="result-tag">{result.publishingStatus}</span>{/if}
|
||||
{#if result.totalChapters > 0}<span class="result-tag">{result.totalChapters} ch</span>{/if}
|
||||
</div>
|
||||
{#if result.summary}
|
||||
<p class="result-summary">{result.summary.slice(0,140)}{result.summary.length > 140 ? "…" : ""}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<span class="result-action" class:result-action-on={isBound}>
|
||||
{isBound ? "✓ Tracking" : "Track"}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script module>
|
||||
function autoFocus(node: HTMLElement) { setTimeout(() => node.focus(), 50); }
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.backdrop {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.72);
|
||||
z-index: var(--z-settings);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
|
||||
animation: fadeIn 0.12s ease both;
|
||||
}
|
||||
.modal {
|
||||
width: min(580px, calc(100vw - 48px));
|
||||
max-height: min(680px, calc(100vh - 80px));
|
||||
display: flex; flex-direction: column;
|
||||
background: var(--bg-surface); border: 1px solid var(--border-base);
|
||||
border-radius: var(--radius-xl); overflow: hidden;
|
||||
box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6);
|
||||
animation: scaleIn 0.15s ease both;
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.modal-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
|
||||
}
|
||||
.header-left { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.modal-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); }
|
||||
.modal-subtitle { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.close-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
|
||||
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
|
||||
/* States */
|
||||
.state-body { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); padding: var(--sp-10) var(--sp-5); flex: 1; }
|
||||
.state-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.state-text { font-size: var(--text-sm); color: var(--text-muted); }
|
||||
.state-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-align: center; }
|
||||
|
||||
/* Tabs */
|
||||
.tabs { display: flex; align-items: center; gap: 2px; padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; overflow-x: auto; scrollbar-width: none; }
|
||||
.tabs::-webkit-scrollbar { display: none; }
|
||||
.tab { display: flex; align-items: center; gap: var(--sp-2); position: relative; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 5px 10px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; white-space: nowrap; transition: color var(--t-base), background var(--t-base); }
|
||||
.tab:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
.tab-active { color: var(--text-secondary); background: var(--bg-raised); }
|
||||
.tab-icon { width: 14px; height: 14px; border-radius: 2px; object-fit: contain; }
|
||||
.tab-badge { font-size: 10px; padding: 0 5px; border-radius: var(--radius-full); background: var(--accent-dim); color: var(--accent-fg); min-width: 16px; text-align: center; }
|
||||
.tab-dot { position: absolute; top: 4px; right: 4px; width: 5px; height: 5px; border-radius: 50%; background: var(--accent); }
|
||||
|
||||
/* Records */
|
||||
.tab-body { flex: 1; overflow-y: auto; padding: var(--sp-3); scrollbar-width: none; display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.tab-body::-webkit-scrollbar { display: none; }
|
||||
.record-row { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-4); border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); transition: opacity var(--t-base); }
|
||||
.record-busy { opacity: 0.5; pointer-events: none; }
|
||||
.record-identity { display: flex; align-items: center; gap: var(--sp-2); min-width: 0; }
|
||||
.record-tracker-icon { width: 16px; height: 16px; border-radius: 3px; flex-shrink: 0; object-fit: contain; }
|
||||
.record-title { display: inline-flex; align-items: center; gap: 3px; font-size: var(--text-sm); color: var(--accent-fg); text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; transition: opacity var(--t-base); }
|
||||
.record-title:hover { opacity: 0.75; }
|
||||
.record-title-plain { font-size: var(--text-sm); color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
|
||||
.record-controls { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
|
||||
.record-select {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 4px 28px 4px 8px; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-strong); background: var(--bg-overlay);
|
||||
color: var(--text-secondary); outline: none; cursor: pointer;
|
||||
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='%23888' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat; background-position: right 8px center;
|
||||
transition: border-color var(--t-base);
|
||||
}
|
||||
.record-select:hover:not(:disabled) { border-color: var(--accent-dim); }
|
||||
.record-select:focus { border-color: var(--accent); outline: none; }
|
||||
.record-select:disabled { opacity: 0.4; cursor: default; }
|
||||
.record-select option { background: var(--bg-surface); color: var(--text-secondary); }
|
||||
.record-select-score { max-width: 100px; }
|
||||
.record-icon-btn { display: flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); flex-shrink: 0; }
|
||||
.record-icon-btn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-overlay); }
|
||||
.record-icon-btn.icon-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
.record-icon-btn.icon-danger:hover:not(:disabled) { color: var(--color-error); border-color: var(--color-error); background: var(--color-error-bg); }
|
||||
.record-icon-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
.record-progress { display: flex; flex-direction: column; gap: 4px; }
|
||||
.record-progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.record-progress-track { height: 2px; background: var(--border-base); 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; }
|
||||
|
||||
/* Search */
|
||||
.search-bar { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
:global(.search-icon) { color: var(--text-faint); flex-shrink: 0; }
|
||||
.search-input { flex: 1; background: none; border: none; outline: none; font-size: var(--text-sm); color: var(--text-primary); }
|
||||
.search-input::placeholder { color: var(--text-faint); }
|
||||
.search-results { flex: 1; overflow-y: auto; padding: var(--sp-2); scrollbar-width: none; }
|
||||
.search-results::-webkit-scrollbar { display: none; }
|
||||
|
||||
/* Results */
|
||||
.result-row { display: flex; align-items: flex-start; gap: var(--sp-3); width: 100%; padding: var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
|
||||
.result-row:hover:not(:disabled) { background: var(--bg-raised); }
|
||||
.result-row:disabled { opacity: 0.5; cursor: default; }
|
||||
.result-bound { background: var(--accent-muted) !important; }
|
||||
.result-cover { width: 46px; height: 66px; object-fit: cover; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||
.result-cover-empty { background: var(--bg-raised); }
|
||||
.hidden { display: none; }
|
||||
.result-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-1); padding-top: 2px; }
|
||||
.result-title { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); text-align: left; }
|
||||
.result-meta { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
|
||||
.result-tag { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 1px 6px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); }
|
||||
.result-summary { font-size: var(--text-xs); color: var(--text-faint); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-align: left; }
|
||||
.result-action { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 12px; border-radius: var(--radius-sm); border: 1px solid var(--border-strong); background: none; color: var(--text-faint); flex-shrink: 0; align-self: center; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
|
||||
.result-row:hover:not(:disabled) .result-action { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||
.result-action-on { color: var(--accent-fg) !important; border-color: var(--accent-dim) !important; background: var(--accent-muted) !important; }
|
||||
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||||
</style>
|
||||
Reference in New Issue
Block a user