Feat: Reworked ENTIRE Project for Readability

This commit is contained in:
Youwes09
2026-04-20 00:19:22 -05:00
parent 005680394e
commit 4b97f4a6c9
191 changed files with 19210 additions and 15915 deletions
@@ -0,0 +1,667 @@
<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_ALL_TRACKER_RECORDS } from "@api/queries";
import { UPDATE_TRACK, UNBIND_TRACK, FETCH_TRACK } from "@api/mutations";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import type { TrackRecord } from "@types/index";
import {
flattenRecords, filterRecords, sortRecords, dedupeStatuses,
scoreToStars, calcProgress, patchTracker, removeRecord,
type TrackerWithRecords, type FlatRecord, type SortKey,
} from "../lib/trackingSync";
let trackers = $state<TrackerWithRecords[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
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);
async function load() {
loading = true; error = null;
try {
const res = await gql<{ trackers: { nodes: TrackerWithRecords[] } }>(GET_ALL_TRACKER_RECORDS);
trackers = res.trackers.nodes;
} catch (e: any) {
error = e?.message ?? "Failed to load tracking data";
} finally { loading = false; }
}
$effect(() => { load(); });
const loggedIn = $derived(trackers.filter((t) => t.isLoggedIn));
const allRecords = $derived(flattenRecords(trackers));
const totalCount = $derived(allRecords.length);
const statusOptions = $derived(
activeTrackerId === "all"
? dedupeStatuses(trackers)
: loggedIn.find((t) => t.id === activeTrackerId)?.statuses ?? []
);
const filtered = $derived(
sortRecords(filterRecords(allRecords, activeTrackerId, statusFilter, searchQuery), sortBy)
);
async function updateStatus(record: FlatRecord, status: number) {
updatingId = record.id;
try {
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, status });
trackers = patchTracker(trackers, record.trackerId, res.updateTrack.trackRecord);
} catch (e: any) {
addToast({ kind: "error", title: "Update failed", body: e?.message });
} finally { updatingId = null; }
}
async function updateScore(record: FlatRecord, scoreString: string) {
updatingId = record.id;
try {
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, scoreString });
trackers = patchTracker(trackers, record.trackerId, res.updateTrack.trackRecord);
} catch (e: any) {
addToast({ kind: "error", title: "Update failed", body: e?.message });
} finally { updatingId = null; }
}
async function syncRecord(record: FlatRecord) {
syncingId = record.id;
try {
const res = await gql<{ fetchTrack: { trackRecord: TrackRecord } }>(FETCH_TRACK, { recordId: record.id });
trackers = patchTracker(trackers, record.trackerId, res.fetchTrack.trackRecord);
addToast({ kind: "success", title: "Synced from tracker" });
} catch (e: any) {
addToast({ kind: "error", title: "Sync failed", body: e?.message });
} finally { syncingId = null; }
}
async function unbind(record: FlatRecord) {
updatingId = record.id;
try {
await gql(UNBIND_TRACK, { recordId: record.id });
trackers = removeRecord(trackers, record.trackerId, record.id);
addToast({ kind: "info", title: `Unlinked from ${record.tracker.name}` });
} catch (e: any) {
addToast({ kind: "error", title: "Unbind failed", body: e?.message });
} finally { updatingId = null; }
}
async function submitChapter(record: FlatRecord) {
const val = Math.max(0, chapterDraft);
editingChapter = null;
if (val === record.lastChapterRead) return;
updatingId = record.id;
try {
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, lastChapterRead: val });
trackers = patchTracker(trackers, record.trackerId, res.updateTrack.trackRecord);
} catch (e: any) {
addToast({ kind: "error", title: "Update 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={load} disabled={loading} title="Refresh">
<ArrowsClockwise size={14} weight="light" class={loading ? "anim-spin" : ""} />
</button>
</div>
{#if !loading && 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>
<div class="body">
{#if loading}
<div class="state">
<CircleNotch size={18} weight="light" class="anim-spin" style="color:var(--text-faint)" />
</div>
{:else if error}
<div class="state">
<span class="state-error">{error}</span>
<button class="ghost-btn" onclick={load}>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)}
{@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" 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.&nbsp;{record.lastChapterRead}&thinsp;/&thinsp;{record.totalChapters}
{:else if record.lastChapterRead > 0}
Ch.&nbsp;{record.lastChapterRead}&nbsp;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>
{/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}
<style>
.page { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.16s ease both; }
.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);
}
.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;
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;
}
.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>
+2
View File
@@ -0,0 +1,2 @@
export { default as Tracking } from "./components/Tracking.svelte";
export * from "./lib/trackingSync";
+111
View File
@@ -0,0 +1,111 @@
import type { Tracker, TrackRecord } from "@types/index";
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 scoreToStars(score: string | undefined, scores: string[] | undefined): number {
if (!score || !scores || scores.length === 0) return 0;
const idx = scores.indexOf(score);
if (idx < 0) return 0;
return Math.round((idx / (scores.length - 1)) * 5);
}
export function calcProgress(lastChapterRead: number, totalChapters: number): number | null {
if (totalChapters <= 0) return null;
return Math.min(100, (lastChapterRead / totalChapters) * 100);
}
export function patchTracker(
trackers: TrackerWithRecords[],
trackerId: number,
updated: Partial<TrackRecord> & { id: number },
): TrackerWithRecords[] {
return trackers.map((t) =>
t.id !== trackerId ? t : {
...t,
trackRecords: {
nodes: t.trackRecords.nodes.map((r) =>
r.id === updated.id ? { ...r, ...updated } : r
),
},
}
);
}
export function removeRecord(
trackers: TrackerWithRecords[],
trackerId: number,
recordId: number,
): TrackerWithRecords[] {
return trackers.map((t) =>
t.id !== trackerId ? t : {
...t,
trackRecords: { nodes: t.trackRecords.nodes.filter((r) => r.id !== recordId) },
}
);
}