mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 17:29:55 -05:00
Feat: TrackingPanel + Tracking Re-design (WIP)
This commit is contained in:
@@ -1,29 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { CircleNotch, ArrowSquareOut, ArrowsClockwise, X, Lock, MagnifyingGlass } from "phosphor-svelte";
|
||||
import { gql } from "@api/client";
|
||||
import { addToast, setActiveManga, setNavPage } from "@store/state.svelte";
|
||||
import { GET_CHAPTERS } from "@api/queries/chapters";
|
||||
import { store } from "@store/state.svelte";
|
||||
import { trackingState } from "@features/tracking/store/trackingState.svelte";
|
||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||
import type { TrackRecord } from "@types/index";
|
||||
import type { Chapter } from "@types/index";
|
||||
import { CircleNotch } from "phosphor-svelte";
|
||||
import { trackingState } from "@features/tracking/store/trackingState.svelte";
|
||||
import {
|
||||
flattenRecords, filterRecords, sortRecords, dedupeStatuses,
|
||||
scoreToStars, calcProgress,
|
||||
type FlatRecord, type SortKey,
|
||||
} from "../lib/trackingSync";
|
||||
import TrackingToolbar from "./TrackingToolbar.svelte";
|
||||
import TrackingCard from "./TrackingCard.svelte";
|
||||
import TrackingPreview from "./TrackingPreview.svelte";
|
||||
|
||||
let activeTrackerId = $state<number | "all">("all");
|
||||
let statusFilter = $state<number | "all">("all");
|
||||
let searchQuery = $state("");
|
||||
let sortBy = $state<SortKey>("title");
|
||||
|
||||
let updatingId = $state<number | null>(null);
|
||||
let syncingId = $state<number | null>(null);
|
||||
let editingChapter = $state<number | null>(null);
|
||||
let chapterDraft = $state(0);
|
||||
let confirmUnbind = $state<FlatRecord | null>(null);
|
||||
let selectedRecord = $state<FlatRecord | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (trackingState.allTrackers.length === 0 && !trackingState.loadingAll) {
|
||||
@@ -31,185 +21,36 @@
|
||||
}
|
||||
});
|
||||
|
||||
const loggedIn = $derived(trackingState.allTrackers.filter(t => t.isLoggedIn));
|
||||
const allRecords = $derived(flattenRecords(trackingState.allTrackers));
|
||||
const totalCount = $derived(allRecords.length);
|
||||
|
||||
const loggedIn = $derived(trackingState.allTrackers.filter(t => t.isLoggedIn));
|
||||
const allRecords = $derived(flattenRecords(trackingState.allTrackers));
|
||||
const totalCount = $derived(allRecords.length);
|
||||
const statusOptions = $derived(
|
||||
activeTrackerId === "all"
|
||||
? dedupeStatuses(trackingState.allTrackers)
|
||||
: loggedIn.find(t => t.id === activeTrackerId)?.statuses ?? []
|
||||
);
|
||||
|
||||
const filtered = $derived(
|
||||
sortRecords(filterRecords(allRecords, activeTrackerId, statusFilter, searchQuery), sortBy)
|
||||
);
|
||||
|
||||
function mangaIdForRecord(record: FlatRecord): number | null {
|
||||
return record.manga?.id ?? null;
|
||||
}
|
||||
|
||||
function prefsForManga(mangaId: number) {
|
||||
return store.settings.mangaPrefs?.[mangaId] ?? {};
|
||||
}
|
||||
|
||||
async function updateStatus(record: FlatRecord, status: number) {
|
||||
const mangaId = mangaIdForRecord(record);
|
||||
if (mangaId === null) return;
|
||||
updatingId = record.id;
|
||||
try {
|
||||
await trackingState.updateStatus(mangaId, record, status);
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||
} finally { updatingId = null; }
|
||||
}
|
||||
|
||||
async function updateScore(record: FlatRecord, scoreString: string) {
|
||||
const mangaId = mangaIdForRecord(record);
|
||||
if (mangaId === null) return;
|
||||
updatingId = record.id;
|
||||
try {
|
||||
await trackingState.updateScore(mangaId, record, scoreString);
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||
} finally { updatingId = null; }
|
||||
}
|
||||
|
||||
async function submitChapter(record: FlatRecord) {
|
||||
const val = Math.max(0, chapterDraft);
|
||||
editingChapter = null;
|
||||
if (val === record.lastChapterRead) return;
|
||||
|
||||
const mangaId = mangaIdForRecord(record);
|
||||
if (mangaId === null) return;
|
||||
updatingId = record.id;
|
||||
|
||||
try {
|
||||
await trackingState.updateChapterProgress(mangaId, record, val);
|
||||
|
||||
if (store.settings.trackerSyncBack && record.manga?.id) {
|
||||
const chapRes = await gql<{ chapters: { nodes: Chapter[] } }>(
|
||||
GET_CHAPTERS, { mangaId: record.manga.id }
|
||||
);
|
||||
await trackingState.syncFromRemote(
|
||||
mangaId,
|
||||
{ ...record, lastChapterRead: val },
|
||||
chapRes.chapters.nodes,
|
||||
prefsForManga(mangaId),
|
||||
);
|
||||
}
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||
} finally { updatingId = null; }
|
||||
}
|
||||
|
||||
async function syncRecord(record: FlatRecord) {
|
||||
const mangaId = mangaIdForRecord(record);
|
||||
if (mangaId === null) return;
|
||||
syncingId = record.id;
|
||||
try {
|
||||
let chapters: Chapter[] = [];
|
||||
if (store.settings.trackerSyncBack && record.manga?.id) {
|
||||
const res = await gql<{ chapters: { nodes: Chapter[] } }>(
|
||||
GET_CHAPTERS, { mangaId: record.manga.id }
|
||||
);
|
||||
chapters = res.chapters.nodes;
|
||||
}
|
||||
|
||||
const { markedIds } = await trackingState.syncFromRemote(
|
||||
mangaId, record, chapters, prefsForManga(mangaId)
|
||||
);
|
||||
|
||||
const body = markedIds.length > 0
|
||||
? `${markedIds.length} chapter${markedIds.length !== 1 ? "s" : ""} marked read`
|
||||
: undefined;
|
||||
addToast({ kind: "success", title: "Synced from tracker", body });
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Sync failed", body: e?.message });
|
||||
} finally { syncingId = null; }
|
||||
}
|
||||
|
||||
async function unbind(record: FlatRecord) {
|
||||
const mangaId = mangaIdForRecord(record);
|
||||
if (mangaId === null) return;
|
||||
updatingId = record.id;
|
||||
try {
|
||||
await trackingState.unbind(mangaId, record);
|
||||
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 openManga(record: FlatRecord) {
|
||||
if (!record.manga) return;
|
||||
setActiveManga(record.manga as any);
|
||||
setNavPage("library");
|
||||
}
|
||||
|
||||
function openChapterEditor(record: FlatRecord) {
|
||||
editingChapter = record.id;
|
||||
chapterDraft = record.lastChapterRead;
|
||||
}
|
||||
|
||||
function focusEl(node: HTMLElement) { setTimeout(() => node.focus(), 0); }
|
||||
</script>
|
||||
|
||||
<div class="page">
|
||||
|
||||
<div class="header">
|
||||
<div class="header-top">
|
||||
<h1 class="heading">Tracking</h1>
|
||||
<button class="icon-btn" onclick={() => trackingState.loadAll()} disabled={trackingState.loadingAll} title="Refresh">
|
||||
<ArrowsClockwise size={14} weight="light" class={trackingState.loadingAll ? "anim-spin" : ""} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if !trackingState.loadingAll && loggedIn.length > 0}
|
||||
<div class="tracker-tabs">
|
||||
<button
|
||||
class="tracker-tab" class:active={activeTrackerId === "all"}
|
||||
onclick={() => { activeTrackerId = "all"; statusFilter = "all"; }}
|
||||
>
|
||||
All
|
||||
<span class="tab-pill">{totalCount}</span>
|
||||
</button>
|
||||
{#each loggedIn as t}
|
||||
<button
|
||||
class="tracker-tab" class:active={activeTrackerId === t.id}
|
||||
onclick={() => { activeTrackerId = Number(t.id); statusFilter = "all"; }}
|
||||
>
|
||||
<Thumbnail src={t.icon} alt={t.name} class="tab-icon" />
|
||||
{t.name}
|
||||
<span class="tab-pill">{t.trackRecords.nodes.length}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="filter-bar">
|
||||
<div class="search-wrap">
|
||||
<MagnifyingGlass size={12} weight="light" class="search-ico" />
|
||||
<input class="filter-input" placeholder="Search…" bind:value={searchQuery} />
|
||||
</div>
|
||||
<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">Title</option>
|
||||
<option value="status">Status</option>
|
||||
<option value="score">Score</option>
|
||||
<option value="progress">Progress</option>
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<TrackingToolbar
|
||||
{loggedIn}
|
||||
{totalCount}
|
||||
{activeTrackerId}
|
||||
{statusFilter}
|
||||
{statusOptions}
|
||||
{searchQuery}
|
||||
{sortBy}
|
||||
loading={trackingState.loadingAll}
|
||||
onRefresh={() => trackingState.loadAll()}
|
||||
onTrackerChange={(id) => { activeTrackerId = id; statusFilter = "all"; }}
|
||||
onStatusChange={(v) => statusFilter = v}
|
||||
onSearchChange={(v) => searchQuery = v}
|
||||
onSortChange={(v) => sortBy = v}
|
||||
/>
|
||||
|
||||
<div class="body">
|
||||
{#if trackingState.loadingAll}
|
||||
@@ -240,240 +81,28 @@
|
||||
{:else}
|
||||
<div class="grid">
|
||||
{#each filtered as record (record.tracker.id + ":" + record.id)}
|
||||
{@const isBusy = updatingId === record.id}
|
||||
{@const isSyncing = syncingId === record.id}
|
||||
{@const progress = calcProgress(record.lastChapterRead, record.totalChapters)}
|
||||
{@const stars = scoreToStars(record.displayScore, record.tracker.scores)}
|
||||
|
||||
<div class="card" class:busy={isBusy}>
|
||||
|
||||
<div class="cover-wrap">
|
||||
<div class="cover-click"
|
||||
role="button" tabindex="0"
|
||||
onclick={() => openManga(record)}
|
||||
onkeydown={(e) => e.key === "Enter" && openManga(record)}
|
||||
>
|
||||
{#if record.manga?.thumbnailUrl}
|
||||
<Thumbnail src={record.manga.thumbnailUrl} alt={record.title} class="cover-img" />
|
||||
{:else}
|
||||
<div class="cover-empty"></div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="cover-actions">
|
||||
{#if record.private}
|
||||
<span class="cover-btn" title="Private"><Lock size={10} weight="fill" /></span>
|
||||
{/if}
|
||||
{#if isSyncing}
|
||||
<span class="cover-btn"><CircleNotch size={10} weight="light" class="anim-spin" /></span>
|
||||
{:else}
|
||||
<button class="cover-btn" title="Sync from tracker" onclick={() => syncRecord(record)}>
|
||||
<ArrowsClockwise size={10} weight="light" />
|
||||
</button>
|
||||
{/if}
|
||||
{#if record.remoteUrl}
|
||||
<a href={record.remoteUrl} target="_blank" rel="noreferrer" class="cover-btn" title="Open on {record.tracker.name}">
|
||||
<ArrowSquareOut size={10} weight="light" />
|
||||
</a>
|
||||
{/if}
|
||||
<button class="cover-btn destroy" title="Unlink" onclick={() => confirmUnbind = record} disabled={isBusy}>
|
||||
<X size={10} weight="bold" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="tracker-badge">
|
||||
<Thumbnail src={record.tracker.icon} alt={record.tracker.name} class="badge-img" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="stars">
|
||||
{#each Array(5) as _, i}
|
||||
<span class="star" class:lit={i < stars}>★</span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="title-block"
|
||||
role="button" tabindex="0"
|
||||
onclick={() => openManga(record)}
|
||||
onkeydown={(e) => e.key === "Enter" && openManga(record)}
|
||||
>
|
||||
<span class="title">{record.title}</span>
|
||||
{#if record.manga?.title && record.manga.title !== record.title}
|
||||
<span class="local-title">{record.manga.title}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="controls-row">
|
||||
<select class="status-select"
|
||||
value={record.status} disabled={isBusy}
|
||||
onchange={(e) => updateStatus(record, parseInt((e.target as HTMLSelectElement).value))}>
|
||||
{#each (record.tracker.statuses ?? []) as s}
|
||||
<option value={s.value}>{s.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<select class="score-select"
|
||||
value={record.displayScore} disabled={isBusy}
|
||||
onchange={(e) => updateScore(record, (e.target as HTMLSelectElement).value)}>
|
||||
{#each (record.tracker.scores ?? []) as s}
|
||||
<option value={s}>★ {s}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if editingChapter === record.id}
|
||||
<div class="chapter-editor" role="presentation" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="chapter-editor-top">
|
||||
<span class="chapter-label">Chapter</span>
|
||||
<div class="chapter-input-row">
|
||||
<input
|
||||
type="number" class="chapter-input"
|
||||
min="0" max={record.totalChapters > 0 ? record.totalChapters : undefined}
|
||||
step="0.5" bind:value={chapterDraft}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === "Enter") submitChapter(record);
|
||||
if (e.key === "Escape") editingChapter = null;
|
||||
}}
|
||||
use:focusEl
|
||||
/>
|
||||
{#if record.totalChapters > 0}
|
||||
<span class="chapter-total">/ {record.totalChapters}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if record.totalChapters > 0}
|
||||
<input type="range" class="chapter-slider" min="0" max={record.totalChapters} step="1" bind:value={chapterDraft} />
|
||||
{/if}
|
||||
<div class="chapter-actions">
|
||||
<button class="chapter-cancel" onclick={() => editingChapter = null}>Cancel</button>
|
||||
<button class="chapter-save" onclick={() => submitChapter(record)}>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="progress-block"
|
||||
role="button" tabindex="0"
|
||||
onclick={() => openChapterEditor(record)}
|
||||
onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)}
|
||||
>
|
||||
<div class="progress-labels">
|
||||
<span class="progress-text">
|
||||
{#if progress !== null}
|
||||
Ch. {record.lastChapterRead} / {record.totalChapters}
|
||||
{:else if record.lastChapterRead > 0}
|
||||
Ch. {record.lastChapterRead} read
|
||||
{:else}
|
||||
Set chapter…
|
||||
{/if}
|
||||
</span>
|
||||
{#if progress !== null}
|
||||
<span class="progress-pct">{Math.round(progress)}%</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="progress-track">
|
||||
<div class="progress-fill" style="width:{progress ?? 0}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<TrackingCard
|
||||
{record}
|
||||
active={selectedRecord?.id === record.id && selectedRecord?.tracker.id === record.tracker.id}
|
||||
onSelect={(r) => selectedRecord = r}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if confirmUnbind}
|
||||
{@const r = confirmUnbind}
|
||||
<div class="modal-backdrop" role="presentation" onclick={() => confirmUnbind = null}>
|
||||
<div class="modal" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="modal-icon"><X size={16} weight="bold" /></div>
|
||||
<p class="modal-title">Unlink from {r.tracker.name}?</p>
|
||||
<p class="modal-body">
|
||||
<strong>{r.title}</strong> will be removed from your list. Your progress on {r.tracker.name} is unaffected.
|
||||
</p>
|
||||
<div class="modal-actions">
|
||||
<button class="modal-cancel" onclick={() => confirmUnbind = null}>Cancel</button>
|
||||
<button class="modal-confirm" onclick={async () => { const rec = r; confirmUnbind = null; await unbind(rec); }}>Unlink</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if selectedRecord}
|
||||
<TrackingPreview record={selectedRecord} onClose={() => selectedRecord = null} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.page { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.16s ease both; }
|
||||
.page { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
|
||||
|
||||
.header { flex-shrink: 0; border-bottom: 1px solid var(--border-dim); }
|
||||
.header-top {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: var(--sp-4) var(--sp-6) var(--sp-3);
|
||||
.body {
|
||||
flex: 1; overflow-y: auto; padding: var(--sp-5);
|
||||
scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent;
|
||||
}
|
||||
.heading {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
||||
}
|
||||
.icon-btn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 26px; height: 26px; border-radius: var(--radius-sm);
|
||||
color: var(--text-faint); background: none;
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.icon-btn:hover:not(:disabled) { color: var(--text-muted); background: var(--bg-raised); }
|
||||
.icon-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
.tracker-tabs {
|
||||
display: flex; align-items: center; gap: 1px;
|
||||
padding: 0 var(--sp-5); overflow-x: auto; scrollbar-width: none;
|
||||
}
|
||||
.tracker-tabs::-webkit-scrollbar { display: none; }
|
||||
.tracker-tab {
|
||||
display: flex; align-items: center; gap: var(--sp-2);
|
||||
padding: 9px 10px 8px;
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
color: var(--text-faint); background: none; border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer; white-space: nowrap; margin-bottom: -1px;
|
||||
transition: color var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.tracker-tab:hover { color: var(--text-muted); }
|
||||
.tracker-tab.active { color: var(--text-secondary); border-bottom-color: var(--accent); }
|
||||
:global(.tab-icon) { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; opacity: 0.85; }
|
||||
.tab-pill {
|
||||
font-size: 10px; padding: 0 5px; border-radius: var(--radius-full);
|
||||
background: var(--bg-overlay); color: var(--text-faint);
|
||||
min-width: 18px; text-align: center; line-height: 17px;
|
||||
}
|
||||
.tracker-tab.active .tab-pill { background: var(--accent-muted); color: var(--accent-fg); }
|
||||
|
||||
.filter-bar {
|
||||
display: flex; align-items: center; gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-5);
|
||||
border-top: 1px solid var(--border-dim);
|
||||
}
|
||||
.search-wrap {
|
||||
display: flex; align-items: center; gap: var(--sp-2); flex: 1;
|
||||
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md); padding: 4px 10px;
|
||||
}
|
||||
:global(.search-ico) { color: var(--text-faint); flex-shrink: 0; }
|
||||
.filter-input {
|
||||
flex: 1; background: none; border: none; outline: none;
|
||||
font-size: var(--text-sm); color: var(--text-primary); min-width: 0;
|
||||
}
|
||||
.filter-input::placeholder { color: var(--text-faint); }
|
||||
.filter-select {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 4px 22px 4px 8px; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-dim); background: var(--bg-raised);
|
||||
color: var(--text-faint); outline: none; cursor: pointer; appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23555' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat; background-position: right 6px center;
|
||||
transition: border-color var(--t-base), color var(--t-base);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.filter-select:hover { border-color: var(--border-strong); color: var(--text-muted); }
|
||||
.filter-select option { background: var(--bg-surface); color: var(--text-secondary); }
|
||||
|
||||
.body { flex: 1; overflow-y: auto; padding: var(--sp-5); scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; }
|
||||
|
||||
.state {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
@@ -482,6 +111,7 @@
|
||||
.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); max-width: 260px; line-height: 1.5; }
|
||||
.state-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); letter-spacing: var(--tracking-wide); }
|
||||
|
||||
.ghost-btn {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 5px 14px; border-radius: var(--radius-md);
|
||||
@@ -495,206 +125,4 @@
|
||||
grid-template-columns: repeat(auto-fill, minmax(178px, 1fr));
|
||||
gap: var(--sp-4); align-content: start;
|
||||
}
|
||||
|
||||
.card {
|
||||
display: flex; flex-direction: column;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--border-dim);
|
||||
background: var(--bg-raised);
|
||||
overflow: hidden;
|
||||
transition: border-color var(--t-base), transform var(--t-base), opacity var(--t-base);
|
||||
}
|
||||
.card:hover { border-color: var(--border-strong); transform: translateY(-1px); }
|
||||
.card.busy { opacity: 0.35; pointer-events: none; }
|
||||
|
||||
.cover-wrap { position: relative; aspect-ratio: 2/3; flex-shrink: 0; overflow: hidden; background: var(--bg-overlay); }
|
||||
.cover-click { position: absolute; inset: 0; cursor: pointer; }
|
||||
:global(.cover-img) { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform 0.35s ease, opacity 0.2s ease; }
|
||||
.cover-wrap:hover :global(.cover-img) { transform: scale(1.04); opacity: 0.85; }
|
||||
.cover-empty { width: 100%; height: 100%; background: var(--bg-overlay); }
|
||||
|
||||
.cover-actions {
|
||||
position: absolute; top: 6px; right: 6px; z-index: 2;
|
||||
display: flex; gap: 2px; opacity: 0;
|
||||
transition: opacity var(--t-base);
|
||||
}
|
||||
.cover-wrap:hover .cover-actions { opacity: 1; }
|
||||
.cover-btn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 22px; height: 22px; border-radius: var(--radius-sm);
|
||||
background: rgba(0,0,0,0.55); backdrop-filter: blur(8px);
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
color: rgba(255,255,255,0.7); cursor: pointer; text-decoration: none;
|
||||
transition: background var(--t-base), color var(--t-base);
|
||||
}
|
||||
.cover-btn:hover { background: rgba(0,0,0,0.75); color: #fff; }
|
||||
.cover-btn.destroy:hover { background: rgba(180,40,40,0.65); }
|
||||
.cover-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
.tracker-badge {
|
||||
position: absolute; bottom: 8px; right: 8px; z-index: 2;
|
||||
width: 20px; height: 20px; border-radius: 5px;
|
||||
border: 1px solid rgba(0,0,0,0.3); background: var(--bg-raised);
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.5); overflow: hidden;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
:global(.badge-img) { width: 100%; height: 100%; object-fit: contain; display: block; }
|
||||
|
||||
.card-body { display: flex; flex-direction: column; gap: 9px; padding: 11px 12px 12px; }
|
||||
|
||||
.stars { display: flex; gap: 2px; align-items: center; }
|
||||
.star { font-size: 13px; line-height: 1; color: var(--border-strong); transition: color var(--t-base); }
|
||||
.star.lit { color: #f5c518; }
|
||||
|
||||
.title-block {
|
||||
display: flex; flex-direction: column; gap: 2px;
|
||||
cursor: pointer; min-width: 0;
|
||||
}
|
||||
.title {
|
||||
font-size: var(--text-sm); font-weight: var(--weight-medium);
|
||||
color: var(--text-secondary); line-height: 1.38;
|
||||
display: -webkit-box; -webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical; overflow: hidden;
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
.title-block:hover .title { color: var(--accent-fg); }
|
||||
.local-title {
|
||||
font-family: var(--font-ui); font-size: 10px; color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.controls-row { display: flex; align-items: center; gap: var(--sp-1); }
|
||||
.status-select {
|
||||
flex: 1; min-width: 0;
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 4px 18px 4px 8px; border-radius: var(--radius-full);
|
||||
border: 1px solid var(--border-dim); background: var(--bg-overlay);
|
||||
color: var(--text-muted); outline: none; cursor: pointer; appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5'%3E%3Cpath d='M1 1l3 3 3-3' stroke='%23555' stroke-width='1.3' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat; background-position: right 6px center;
|
||||
transition: border-color var(--t-base), color var(--t-base);
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.status-select:hover:not(:disabled) { border-color: var(--border-strong); color: var(--text-secondary); }
|
||||
.status-select:disabled { opacity: 0.35; cursor: default; }
|
||||
.status-select option { background: var(--bg-surface); color: var(--text-secondary); }
|
||||
|
||||
.score-select {
|
||||
flex-shrink: 0; width: 54px;
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 4px 14px 4px 5px; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-dim); background: var(--bg-overlay);
|
||||
color: var(--text-faint); outline: none; cursor: pointer; appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5'%3E%3Cpath d='M1 1l3 3 3-3' stroke='%23555' stroke-width='1.3' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat; background-position: right 4px center;
|
||||
transition: border-color var(--t-base), color var(--t-base);
|
||||
}
|
||||
.score-select:hover:not(:disabled) { border-color: var(--border-strong); color: var(--text-secondary); }
|
||||
.score-select:disabled { opacity: 0.35; cursor: default; }
|
||||
.score-select option { background: var(--bg-surface); color: var(--text-secondary); }
|
||||
|
||||
.progress-block {
|
||||
display: flex; flex-direction: column; gap: 6px;
|
||||
padding: 4px 5px; margin: 0 -5px;
|
||||
cursor: pointer; border-radius: var(--radius-sm);
|
||||
transition: background var(--t-fast);
|
||||
}
|
||||
.progress-block:hover { background: var(--bg-overlay); }
|
||||
.progress-labels { display: flex; align-items: center; justify-content: space-between; }
|
||||
.progress-text { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
|
||||
.progress-pct { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.progress-track { height: 2px; background: var(--border-strong); border-radius: var(--radius-full); overflow: hidden; }
|
||||
.progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
|
||||
|
||||
.chapter-editor {
|
||||
display: flex; flex-direction: column; gap: var(--sp-2);
|
||||
padding: var(--sp-2); border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-dim); background: var(--bg-surface);
|
||||
}
|
||||
.chapter-editor-top { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); }
|
||||
.chapter-label { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.chapter-input-row { display: flex; align-items: center; gap: var(--sp-1); }
|
||||
.chapter-input {
|
||||
width: 52px; background: var(--bg-raised);
|
||||
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
|
||||
padding: 3px 6px; font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-primary); outline: none; text-align: center;
|
||||
appearance: none; -moz-appearance: textfield;
|
||||
}
|
||||
.chapter-input:focus { border-color: var(--accent); }
|
||||
.chapter-input::-webkit-outer-spin-button,
|
||||
.chapter-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
||||
.chapter-total { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.chapter-slider { width: 100%; accent-color: var(--accent); cursor: pointer; height: 3px; }
|
||||
.chapter-actions { display: flex; align-items: center; gap: var(--sp-2); justify-content: flex-end; }
|
||||
.chapter-save {
|
||||
font-family: var(--font-ui); font-size: 10px; letter-spacing: var(--tracking-wide);
|
||||
padding: 3px 10px; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--accent-dim); background: var(--accent-muted);
|
||||
color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base);
|
||||
}
|
||||
.chapter-save:hover { filter: brightness(1.15); }
|
||||
.chapter-cancel {
|
||||
font-family: var(--font-ui); font-size: 10px; letter-spacing: var(--tracking-wide);
|
||||
padding: 3px 6px; border-radius: var(--radius-sm);
|
||||
border: none; background: none; color: var(--text-faint);
|
||||
cursor: pointer; transition: color var(--t-base);
|
||||
}
|
||||
.chapter-cancel:hover { color: var(--text-muted); }
|
||||
|
||||
.modal-backdrop {
|
||||
position: fixed; inset: 0; z-index: 200;
|
||||
background: rgba(0,0,0,0.5); backdrop-filter: blur(4px);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
animation: fadeIn 0.12s ease both;
|
||||
}
|
||||
.modal {
|
||||
background: var(--bg-surface); border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-xl); padding: var(--sp-6);
|
||||
width: 300px; max-width: calc(100vw - 32px);
|
||||
display: flex; flex-direction: column; align-items: center; gap: var(--sp-3);
|
||||
box-shadow: 0 24px 60px rgba(0,0,0,0.5);
|
||||
animation: modalIn 0.18s cubic-bezier(0.34,1.56,0.64,1) both;
|
||||
}
|
||||
.modal-icon {
|
||||
width: 36px; height: 36px; border-radius: 50%;
|
||||
background: rgba(200,50,50,0.1); border: 1px solid rgba(200,50,50,0.2);
|
||||
color: var(--color-error); display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.modal-title {
|
||||
font-size: var(--text-sm); font-weight: var(--weight-medium);
|
||||
color: var(--text-primary); text-align: center; margin: 0;
|
||||
}
|
||||
.modal-body {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-muted); text-align: center; line-height: 1.5; margin: 0;
|
||||
}
|
||||
.modal-body strong { color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||
.modal-actions { display: flex; gap: var(--sp-2); width: 100%; margin-top: var(--sp-1); }
|
||||
.modal-cancel {
|
||||
flex: 1; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 8px 0; border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-dim); background: none;
|
||||
color: var(--text-muted); cursor: pointer;
|
||||
transition: border-color var(--t-base), color var(--t-base);
|
||||
}
|
||||
.modal-cancel:hover { border-color: var(--border-strong); color: var(--text-primary); }
|
||||
.modal-confirm {
|
||||
flex: 1; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 8px 0; border-radius: var(--radius-md);
|
||||
border: 1px solid rgba(200,50,50,0.25); background: rgba(200,50,50,0.08);
|
||||
color: var(--color-error); cursor: pointer;
|
||||
transition: filter var(--t-base), background var(--t-base);
|
||||
}
|
||||
.modal-confirm:hover { filter: brightness(1.2); background: rgba(200,50,50,0.16); }
|
||||
|
||||
@keyframes modalIn {
|
||||
from { opacity: 0; transform: scale(0.94) translateY(6px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(4px); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script lang="ts">
|
||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||
import type { FlatRecord } from "../lib/trackingSync";
|
||||
import { calcProgress } from "../lib/trackingSync";
|
||||
|
||||
interface Props {
|
||||
record: FlatRecord;
|
||||
active: boolean;
|
||||
onSelect: (r: FlatRecord) => void;
|
||||
}
|
||||
|
||||
let { record, active, onSelect }: Props = $props();
|
||||
|
||||
const progress = $derived(calcProgress(record.lastChapterRead, record.totalChapters));
|
||||
</script>
|
||||
|
||||
<button class="card" class:active onclick={() => onSelect(record)}>
|
||||
<div class="cover-wrap">
|
||||
{#if record.manga?.thumbnailUrl}
|
||||
<Thumbnail src={record.manga.thumbnailUrl} alt={record.title} class="cover" />
|
||||
{:else}
|
||||
<div class="cover-empty"></div>
|
||||
{/if}
|
||||
<div class="tracker-badge">
|
||||
<Thumbnail src={record.tracker.icon} alt={record.tracker.name} class="badge-img" />
|
||||
</div>
|
||||
{#if progress !== null}
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width:{progress}%"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="title">{record.title}</p>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.card {
|
||||
background: none; border: none; padding: 0;
|
||||
cursor: pointer; text-align: left;
|
||||
}
|
||||
.card:hover .cover-wrap { border-color: var(--border-strong); transform: translateY(-2px); box-shadow: 0 6px 20px rgba(0,0,0,0.35); }
|
||||
.card:hover .title { color: var(--text-primary); }
|
||||
.card.active .cover-wrap { outline: 2px solid var(--accent); outline-offset: 2px; border-color: var(--accent-dim); }
|
||||
.card.active .title { color: var(--accent-fg); }
|
||||
|
||||
.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);
|
||||
transition: transform 0.18s cubic-bezier(0.16,1,0.3,1), border-color var(--t-base), box-shadow 0.18s cubic-bezier(0.16,1,0.3,1);
|
||||
}
|
||||
:global(.cover) { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||
.cover-empty { width: 100%; height: 100%; background: var(--bg-overlay); }
|
||||
|
||||
.tracker-badge {
|
||||
position: absolute; bottom: 6px; left: 6px; z-index: 2;
|
||||
width: 18px; height: 18px; border-radius: 4px;
|
||||
border: 1px solid rgba(0,0,0,0.3); background: var(--bg-raised);
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.4); overflow: hidden;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
:global(.badge-img) { width: 100%; height: 100%; object-fit: contain; display: block; }
|
||||
|
||||
.progress-bar {
|
||||
position: absolute; bottom: 0; left: 0; right: 0;
|
||||
height: 2px; background: rgba(0,0,0,0.4);
|
||||
}
|
||||
.progress-fill { height: 100%; background: var(--accent); transition: width 0.3s ease; }
|
||||
|
||||
.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;
|
||||
height: 2lh;
|
||||
transition: color var(--t-base);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,603 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from "svelte";
|
||||
import { X, ArrowSquareOut, ArrowsClockwise, Lock, CircleNotch, Books } from "phosphor-svelte";
|
||||
import { gql } from "@api/client";
|
||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||
import { GET_CHAPTERS } from "@api/queries/chapters";
|
||||
import { store } from "@store/state.svelte";
|
||||
import { addToast, setActiveManga, setNavPage } from "@store/state.svelte";
|
||||
import { trackingState } from "@features/tracking/store/trackingState.svelte";
|
||||
import type { Chapter } from "@types/index";
|
||||
import { calcProgress, type FlatRecord } from "../lib/trackingSync";
|
||||
|
||||
interface Props {
|
||||
record: FlatRecord;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let { record, onClose }: Props = $props();
|
||||
|
||||
let updatingId = $state<number | null>(null);
|
||||
let syncingId = $state<number | null>(null);
|
||||
let editingChapter = $state(false);
|
||||
let chapterDraft = $state(record.lastChapterRead);
|
||||
let scoreDraft = $state(record.displayScore ?? "");
|
||||
let confirmUnbind = $state(false);
|
||||
|
||||
const isBusy = $derived(updatingId === record.id);
|
||||
const isSyncing = $derived(syncingId === record.id);
|
||||
const progress = $derived(calcProgress(record.lastChapterRead, record.totalChapters));
|
||||
const statusName = $derived(record.tracker.statuses?.find(s => s.value === record.status)?.name);
|
||||
|
||||
function prefsForManga(mangaId: number) {
|
||||
return store.settings.mangaPrefs?.[mangaId] ?? {};
|
||||
}
|
||||
|
||||
async function updateStatus(status: number) {
|
||||
const mangaId = record.manga?.id ?? null;
|
||||
if (mangaId === null) return;
|
||||
updatingId = record.id;
|
||||
try {
|
||||
await trackingState.updateStatus(mangaId, record, status);
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||
} finally { updatingId = null; }
|
||||
}
|
||||
|
||||
async function submitScore() {
|
||||
const val = String(scoreDraft).trim();
|
||||
if (val === String(record.displayScore ?? "")) return;
|
||||
const mangaId = record.manga?.id ?? null;
|
||||
if (mangaId === null) return;
|
||||
updatingId = record.id;
|
||||
try {
|
||||
await trackingState.updateScore(mangaId, record, val);
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||
} finally { updatingId = null; }
|
||||
}
|
||||
|
||||
async function submitChapter() {
|
||||
const val = Math.max(0, chapterDraft);
|
||||
editingChapter = false;
|
||||
if (val === record.lastChapterRead) return;
|
||||
const mangaId = record.manga?.id ?? null;
|
||||
if (mangaId === null) return;
|
||||
updatingId = record.id;
|
||||
try {
|
||||
await trackingState.updateChapterProgress(mangaId, record, val);
|
||||
if (store.settings.trackerSyncBack && record.manga?.id) {
|
||||
const chapRes = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: record.manga.id });
|
||||
await trackingState.syncFromRemote(mangaId, { ...record, lastChapterRead: val }, chapRes.chapters.nodes, prefsForManga(mangaId));
|
||||
}
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Update failed", body: e?.message });
|
||||
} finally { updatingId = null; }
|
||||
}
|
||||
|
||||
async function syncRecord() {
|
||||
const mangaId = record.manga?.id ?? null;
|
||||
if (mangaId === null) return;
|
||||
syncingId = record.id;
|
||||
try {
|
||||
let chapters: Chapter[] = [];
|
||||
if (store.settings.trackerSyncBack && record.manga?.id) {
|
||||
const res = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: record.manga.id });
|
||||
chapters = res.chapters.nodes;
|
||||
}
|
||||
const { markedIds } = await trackingState.syncFromRemote(mangaId, record, chapters, prefsForManga(mangaId));
|
||||
const body = markedIds.length > 0 ? `${markedIds.length} chapter${markedIds.length !== 1 ? "s" : ""} marked read` : undefined;
|
||||
addToast({ kind: "success", title: "Synced from tracker", body });
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Sync failed", body: e?.message });
|
||||
} finally { syncingId = null; }
|
||||
}
|
||||
|
||||
async function unbind() {
|
||||
const mangaId = record.manga?.id ?? null;
|
||||
if (mangaId === null) return;
|
||||
updatingId = record.id;
|
||||
confirmUnbind = false;
|
||||
try {
|
||||
await trackingState.unbind(mangaId, record);
|
||||
addToast({ kind: "info", title: `Unlinked from ${record.tracker.name}` });
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
addToast({ kind: "error", title: "Unbind failed", body: e?.message });
|
||||
} finally { updatingId = null; }
|
||||
}
|
||||
|
||||
function openManga() {
|
||||
if (!record.manga) return;
|
||||
setActiveManga(record.manga as any);
|
||||
setNavPage("library");
|
||||
onClose();
|
||||
}
|
||||
|
||||
function focusEl(node: HTMLElement) { setTimeout(() => node.focus(), 0); }
|
||||
|
||||
function onKey(e: KeyboardEvent) { if (e.key === "Escape") onClose(); }
|
||||
onMount(() => window.addEventListener("keydown", onKey));
|
||||
onDestroy(() => window.removeEventListener("keydown", onKey));
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="backdrop"
|
||||
role="presentation"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div class="modal" role="dialog" aria-label="Tracking detail">
|
||||
|
||||
<div class="cover-col">
|
||||
<div class="cover-wrap">
|
||||
{#if record.manga?.thumbnailUrl}
|
||||
<div class="cover-glow" style="background-image:url({record.manga.thumbnailUrl})"></div>
|
||||
<Thumbnail src={record.manga.thumbnailUrl} alt={record.title} class="cover" />
|
||||
{:else}
|
||||
<div class="cover-empty"></div>
|
||||
{/if}
|
||||
<div class="tracker-badge">
|
||||
<Thumbnail src={record.tracker.icon} alt={record.tracker.name} class="tracker-badge-img" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-actions">
|
||||
{#if isSyncing}
|
||||
<div class="action-btn action-btn-inert">
|
||||
<CircleNotch size={13} weight="light" class="anim-spin" />
|
||||
<span class="action-label">Syncing…</span>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="action-btn" onclick={syncRecord} disabled={isBusy}>
|
||||
<ArrowsClockwise size={13} weight="light" />
|
||||
<span class="action-label">Sync from tracker</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if record.manga}
|
||||
<button class="action-btn" onclick={openManga}>
|
||||
<Books size={13} weight="light" />
|
||||
<span class="action-label">Go to series</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if record.remoteUrl}
|
||||
<a href={record.remoteUrl} target="_blank" rel="noreferrer" class="action-btn">
|
||||
<Thumbnail src={record.tracker.icon} alt={record.tracker.name} class="tracker-icon" />
|
||||
<span class="action-label">Open on {record.tracker.name}</span>
|
||||
<ArrowSquareOut size={11} weight="light" style="flex-shrink:0;opacity:0.5" />
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<button class="action-btn action-danger" onclick={() => confirmUnbind = true} disabled={isBusy}>
|
||||
<X size={12} weight="bold" />
|
||||
<span class="action-label">Unlink</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="content-header">
|
||||
<div class="title-block">
|
||||
<h2 class="title">{record.title}</h2>
|
||||
{#if record.manga?.title && record.manga.title !== record.title}
|
||||
<p class="byline">{record.manga.title}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="close-btn" onclick={onClose}><X size={15} weight="light" /></button>
|
||||
</div>
|
||||
|
||||
<div class="content-body">
|
||||
|
||||
<div class="badges">
|
||||
<span class="badge badge-tracker">
|
||||
<Thumbnail src={record.tracker.icon} alt={record.tracker.name} class="badge-icon" />
|
||||
{record.tracker.name}
|
||||
</span>
|
||||
{#if statusName}
|
||||
<span class="badge badge-accent">{statusName}</span>
|
||||
{/if}
|
||||
{#if record.private}
|
||||
<span class="badge badge-private"><Lock size={10} weight="fill" /> Private</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="progress-box">
|
||||
<div class="progress-box-top">
|
||||
<div class="progress-stat">
|
||||
<span class="progress-stat-value">{record.lastChapterRead > 0 ? record.lastChapterRead : "—"}</span>
|
||||
<span class="progress-stat-label">read</span>
|
||||
</div>
|
||||
{#if record.totalChapters > 0}
|
||||
<div class="progress-divider"></div>
|
||||
<div class="progress-stat">
|
||||
<span class="progress-stat-value">{record.totalChapters}</span>
|
||||
<span class="progress-stat-label">total</span>
|
||||
</div>
|
||||
<div class="progress-divider"></div>
|
||||
<div class="progress-stat">
|
||||
<span class="progress-stat-value">{Math.max(0, record.totalChapters - record.lastChapterRead)}</span>
|
||||
<span class="progress-stat-label">left</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !editingChapter}
|
||||
<button class="edit-btn" onclick={() => { editingChapter = true; chapterDraft = record.lastChapterRead; }} disabled={isBusy}>
|
||||
Edit
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if progress !== null}
|
||||
<div class="progress-track">
|
||||
<div class="progress-fill" style="width:{progress}%"></div>
|
||||
</div>
|
||||
<span class="progress-pct">{Math.round(progress)}% complete</span>
|
||||
{/if}
|
||||
|
||||
{#if editingChapter}
|
||||
<div class="chapter-editor">
|
||||
<div class="chapter-input-row">
|
||||
<input
|
||||
type="number" class="chapter-input"
|
||||
min="0" max={record.totalChapters > 0 ? record.totalChapters : undefined}
|
||||
step="0.5"
|
||||
bind:value={chapterDraft}
|
||||
onkeydown={(e) => { if (e.key === "Enter") submitChapter(); if (e.key === "Escape") editingChapter = false; }}
|
||||
use:focusEl
|
||||
/>
|
||||
{#if record.totalChapters > 0}
|
||||
<span class="chapter-total">/ {record.totalChapters}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if record.totalChapters > 0}
|
||||
<input type="range" class="chapter-slider" min="0" max={record.totalChapters} step="1" bind:value={chapterDraft} />
|
||||
{/if}
|
||||
<div class="chapter-actions">
|
||||
<button class="chapter-cancel" onclick={() => editingChapter = false}>Cancel</button>
|
||||
<button class="chapter-save" onclick={submitChapter}>Save</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="controls-row">
|
||||
<div class="control-group">
|
||||
<span class="control-label">Status</span>
|
||||
<select
|
||||
class="field-select"
|
||||
value={record.status}
|
||||
disabled={isBusy}
|
||||
onchange={(e) => updateStatus(parseInt((e.target as HTMLSelectElement).value))}
|
||||
>
|
||||
{#each (record.tracker.statuses ?? []) as s}
|
||||
<option value={s.value}>{s.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="control-group">
|
||||
<span class="control-label">Score</span>
|
||||
<input
|
||||
type="number"
|
||||
class="field-input"
|
||||
bind:value={scoreDraft}
|
||||
disabled={isBusy}
|
||||
min={record.tracker.scores?.[0] ?? 0}
|
||||
max={record.tracker.scores?.[record.tracker.scores.length - 1] ?? 10}
|
||||
step="0.1"
|
||||
onblur={submitScore}
|
||||
onkeydown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="meta-section">
|
||||
<div class="meta-row">
|
||||
<span class="meta-key">Tracker</span>
|
||||
<span class="meta-val">{record.tracker.name}</span>
|
||||
</div>
|
||||
{#if record.manga?.title}
|
||||
<div class="meta-row">
|
||||
<span class="meta-key">Local title</span>
|
||||
<span class="meta-val">{record.manga.title}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if record.startDate}
|
||||
<div class="meta-row">
|
||||
<span class="meta-key">Started</span>
|
||||
<span class="meta-val">{record.startDate}</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if record.finishDate}
|
||||
<div class="meta-row">
|
||||
<span class="meta-key">Finished</span>
|
||||
<span class="meta-val">{record.finishDate}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if confirmUnbind}
|
||||
<div class="confirm-backdrop" role="presentation" onclick={() => confirmUnbind = false}>
|
||||
<div class="confirm-modal" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="confirm-icon"><X size={16} weight="bold" /></div>
|
||||
<p class="confirm-title">Unlink from {record.tracker.name}?</p>
|
||||
<p class="confirm-body"><strong>{record.title}</strong> will be removed from your list. Your progress on {record.tracker.name} is unaffected.</p>
|
||||
<div class="confirm-actions">
|
||||
<button class="confirm-cancel" onclick={() => confirmUnbind = false}>Cancel</button>
|
||||
<button class="confirm-confirm" onclick={unbind}>Unlink</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<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(720px, calc(100vw - 48px));
|
||||
height: min(520px, calc(100vh - 80px));
|
||||
display: flex; flex-direction: row;
|
||||
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.16s ease both;
|
||||
}
|
||||
|
||||
.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-glow {
|
||||
position: absolute; inset: -20px; z-index: 0;
|
||||
background-size: cover; background-position: center;
|
||||
filter: blur(24px) saturate(1.4);
|
||||
opacity: 0.18;
|
||||
border-radius: var(--radius-md);
|
||||
pointer-events: none;
|
||||
}
|
||||
:global(.cover) {
|
||||
position: relative; z-index: 1;
|
||||
width: 100%; aspect-ratio: 2/3;
|
||||
object-fit: cover;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-dim);
|
||||
display: block;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
||||
}
|
||||
.cover-empty {
|
||||
width: 100%; aspect-ratio: 2/3;
|
||||
border-radius: var(--radius-md); border: 1px solid var(--border-dim);
|
||||
background: var(--bg-overlay);
|
||||
}
|
||||
.tracker-badge {
|
||||
position: absolute; bottom: 7px; right: 7px; z-index: 2;
|
||||
width: 22px; height: 22px; border-radius: 5px;
|
||||
background: var(--bg-surface); border: 1px solid var(--border-base);
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.5);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
:global(.tracker-badge-img) { width: 16px; height: 16px; object-fit: contain; display: block; }
|
||||
|
||||
.col-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; text-decoration: none;
|
||||
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.action-btn-inert { cursor: default; pointer-events: none; }
|
||||
.action-btn:hover:not(:disabled):not(.action-btn-inert) { color: var(--accent-fg); border-color: var(--accent); background: var(--accent-muted); }
|
||||
.action-btn:disabled { opacity: 0.4; cursor: default; }
|
||||
.action-danger:hover:not(:disabled) {
|
||||
color: var(--color-error);
|
||||
border-color: color-mix(in srgb, var(--color-error) 40%, transparent);
|
||||
background: color-mix(in srgb, var(--color-error) 8%, transparent);
|
||||
}
|
||||
.action-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||
:global(.tracker-icon) { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; flex-shrink: 0; }
|
||||
|
||||
.content { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.content-header {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
gap: var(--sp-4); padding: var(--sp-5) var(--sp-6) var(--sp-4);
|
||||
border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
|
||||
}
|
||||
.title-block { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-1); }
|
||||
.title { font-size: var(--text-lg); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); line-height: var(--leading-tight); margin: 0; }
|
||||
.byline { font-size: var(--text-sm); color: var(--text-muted); line-height: var(--leading-snug); margin: 0; }
|
||||
.close-btn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 28px; height: 28px; flex-shrink: 0;
|
||||
border-radius: var(--radius-sm); color: var(--text-faint);
|
||||
background: none; border: none; cursor: pointer;
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||
|
||||
.content-body {
|
||||
flex: 1; min-height: 0; overflow-y: auto;
|
||||
padding: var(--sp-5) var(--sp-6);
|
||||
display: flex; flex-direction: column; gap: var(--sp-4);
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.content-body::-webkit-scrollbar { display: none; }
|
||||
|
||||
.badges { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
|
||||
.badge {
|
||||
display: inline-flex; align-items: center; gap: 5px;
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wide); text-transform: uppercase;
|
||||
padding: 3px 8px; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint);
|
||||
}
|
||||
.badge-accent { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
||||
.badge-tracker { background: var(--bg-overlay); border-color: var(--border-dim); color: var(--text-muted); }
|
||||
.badge-private { background: rgba(245,158,11,0.1); border-color: rgba(245,158,11,0.25); color: #f59e0b; }
|
||||
:global(.badge-icon) { width: 11px; height: 11px; border-radius: 2px; object-fit: contain; }
|
||||
|
||||
.progress-box {
|
||||
display: flex; flex-direction: column; gap: var(--sp-3);
|
||||
padding: var(--sp-4); background: var(--bg-raised);
|
||||
border: 1px solid var(--border-dim); border-radius: var(--radius-md);
|
||||
}
|
||||
.progress-box-top { display: flex; align-items: center; gap: var(--sp-4); }
|
||||
.progress-stat { display: flex; flex-direction: column; align-items: center; gap: 1px; }
|
||||
.progress-stat-value { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); line-height: 1; }
|
||||
.progress-stat-label { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--text-faint); }
|
||||
.progress-divider { width: 1px; height: 24px; background: var(--border-dim); }
|
||||
.edit-btn {
|
||||
margin-left: auto;
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 4px 10px; 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);
|
||||
}
|
||||
.edit-btn:hover:not(:disabled) { color: var(--accent-fg); border-color: var(--accent-dim); }
|
||||
.edit-btn:disabled { opacity: 0.35; cursor: default; }
|
||||
.progress-track { height: 3px; background: var(--bg-overlay); border-radius: var(--radius-full); overflow: hidden; }
|
||||
.progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
|
||||
.progress-pct { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); }
|
||||
|
||||
.chapter-editor { display: flex; flex-direction: column; gap: var(--sp-2); padding-top: var(--sp-1); border-top: 1px solid var(--border-dim); }
|
||||
.chapter-input-row { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.chapter-input {
|
||||
width: 70px; background: var(--bg-surface);
|
||||
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
|
||||
padding: 5px 8px; font-family: var(--font-ui); font-size: var(--text-sm);
|
||||
color: var(--text-primary); outline: none; text-align: center;
|
||||
appearance: none; -moz-appearance: textfield;
|
||||
transition: border-color var(--t-base);
|
||||
}
|
||||
.chapter-input:focus { border-color: var(--accent); }
|
||||
.chapter-input::-webkit-outer-spin-button,
|
||||
.chapter-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
||||
.chapter-total { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||
.chapter-slider { width: 100%; accent-color: var(--accent); cursor: pointer; height: 3px; }
|
||||
.chapter-actions { display: flex; align-items: center; gap: var(--sp-2); justify-content: flex-end; }
|
||||
.chapter-save {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 5px 16px; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--accent-dim); background: var(--accent-muted);
|
||||
color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base);
|
||||
}
|
||||
.chapter-save:hover { filter: brightness(1.15); }
|
||||
.chapter-cancel {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 5px 8px; border-radius: var(--radius-sm);
|
||||
border: none; background: none; color: var(--text-faint);
|
||||
cursor: pointer; transition: color var(--t-base);
|
||||
}
|
||||
.chapter-cancel:hover { color: var(--text-muted); }
|
||||
|
||||
.controls-row { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-4); }
|
||||
.control-group { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||
.control-label {
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.field-select {
|
||||
width: 100%;
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 7px 28px 7px 10px; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-dim); background: var(--bg-overlay);
|
||||
color: var(--text-secondary); outline: none; cursor: pointer; appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5'%3E%3Cpath d='M1 1l3 3 3-3' stroke='%23555' stroke-width='1.3' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat; background-position: right 8px center;
|
||||
transition: border-color var(--t-base), color var(--t-base);
|
||||
}
|
||||
.field-select:hover:not(:disabled) { border-color: var(--border-strong); color: var(--text-primary); }
|
||||
.field-select:disabled { opacity: 0.35; cursor: default; }
|
||||
.field-select option { background: var(--bg-surface); color: var(--text-secondary); }
|
||||
.field-input {
|
||||
width: 100%;
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 7px 10px; border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border-dim); background: var(--bg-overlay);
|
||||
color: var(--text-secondary); outline: none;
|
||||
appearance: none; -moz-appearance: textfield;
|
||||
transition: border-color var(--t-base), color var(--t-base);
|
||||
}
|
||||
.field-input:hover:not(:disabled) { border-color: var(--border-strong); color: var(--text-primary); }
|
||||
.field-input:focus { border-color: var(--accent); color: var(--text-primary); }
|
||||
.field-input:disabled { opacity: 0.35; cursor: default; }
|
||||
.field-input::-webkit-outer-spin-button,
|
||||
.field-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
|
||||
|
||||
.meta-section { display: flex; flex-direction: column; border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); }
|
||||
.meta-row { display: flex; align-items: baseline; gap: var(--sp-3); padding: 5px 0; }
|
||||
.meta-key {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wide); text-transform: uppercase;
|
||||
min-width: 72px; flex-shrink: 0;
|
||||
}
|
||||
.meta-val { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.confirm-backdrop {
|
||||
position: fixed; inset: 0; z-index: calc(var(--z-settings) + 1);
|
||||
background: rgba(0,0,0,0.5); backdrop-filter: blur(4px);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
animation: fadeIn 0.12s ease both;
|
||||
}
|
||||
.confirm-modal {
|
||||
background: var(--bg-surface); border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-xl); padding: var(--sp-6);
|
||||
width: 300px; max-width: calc(100vw - 32px);
|
||||
display: flex; flex-direction: column; align-items: center; gap: var(--sp-3);
|
||||
box-shadow: 0 24px 60px rgba(0,0,0,0.5);
|
||||
animation: scaleIn 0.18s cubic-bezier(0.34,1.56,0.64,1) both;
|
||||
}
|
||||
.confirm-icon {
|
||||
width: 36px; height: 36px; border-radius: 50%;
|
||||
background: rgba(200,50,50,0.1); border: 1px solid rgba(200,50,50,0.2);
|
||||
color: var(--color-error); display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.confirm-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-primary); text-align: center; margin: 0; }
|
||||
.confirm-body { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); text-align: center; line-height: 1.5; margin: 0; }
|
||||
.confirm-body strong { color: var(--text-secondary); font-weight: var(--weight-medium); }
|
||||
.confirm-actions { display: flex; gap: var(--sp-2); width: 100%; margin-top: var(--sp-1); }
|
||||
.confirm-cancel {
|
||||
flex: 1; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 8px 0; border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-dim); background: none;
|
||||
color: var(--text-muted); cursor: pointer;
|
||||
transition: border-color var(--t-base), color var(--t-base);
|
||||
}
|
||||
.confirm-cancel:hover { border-color: var(--border-strong); color: var(--text-primary); }
|
||||
.confirm-confirm {
|
||||
flex: 1; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 8px 0; border-radius: var(--radius-md);
|
||||
border: 1px solid rgba(200,50,50,0.25); background: rgba(200,50,50,0.08);
|
||||
color: var(--color-error); cursor: pointer;
|
||||
transition: filter var(--t-base), background var(--t-base);
|
||||
}
|
||||
.confirm-confirm:hover { filter: brightness(1.2); background: rgba(200,50,50,0.16); }
|
||||
|
||||
:global(.anim-spin) { animation: anim-spin 0.8s linear infinite; }
|
||||
@keyframes anim-spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
|
||||
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||||
</style>
|
||||
@@ -0,0 +1,172 @@
|
||||
<script lang="ts">
|
||||
import { ArrowsClockwise, MagnifyingGlass } from "phosphor-svelte";
|
||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||
import type { SortKey } from "../lib/trackingSync";
|
||||
|
||||
interface Tracker { id: number; name: string; icon: string; trackRecords: { nodes: any[] }; isLoggedIn: boolean; }
|
||||
interface StatusOption { value: number; name: string; }
|
||||
|
||||
interface Props {
|
||||
loggedIn: Tracker[];
|
||||
totalCount: number;
|
||||
activeTrackerId: number | "all";
|
||||
statusFilter: number | "all";
|
||||
statusOptions: StatusOption[];
|
||||
searchQuery: string;
|
||||
sortBy: SortKey;
|
||||
loading: boolean;
|
||||
onRefresh: () => void;
|
||||
onTrackerChange: (id: number | "all") => void;
|
||||
onStatusChange: (v: number | "all") => void;
|
||||
onSearchChange: (v: string) => void;
|
||||
onSortChange: (v: SortKey) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
loggedIn, totalCount, activeTrackerId, statusFilter, statusOptions,
|
||||
searchQuery, sortBy, loading,
|
||||
onRefresh, onTrackerChange, onStatusChange, onSearchChange, onSortChange,
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-top">
|
||||
<span class="heading">Tracking</span>
|
||||
<button class="icon-btn" onclick={onRefresh} disabled={loading} title="Refresh">
|
||||
<ArrowsClockwise size={14} weight="bold" class={loading ? "anim-spin" : ""} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if !loading && loggedIn.length > 0}
|
||||
<div class="tracker-tabs">
|
||||
<button
|
||||
class="tracker-tab" class:active={activeTrackerId === "all"}
|
||||
onclick={() => onTrackerChange("all")}
|
||||
>
|
||||
All
|
||||
<span class="tab-count">{totalCount}</span>
|
||||
</button>
|
||||
{#each loggedIn as t}
|
||||
<button
|
||||
class="tracker-tab" class:active={activeTrackerId === t.id}
|
||||
onclick={() => onTrackerChange(t.id)}
|
||||
>
|
||||
<Thumbnail src={t.icon} alt={t.name} class="tab-icon" />
|
||||
{t.name}
|
||||
<span class="tab-count">{t.trackRecords.nodes.length}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="filter-row">
|
||||
<div class="search-wrap">
|
||||
<MagnifyingGlass size={13} weight="light" class="search-ico" />
|
||||
<input
|
||||
class="search-input"
|
||||
placeholder="Search…"
|
||||
value={searchQuery}
|
||||
oninput={(e) => onSearchChange((e.target as HTMLInputElement).value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
class="pill-select"
|
||||
value={statusFilter}
|
||||
onchange={(e) => {
|
||||
const v = (e.target as HTMLSelectElement).value;
|
||||
onStatusChange(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="pill-select" value={sortBy} onchange={(e) => onSortChange((e.target as HTMLSelectElement).value as SortKey)}>
|
||||
<option value="title">Title</option>
|
||||
<option value="status">Status</option>
|
||||
<option value="score">Score</option>
|
||||
<option value="progress">Progress</option>
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.toolbar { flex-shrink: 0; border-bottom: 1px solid var(--border-dim); }
|
||||
|
||||
.toolbar-top {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: var(--sp-4) var(--sp-6);
|
||||
}
|
||||
.heading {
|
||||
font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal);
|
||||
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
||||
}
|
||||
.icon-btn {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
width: 26px; height: 26px; border-radius: var(--radius-sm);
|
||||
color: var(--text-faint); background: none; border: none; cursor: pointer;
|
||||
transition: color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.icon-btn:hover:not(:disabled) { color: var(--text-muted); background: var(--bg-raised); }
|
||||
.icon-btn:disabled { opacity: 0.3; cursor: default; }
|
||||
|
||||
.tracker-tabs {
|
||||
display: flex; align-items: center; gap: 1px;
|
||||
padding: 0 var(--sp-5); overflow-x: auto; scrollbar-width: none;
|
||||
}
|
||||
.tracker-tabs::-webkit-scrollbar { display: none; }
|
||||
|
||||
.tracker-tab {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 8px 10px 7px;
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
||||
letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
||||
color: var(--text-faint); background: none; border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer; white-space: nowrap; margin-bottom: -1px;
|
||||
transition: color var(--t-base), border-color var(--t-base);
|
||||
}
|
||||
.tracker-tab:hover { color: var(--text-muted); }
|
||||
.tracker-tab.active { color: var(--text-secondary); border-bottom-color: var(--accent); }
|
||||
:global(.tab-icon) { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; opacity: 0.8; }
|
||||
|
||||
.tab-count {
|
||||
font-size: 10px; padding: 1px 5px; border-radius: var(--radius-full);
|
||||
background: var(--bg-overlay); color: var(--text-faint); line-height: 15px;
|
||||
}
|
||||
.tracker-tab.active .tab-count { background: var(--accent-muted); color: var(--accent-fg); }
|
||||
|
||||
.filter-row {
|
||||
display: flex; align-items: center; gap: var(--sp-2);
|
||||
padding: var(--sp-2) var(--sp-5) var(--sp-3);
|
||||
}
|
||||
.search-wrap {
|
||||
flex: 1; display: flex; align-items: center; gap: var(--sp-2);
|
||||
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
||||
border-radius: var(--radius-md); padding: 5px 10px;
|
||||
transition: border-color var(--t-base);
|
||||
}
|
||||
.search-wrap:focus-within { border-color: var(--border-strong); }
|
||||
:global(.search-ico) { color: var(--text-faint); flex-shrink: 0; }
|
||||
.search-input {
|
||||
flex: 1; background: none; border: none; outline: none; min-width: 0;
|
||||
font-size: var(--text-sm); color: var(--text-primary);
|
||||
}
|
||||
.search-input::placeholder { color: var(--text-faint); }
|
||||
|
||||
.pill-select {
|
||||
flex-shrink: 0;
|
||||
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
|
||||
padding: 5px 22px 5px 9px; border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-dim); background: var(--bg-raised);
|
||||
color: var(--text-faint); outline: none; cursor: pointer; appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5'%3E%3Cpath d='M1 1l3 3 3-3' stroke='%23555' stroke-width='1.3' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat; background-position: right 7px center;
|
||||
transition: border-color var(--t-base), color var(--t-base);
|
||||
}
|
||||
.pill-select:hover { border-color: var(--border-strong); color: var(--text-muted); }
|
||||
.pill-select option { background: var(--bg-surface); color: var(--text-secondary); }
|
||||
</style>
|
||||
Reference in New Issue
Block a user