Chore: Port over Reader & Tracking

This commit is contained in:
Youwes09
2026-05-31 21:14:25 -05:00
parent 13f2a483ca
commit c5243ba30c
42 changed files with 6385 additions and 241 deletions
+127
View File
@@ -0,0 +1,127 @@
<script lang="ts">
import { CircleNotch } from 'phosphor-svelte'
import { trackingState } from '$lib/state/tracking.svelte'
import {
flattenRecords, filterRecords, sortRecords, dedupeStatuses,
type FlatRecord, type SortKey,
} from '$lib/components/tracking/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 selectedRecord = $state<FlatRecord | null>(null)
$effect(() => {
if (trackingState.allTrackers.length === 0 && !trackingState.loadingAll) {
trackingState.loadAll()
}
})
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)
)
</script>
<div class="page">
<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}
<div class="state">
<CircleNotch size={18} weight="light" class="anim-spin" style="color:var(--text-faint)" />
</div>
{:else if trackingState.error}
<div class="state">
<span class="state-error">{trackingState.error}</span>
<button class="ghost-btn" onclick={() => trackingState.loadAll()}>Retry</button>
</div>
{:else if loggedIn.length === 0}
<div class="state">
<span class="state-text">No trackers connected.</span>
<span class="state-hint">Settings → Tracking to connect AniList, MAL, or others.</span>
</div>
{:else if filtered.length === 0}
<div class="state">
<span class="state-text">{searchQuery || statusFilter !== 'all' ? 'No results.' : 'Nothing tracked yet.'}</span>
{#if searchQuery || statusFilter !== 'all'}
<button class="ghost-btn" onclick={() => { searchQuery = ''; statusFilter = 'all' }}>Clear filters</button>
{/if}
</div>
{:else}
<div class="grid">
{#each filtered as record (record.tracker.id + ':' + record.id)}
<TrackingCard
{record}
active={selectedRecord?.id === record.id && selectedRecord?.tracker.id === record.tracker.id}
onSelect={(r) => selectedRecord = r}
/>
{/each}
</div>
{/if}
</div>
</div>
{#if selectedRecord}
<TrackingPreview record={selectedRecord} onClose={() => selectedRecord = null} />
{/if}
<style>
.page { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.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;
gap: var(--sp-3); height: 100%; text-align: center;
}
.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);
border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer;
transition: color var(--t-base), border-color var(--t-base);
}
.ghost-btn:hover { color: var(--accent-fg); border-color: var(--accent-dim); }
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(178px, 1fr));
gap: var(--sp-4); align-content: start;
}
</style>
@@ -0,0 +1,79 @@
<script lang="ts">
import Thumbnail from '$lib/components/shared/manga/Thumbnail.svelte'
import type { FlatRecord } from '$lib/components/tracking/lib/trackingSync'
import { calcProgress } from '$lib/components/tracking/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>
@@ -5,7 +5,7 @@
import { addToast } from "$lib/state/notifications.svelte";
import { settingsState } from "$lib/state/settings.svelte";
import { seriesState } from "$lib/state/series.svelte";
import { syncBackFromTracker } from "$lib/state/tracking.svelte";
import { syncBackFromTracker } from "$lib/components/tracking/lib/trackingSync";
import { getChapters } from "$lib/request-manager/chapters";
import { markManyRead } from "$lib/request-manager/chapters";
import type { Tracker, TrackRecord, TrackSearch } from "$lib/types";
@@ -0,0 +1,615 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte'
import { X, ArrowSquareOut, ArrowsClockwise, Lock, CircleNotch, Books } from 'phosphor-svelte'
import Thumbnail from '$lib/components/shared/manga/Thumbnail.svelte'
import { trackingState } from '$lib/state/tracking.svelte'
import { settingsState } from '$lib/state/settings.svelte'
import { addToast } from '$lib/state/notifications.svelte'
import { setNavPage } from '$lib/state/app.svelte'
import { seriesState } from '$lib/state/series.svelte'
import { getAdapter } from '$lib/request-manager'
import { calcProgress, type FlatRecord } from '$lib/components/tracking/lib/trackingSync'
import type { Chapter } from '$lib/types'
import type { ChapterDisplayPrefs } from '$lib/components/series/lib/chapterList'
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(0)
let scoreDraft = $state('')
let confirmUnbind = $state(false)
$effect(() => {
chapterDraft = record.lastChapterRead
scoreDraft = record.displayScore ?? ''
})
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): ChapterDisplayPrefs {
return (settingsState.settings.mangaPrefs?.[mangaId] ?? {}) as ChapterDisplayPrefs
}
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: unknown) {
addToast({ kind: 'error', title: 'Update failed', body: e instanceof Error ? e.message : undefined })
} 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: unknown) {
addToast({ kind: 'error', title: 'Update failed', body: e instanceof Error ? e.message : undefined })
} 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 (settingsState.settings.trackerSyncBack && record.manga?.id) {
const chapters = await getAdapter().getChapters(String(record.manga.id)) as Chapter[]
await trackingState.syncFromRemote(mangaId, { ...record, lastChapterRead: val }, chapters, prefsForManga(mangaId))
}
} catch (e: unknown) {
addToast({ kind: 'error', title: 'Update failed', body: e instanceof Error ? e.message : undefined })
} finally { updatingId = null }
}
async function syncRecord() {
const mangaId = record.manga?.id ?? null
if (mangaId === null) return
syncingId = record.id
try {
let chapters: Chapter[] = []
if (settingsState.settings.trackerSyncBack && record.manga?.id) {
chapters = await getAdapter().getChapters(String(record.manga.id)) as Chapter[]
}
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: unknown) {
addToast({ kind: 'error', title: 'Sync failed', body: e instanceof Error ? e.message : undefined })
} 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: unknown) {
addToast({ kind: 'error', title: 'Unbind failed', body: e instanceof Error ? e.message : undefined })
} finally { updatingId = null }
}
function openManga() {
if (!record.manga) return
// Navigate to series page — set the series and switch nav
seriesState.current = record.manga as any
setNavPage('series')
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="button"
tabindex="-1"
aria-label="Close tracking detail"
onclick={(e) => { if (e.target === e.currentTarget) onClose() }}
onkeydown={(e) => { if (e.key === 'Escape') 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="button" tabindex="-1" aria-label="Cancel" onclick={() => confirmUnbind = false} onkeydown={(e) => { if (e.key === 'Escape') confirmUnbind = false }}>
<div class="confirm-modal" role="dialog" aria-modal="true" tabindex="-1" onclick={(e) => e.stopPropagation()} onkeydown={(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,138 @@
<script lang="ts">
import { ArrowsClockwise, MagnifyingGlass } from 'phosphor-svelte'
import Thumbnail from '$lib/components/shared/manga/Thumbnail.svelte'
import type { SortKey } from '$lib/components/tracking/lib/trackingSync'
interface Tracker { id: number; name: string; icon: string; trackRecords: { nodes: unknown[] }; 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>
{#if !loading && loggedIn.length > 0}
<div class="tabs">
<button
class="tab" class:active={activeTrackerId === 'all'}
onclick={() => onTrackerChange('all')}
>
All
<span class="tab-count">{totalCount}</span>
</button>
{#each loggedIn as t}
<button
class="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>
{/if}
<div class="header-right">
<button class="icon-btn" onclick={onRefresh} disabled={loading} title="Refresh">
<ArrowsClockwise size={14} weight="bold" class={loading ? 'anim-spin' : ''} />
</button>
</div>
</div>
{#if !loading && loggedIn.length > 0}
<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; }
.toolbar-top {
display: flex; align-items: center; gap: var(--sp-4);
padding: var(--sp-4) var(--sp-6);
border-bottom: 1px solid var(--border-dim);
}
.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; flex-shrink: 0; }
.header-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; flex-shrink: 0; }
.tabs { display: flex; align-items: center; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; overflow-x: auto; scrollbar-width: none; }
.tabs::-webkit-scrollbar { display: none; }
.tab { display: 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: 4px 10px; border-radius: var(--radius-sm); border: 1px solid transparent; color: var(--text-faint); white-space: nowrap; cursor: pointer; flex-shrink: 0; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); }
.tab:hover { color: var(--text-muted); }
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); }
:global(.tab-icon) { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; opacity: 0.8; }
.tab-count { font-size: var(--text-2xs); opacity: 0.6; }
.tab.active .tab-count { opacity: 1; }
.icon-btn { display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.icon-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); }
.icon-btn:disabled { opacity: 0.3; cursor: default; }
.filter-row { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-3) var(--sp-6); border-bottom: 1px solid var(--border-dim); }
.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>
@@ -0,0 +1,141 @@
import type { Tracker, TrackRecord } from '$lib/types'
import { buildChapterList, type ChapterDisplayPrefs } from '$lib/components/series/lib/chapterList'
import type { Chapter } from '$lib/types'
export interface TrackerWithRecords extends Tracker {
trackRecords: { nodes: TrackRecord[] }
}
export interface FlatRecord extends TrackRecord {
tracker: Tracker
}
export type SortKey = 'title' | 'status' | 'score' | 'progress'
export function flattenRecords(trackers: TrackerWithRecords[]): FlatRecord[] {
return trackers
.filter((t) => t.isLoggedIn)
.flatMap((t) =>
t.trackRecords.nodes.map((r) => ({
...r,
trackerId: r.trackerId ?? t.id,
tracker: t as Tracker,
}))
)
}
export function dedupeStatuses(trackers: TrackerWithRecords[]): { value: number; name: string }[] {
const seen = new Map<string, { value: number; name: string }>()
for (const t of trackers.filter((t) => t.isLoggedIn))
for (const s of t.statuses ?? [])
seen.set(`${s.value}:${s.name}`, s)
return [...seen.values()]
}
export function filterRecords(
records: FlatRecord[],
trackerId: number | 'all',
statusFilter: number | 'all',
query: string,
): FlatRecord[] {
let list = trackerId === 'all'
? records
: records.filter((r) => Number(r.trackerId) === Number(trackerId))
if (statusFilter !== 'all')
list = list.filter((r) => Number(r.status) === Number(statusFilter))
if (query.trim()) {
const q = query.toLowerCase()
list = list.filter((r) =>
r.title.toLowerCase().includes(q) ||
r.manga?.title?.toLowerCase().includes(q)
)
}
return list
}
export function sortRecords(records: FlatRecord[], sortBy: SortKey): FlatRecord[] {
return [...records].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
})
}
export function calcProgress(lastChapterRead: number, totalChapters: number): number | null {
if (totalChapters <= 0) return null
return Math.min(100, (lastChapterRead / totalChapters) * 100)
}
export interface SyncBackOptions {
threshold: number | null
respectScanlatorFilter: boolean
chapterPrefs: ChapterDisplayPrefs
}
export async function syncBackFromTracker(
records: TrackRecord[],
chapters: Chapter[],
opts: SyncBackOptions,
markRead: (ids: string[], read: boolean) => Promise<void>,
): Promise<number[]> {
const eligible = buildChapterList(chapters, {
...opts.chapterPrefs,
sortDir: 'asc',
...(opts.respectScanlatorFilter ? {} : {
scanlatorFilter: [],
scanlatorBlacklist: [],
scanlatorForce: false,
}),
})
// Dedupe to one chapter per integer floor (prefer exact integer)
const seenInt = new Map<number, Chapter>()
for (const ch of eligible) {
if (!Number.isInteger(ch.chapterNumber)) continue
const key = Math.floor(ch.chapterNumber)
if (!seenInt.has(key)) seenInt.set(key, ch)
}
const dedupedEligible = [...seenInt.values()]
// Also track decimal sub-chapters grouped by their floor
const decimalsByFloor = new Map<number, Chapter[]>()
for (const ch of eligible) {
if (Number.isInteger(ch.chapterNumber)) continue
const key = Math.floor(ch.chapterNumber)
const arr = decimalsByFloor.get(key) ?? []
arr.push(ch)
decimalsByFloor.set(key, arr)
}
const toMarkRead: number[] = []
for (const record of records) {
const remote = record.lastChapterRead
if (!remote || remote <= 0) continue
for (const chapter of dedupedEligible) {
if (chapter.read) continue
if (chapter.chapterNumber > remote) continue
if (opts.threshold !== null && remote - chapter.chapterNumber > opts.threshold) continue
toMarkRead.push(chapter.id)
for (const dec of decimalsByFloor.get(chapter.chapterNumber) ?? []) {
if (!dec.read) toMarkRead.push(dec.id)
}
}
}
const ids = [...new Set(toMarkRead)]
if (ids.length > 0) {
await markRead(ids.map(String), true)
}
return ids
}