Chore: Restructure Repository for SvelteKit

This commit is contained in:
Youwes09
2026-05-22 04:04:59 -05:00
parent bf071dcfc7
commit 8cef74bb98
266 changed files with 5093 additions and 396 deletions
@@ -0,0 +1,453 @@
<script lang="ts">
import { onMount } from "svelte";
import { ClockCounterClockwise, Books, Fire, BookOpen, Clock, TrendUp } from "phosphor-svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import { store, setPreviewManga } from "@store/state.svelte";
import { gql } from "@api/client";
import { GET_LIBRARY } from "@api/queries/manga";
import { cache, CACHE_KEYS } from "@core/cache";
import type { HistoryEntry } from "@store/state.svelte";
import type { Manga } from "@types";
import { timeAgo, dayLabel, formatReadTime } from "@core/util";
interface Props {
search: string;
confirmClear: boolean;
}
let { search, confirmClear }: Props = $props();
let libraryManga = $state<Manga[]>([]);
onMount(() => {
cache.get(CACHE_KEYS.LIBRARY, () =>
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes)
)
.then(m => { libraryManga = m; })
.catch(() => {});
});
function thumbFor(mangaId: number, fallback: string): string {
return libraryManga.find(m => m.id === mangaId)?.thumbnailUrl ?? fallback ?? "";
}
const SESSION_GAP_MS = 30 * 60 * 1000;
interface Session {
mangaId: number;
mangaTitle: string;
thumbnailUrl: string;
latestChapterId: number;
latestChapterName: string;
latestPageNumber: number;
firstChapterName: string;
chapterCount: number;
readAt: number;
}
function buildSessions(entries: HistoryEntry[]): Session[] {
if (!entries.length) return [];
const sessions: Session[] = [];
let i = 0;
while (i < entries.length) {
const anchor = entries[i];
const group: HistoryEntry[] = [anchor];
let j = i + 1;
while (j < entries.length) {
const next = entries[j];
if (next.mangaId === anchor.mangaId && anchor.readAt - next.readAt <= SESSION_GAP_MS) {
group.push(next); j++;
} else break;
}
const latest = group[0], oldest = group[group.length - 1];
sessions.push({
mangaId: latest.mangaId,
mangaTitle: latest.mangaTitle,
thumbnailUrl: latest.thumbnailUrl,
latestChapterId: latest.chapterId,
latestChapterName: latest.chapterName,
latestPageNumber: latest.pageNumber,
firstChapterName: oldest.chapterName,
chapterCount: group.length,
readAt: latest.readAt,
});
i = j;
}
return sessions;
}
const filtered = $derived(search.trim()
? store.history.filter(e =>
e.mangaTitle.toLowerCase().includes(search.toLowerCase()) ||
e.chapterName.toLowerCase().includes(search.toLowerCase())
)
: store.history);
const sessions = $derived(buildSessions(filtered));
const groups = $derived.by(() => {
const map = new Map<string, Session[]>();
for (const s of sessions) {
const l = dayLabel(s.readAt);
if (!map.has(l)) map.set(l, []);
map.get(l)!.push(s);
}
return Array.from(map.entries()).map(([label, items]) => ({ label, items }));
});
</script>
<div class="root anim-fade-in">
{#if store.history.length === 0}
<div class="empty">
<div class="empty-icon-wrap">
<ClockCounterClockwise size={24} weight="light" />
</div>
<p class="empty-text">No reading history yet</p>
<p class="empty-hint">Chapters you read will appear here</p>
</div>
{:else if sessions.length === 0}
<div class="empty">
<div class="empty-icon-wrap">
<Books size={20} weight="light" />
</div>
<p class="empty-text">No results for "{search}"</p>
</div>
{:else}
<div class="timeline">
{#if store.readingStats.totalChaptersRead > 0}
<div class="stats-section">
<div class="stats-header">
<span class="stats-title"><TrendUp size={10} weight="bold" /> Reading Stats</span>
</div>
<div class="stats-grid">
<div class="stat-card">
<div class="stat-icon-wrap fire"><Fire size={14} weight="fill" /></div>
<div class="stat-body">
<span class="stat-val">{store.readingStats.currentStreakDays}</span>
<span class="stat-label">Day streak</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon-wrap accent"><BookOpen size={14} weight="light" /></div>
<div class="stat-body">
<span class="stat-val">{store.readingStats.totalChaptersRead}</span>
<span class="stat-label">Chapters read</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon-wrap neutral"><Clock size={14} weight="light" /></div>
<div class="stat-body">
<span class="stat-val">{formatReadTime(store.readingStats.totalMinutesRead)}</span>
<span class="stat-label">Read time</span>
</div>
</div>
<div class="stat-card">
<div class="stat-icon-wrap neutral"><TrendUp size={14} weight="light" /></div>
<div class="stat-body">
<span class="stat-val">{store.readingStats.totalMangaRead}</span>
<span class="stat-label">Series read</span>
</div>
</div>
</div>
</div>
{/if}
{#each groups as { label, items }}
<div class="day-group">
<div class="day-header">
<span class="day-label">{label}</span>
<div class="day-rule"></div>
</div>
<div class="session-list">
{#each items as session (session.latestChapterId)}
<button class="session-row" onclick={() => setPreviewManga({ id: session.mangaId, title: session.mangaTitle, thumbnailUrl: thumbFor(session.mangaId, session.thumbnailUrl) } as any)}>
<div class="thumb-wrap">
<Thumbnail src={thumbFor(session.mangaId, session.thumbnailUrl)} alt={session.mangaTitle} class="thumb" />
{#if session.chapterCount > 1}
<span class="session-count">{session.chapterCount}</span>
{/if}
</div>
<div class="session-info">
<span class="session-title">{session.mangaTitle}</span>
<span class="session-chapter">
{#if session.chapterCount > 1}
{session.firstChapterName}<span class="ch-arrow"></span>{session.latestChapterName}
{:else}
{session.latestChapterName}
{#if session.latestPageNumber > 1}
<span class="ch-page">· p.{session.latestPageNumber}</span>
{/if}
{/if}
</span>
</div>
<span class="session-time">{timeAgo(session.readAt)}</span>
</button>
{/each}
</div>
</div>
{/each}
</div>
{/if}
</div>
<style>
.root {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.stats-header {
display: flex;
align-items: center;
padding-bottom: var(--sp-2);
}
.stats-title {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
gap: var(--sp-2);
}
.stat-card {
display: flex;
align-items: center;
gap: var(--sp-3);
background: var(--bg-raised);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
padding: var(--sp-3);
transition: border-color var(--t-fast);
}
.stat-card:hover { border-color: var(--border-base); }
.stat-icon-wrap {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: var(--radius-sm);
flex-shrink: 0;
}
.fire { background: rgba(251, 146, 60, 0.15); color: #fb923c; }
.accent { background: var(--accent-muted); color: var(--accent-fg); }
.neutral { background: var(--bg-overlay); color: var(--text-faint); }
.stat-body {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.stat-val {
font-family: var(--font-ui);
font-size: var(--text-lg, 1.05rem);
font-weight: var(--weight-medium);
color: var(--text-secondary);
line-height: 1;
}
.stat-label {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
white-space: nowrap;
}
.timeline {
flex: 1;
overflow-y: auto;
padding: var(--sp-4) var(--sp-6) var(--sp-6);
display: flex;
flex-direction: column;
gap: var(--sp-5);
scrollbar-width: thin;
scrollbar-color: var(--border-dim) transparent;
}
.day-group {
display: flex;
flex-direction: column;
gap: var(--sp-3);
}
.day-header {
display: flex;
align-items: center;
gap: var(--sp-3);
}
.day-label {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
flex-shrink: 0;
}
.day-rule {
flex: 1;
height: 1px;
background: var(--border-dim);
}
.session-list {
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
.session-row {
display: flex;
align-items: center;
gap: var(--sp-3);
width: 100%;
padding: var(--sp-3);
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-raised);
text-align: left;
cursor: pointer;
transition: border-color var(--t-fast), background var(--t-fast);
}
.session-row:hover { border-color: var(--border-strong); background: var(--bg-elevated); }
.thumb-wrap {
position: relative;
flex-shrink: 0;
}
:global(.thumb) {
width: 38px;
height: 54px;
object-fit: cover;
display: block;
border-radius: var(--radius-sm);
border: 1px solid var(--border-dim);
}
.session-count {
position: absolute;
bottom: -4px;
right: -6px;
background: var(--accent-muted);
border: 1px solid var(--accent-dim);
color: var(--accent-fg);
font-family: var(--font-ui);
font-size: 8px;
font-weight: 700;
padding: 1px 3px;
border-radius: var(--radius-sm);
line-height: 1.3;
pointer-events: none;
letter-spacing: 0.02em;
}
.session-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
overflow: hidden;
min-width: 0;
}
.session-title {
font-size: var(--text-sm);
font-weight: var(--weight-medium);
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2;
}
.session-chapter {
display: flex;
align-items: center;
gap: 4px;
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-muted);
letter-spacing: var(--tracking-wide);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
}
.ch-arrow {
color: var(--text-faint);
opacity: 0.35;
flex-shrink: 0;
}
.ch-page {
color: var(--text-faint);
opacity: 0.5;
flex-shrink: 0;
}
.session-time {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
flex-shrink: 0;
white-space: nowrap;
opacity: 0.45;
}
.empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--sp-2);
}
.empty-icon-wrap {
width: 44px;
height: 44px;
border-radius: var(--radius-lg);
background: var(--bg-raised);
border: 1px solid var(--border-dim);
display: flex;
align-items: center;
justify-content: center;
color: var(--text-faint);
opacity: 0.5;
margin-bottom: var(--sp-1);
}
.empty-text {
font-size: var(--text-sm);
font-weight: var(--weight-medium);
color: var(--text-muted);
}
.empty-hint {
font-size: var(--text-xs);
color: var(--text-faint);
}
</style>
@@ -0,0 +1,234 @@
<script lang="ts">
import { ArrowCircleUp, ArrowsClockwise, BookOpen, CircleNotch, MagnifyingGlass, NewspaperClipping, Trash } from "phosphor-svelte";
import { store, clearHistory } from "@store/state.svelte";
import HistoryPanel from "./HistoryPanel.svelte";
import UpdatesPanel from "./UpdatesPanel.svelte";
type RecentTab = "updates" | "history";
let tab = $state<RecentTab>("updates");
// History toolbar state
let historySearch = $state("");
let historyConfirmClear = $state(false);
function handleHistoryClear() {
if (!historyConfirmClear) {
historyConfirmClear = true;
setTimeout(() => { historyConfirmClear = false; }, 3000);
return;
}
clearHistory();
historyConfirmClear = false;
}
// Updates toolbar state — bound to the child panel
let updatesLoading = $state(true);
let updatesRefreshFn = $state<(() => Promise<void>) | null>(null);
</script>
<div class="root anim-fade-in">
<div class="header">
<span class="heading">Recent</span>
<div class="tabs">
<button class="tab" class:active={tab === "updates"} onclick={() => tab = "updates"}>
<NewspaperClipping size={11} weight="bold" />
Updates
</button>
<button class="tab" class:active={tab === "history"} onclick={() => tab = "history"}>
<BookOpen size={11} weight="bold" />
Reading history
</button>
</div>
<div class="header-right">
{#if tab === "updates"}
<button class="icon-btn" onclick={() => updatesRefreshFn?.()} disabled={updatesLoading} title="Refresh updates">
{#if updatesLoading}
<CircleNotch size={14} weight="light" class="anim-spin" />
{:else}
<ArrowsClockwise size={14} weight="bold" />
{/if}
</button>
{:else}
<div class="search-wrap">
<MagnifyingGlass size={11} class="search-icon" weight="light" />
<input
class="search"
placeholder="Search…"
value={historySearch}
oninput={(e) => historySearch = (e.target as HTMLInputElement).value}
/>
{#if historySearch}
<button class="search-clear" onclick={() => historySearch = ""}>×</button>
{/if}
</div>
{#if store.history.length > 0}
<button
class="clear-btn"
class:confirm={historyConfirmClear}
onclick={handleHistoryClear}
title={historyConfirmClear ? "Click again to confirm" : "Clear history"}
>
<Trash size={12} weight="light" />
{#if historyConfirmClear}<span class="clear-label">Confirm?</span>{/if}
</button>
{/if}
{/if}
</div>
</div>
<div class="content">
{#if tab === "updates"}
<UpdatesPanel
bind:loading={updatesLoading}
onRegisterRefresh={(fn) => updatesRefreshFn = fn}
/>
{:else}
<HistoryPanel search={historySearch} confirmClear={historyConfirmClear} />
{/if}
</div>
</div>
<style>
.root {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.header {
position: relative;
z-index: 100;
display: flex;
align-items: center;
gap: var(--sp-4);
padding: var(--sp-4) var(--sp-6);
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0;
min-width: 0;
}
.heading {
font-family: var(--font-ui);
font-size: var(--text-xs);
font-weight: var(--weight-medium);
color: var(--text-faint);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
flex-shrink: 0;
}
.tabs {
display: flex;
gap: 2px;
background: var(--bg-raised);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
padding: 2px;
}
.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);
color: var(--text-faint);
white-space: nowrap;
transition: background var(--t-base), color var(--t-base), border-color var(--t-base);
border: 1px solid transparent;
}
.tab:hover { color: var(--text-muted); }
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); }
.header-right {
display: flex;
align-items: center;
gap: var(--sp-2);
margin-left: auto;
flex-shrink: 0;
}
.icon-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-raised);
color: var(--text-faint);
cursor: pointer;
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.45; cursor: default; }
.search-wrap {
position: relative;
display: flex;
align-items: center;
}
.search-wrap :global(.search-icon) {
position: absolute;
left: 8px;
color: var(--text-faint);
pointer-events: none;
}
.search {
background: var(--bg-raised);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
padding: 4px 26px;
color: var(--text-primary);
font-size: var(--text-xs);
width: 148px;
outline: none;
transition: border-color var(--t-base), width var(--t-base), background var(--t-base);
}
.search::placeholder { color: var(--text-faint); }
.search:focus { border-color: var(--border-strong); background: var(--bg-elevated); width: 200px; }
.search-clear {
position: absolute;
right: 8px;
color: var(--text-faint);
font-size: 13px;
line-height: 1;
background: none;
border: none;
cursor: pointer;
padding: 2px;
transition: color var(--t-base);
}
.search-clear:hover { color: var(--text-muted); }
.clear-btn {
display: flex; align-items: center; gap: 4px;
height: 28px; padding: 0 var(--sp-2);
border-radius: var(--radius-md); border: 1px solid var(--border-dim);
background: var(--bg-raised); color: var(--text-faint);
cursor: pointer; font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide); flex-shrink: 0;
transition: color var(--t-base), background var(--t-base), border-color var(--t-base);
}
.clear-btn:hover { color: var(--color-error); background: var(--color-error-bg); border-color: color-mix(in srgb, var(--color-error) 30%, transparent); }
.clear-btn.confirm { color: var(--color-error); background: var(--color-error-bg); border-color: var(--color-error); }
.clear-label { font-size: var(--text-2xs); }
.content {
flex: 1;
min-height: 0;
overflow: hidden;
}
</style>
@@ -0,0 +1,631 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { BookOpen, CircleNotch } from "phosphor-svelte";
import { gql } from "@api/client";
import { GET_RECENTLY_UPDATED, GET_CHAPTERS, LIBRARY_UPDATE_STATUS } from "@api/queries";
import { cache, CACHE_GROUPS, CACHE_KEYS } from "@core/cache";
import { store, openReader, setActiveManga, addToast } from "@store/state.svelte";
import { dayLabel } from "@core/util";
import { buildReaderChapterList } from "@features/series/lib/chapterList";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import type { Chapter, Manga } from "@types";
interface Props {
loading?: boolean;
onRegisterRefresh?: (fn: () => Promise<void>) => void;
}
let { loading = $bindable(true), onRegisterRefresh }: Props = $props();
interface RecentUpdate extends Pick<Chapter, "id" | "name" | "chapterNumber" | "sourceOrder" | "isRead" | "lastPageRead" | "mangaId" | "fetchedAt"> {
manga: { id: number; title: string; thumbnailUrl: string; inLibrary: boolean } | null;
}
interface UpdateGroup {
label: string;
items: RecentUpdate[];
}
let updates = $state<RecentUpdate[]>([]);
let error = $state<string | null>(null);
let openingId = $state<number | null>(null);
let updaterRunning = $state(false);
let lastUpdatedTs = $state<number | null>(null);
let updaterFinishedJobs = $state<number | null>(null);
let updaterTotalJobs = $state<number | null>(null);
let ctrl: AbortController | null = null;
let statusPollTimer: ReturnType<typeof setTimeout> | null = null;
const RECENT_UPDATES_TTL_MS = 60 * 1_000;
const UPDATE_STATUS_POLL_MS = 2_000;
onMount(() => {
onRegisterRefresh?.(() => loadUpdates(true));
void loadUpdates();
});
onDestroy(() => {
ctrl?.abort();
stopStatusPolling();
});
function fetchedAtMs(item: Pick<RecentUpdate, "fetchedAt">): number {
const ts = item.fetchedAt ? new Date(item.fetchedAt).getTime() : Date.now();
return Number.isFinite(ts) ? ts : Date.now();
}
const groups = $derived.by(() => {
const grouped: Record<string, RecentUpdate[]> = {};
for (const item of updates) {
const label = dayLabel(fetchedAtMs(item));
if (!grouped[label]) grouped[label] = [];
grouped[label].push(item);
}
return Object.entries(grouped).map(([label, items]) => ({ label, items })) as UpdateGroup[];
});
const lastUpdatedLabel = $derived(
lastUpdatedTs
? new Date(lastUpdatedTs).toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
})
: null
);
const updaterProgressLabel = $derived(
typeof updaterFinishedJobs === "number" && typeof updaterTotalJobs === "number" && updaterTotalJobs > 0
? `${updaterFinishedJobs}/${updaterTotalJobs}`
: null
);
function parseServerTimestamp(value: unknown): number | null {
if (typeof value === "number") return Number.isFinite(value) ? value : null;
if (typeof value === "string") {
const numeric = Number(value);
if (Number.isFinite(numeric)) return numeric;
const parsed = new Date(value).getTime();
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}
function applyUpdateStatus(statusRes: {
libraryUpdateStatus: {
jobsInfo: {
isRunning: boolean;
finishedJobs?: number;
totalJobs?: number;
};
};
lastUpdateTimestamp: { timestamp: string | number | null } | null;
} | null) {
const jobsInfo = statusRes?.libraryUpdateStatus.jobsInfo;
updaterRunning = jobsInfo?.isRunning ?? false;
updaterFinishedJobs = typeof jobsInfo?.finishedJobs === "number" ? jobsInfo.finishedJobs : null;
updaterTotalJobs = typeof jobsInfo?.totalJobs === "number" ? jobsInfo.totalJobs : null;
lastUpdatedTs = parseServerTimestamp(statusRes?.lastUpdateTimestamp?.timestamp ?? null);
}
function stopStatusPolling() {
if (!statusPollTimer) return;
clearTimeout(statusPollTimer);
statusPollTimer = null;
}
function scheduleStatusPoll() {
if (statusPollTimer) return;
const tick = async () => {
statusPollTimer = null;
try {
const statusRes = await gql<{
libraryUpdateStatus: {
jobsInfo: {
isRunning: boolean;
finishedJobs: number;
totalJobs: number;
};
};
lastUpdateTimestamp: { timestamp: string | number | null } | null;
}>(LIBRARY_UPDATE_STATUS, {});
const wasRunning = updaterRunning;
applyUpdateStatus(statusRes);
if (updaterRunning) {
statusPollTimer = setTimeout(tick, UPDATE_STATUS_POLL_MS);
} else if (wasRunning) {
void loadUpdates(true);
}
} catch {
if (updaterRunning) {
statusPollTimer = setTimeout(tick, UPDATE_STATUS_POLL_MS);
}
}
};
statusPollTimer = setTimeout(tick, UPDATE_STATUS_POLL_MS);
}
function mangaStub(item: RecentUpdate): Manga {
return {
id: item.manga?.id ?? item.mangaId,
title: item.manga?.title ?? "Unknown series",
thumbnailUrl: item.manga?.thumbnailUrl ?? "",
inLibrary: item.manga?.inLibrary ?? true,
};
}
function chapterLabel(item: RecentUpdate): string {
if (item.name?.trim()) return item.name;
if (Number.isFinite(item.chapterNumber)) return `Chapter ${item.chapterNumber}`;
return "Chapter";
}
async function loadUpdates(force = false) {
ctrl?.abort();
const nextCtrl = new AbortController();
ctrl = nextCtrl;
loading = true;
error = null;
try {
const key = CACHE_KEYS.RECENT_UPDATES;
if (force) cache.clear(key);
const [updatesRes, statusRes] = await Promise.all([
cache.get<{ chapters: { nodes: RecentUpdate[] } }>(
key,
() => gql<{ chapters: { nodes: RecentUpdate[] } }>(GET_RECENTLY_UPDATED, {}, nextCtrl.signal),
RECENT_UPDATES_TTL_MS,
CACHE_GROUPS.LIBRARY,
),
gql<{
libraryUpdateStatus: {
jobsInfo: { isRunning: boolean };
};
lastUpdateTimestamp: { timestamp: string | number | null } | null;
}>(LIBRARY_UPDATE_STATUS, {}, nextCtrl.signal).catch(() => null),
]);
applyUpdateStatus(statusRes);
if (updaterRunning) scheduleStatusPoll();
else stopStatusPolling();
if (nextCtrl.signal.aborted) return;
updates = updatesRes.chapters.nodes
.filter(item => item.manga?.inLibrary)
.sort((a, b) => fetchedAtMs(b) - fetchedAtMs(a));
} catch (e: any) {
if (nextCtrl.signal.aborted) return;
error = e?.message ?? "Failed to load updates";
updates = [];
updaterRunning = false;
lastUpdatedTs = null;
updaterFinishedJobs = null;
updaterTotalJobs = null;
stopStatusPolling();
} finally {
if (!nextCtrl.signal.aborted) loading = false;
}
}
async function openUpdate(item: RecentUpdate) {
if (openingId !== null) return;
openingId = item.id;
const manga = mangaStub(item);
try {
const res = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: item.mangaId });
const raw = [...res.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
const list = buildReaderChapterList(raw, store.settings.mangaPrefs?.[item.mangaId]);
const target = list.find(ch => ch.id === item.id);
if (target) {
setActiveManga(manga);
openReader(target, list);
} else {
setActiveManga(manga);
}
} catch {
setActiveManga(manga);
addToast({ kind: "error", title: "Couldn't open chapter", body: "Opened the series instead." });
} finally {
openingId = null;
}
}
</script>
<div class="root anim-fade-in">
<div class="bar-wrap">
<div class="status-bar">
<div class="status-dot" class:active={loading || updaterRunning}></div>
<span class="status-text">
{#if loading}Checking for updates…{:else if error}Update check failed{:else if updaterRunning}Library update in progress...{#if updaterProgressLabel} ({updaterProgressLabel}){/if}{:else}Up to date{/if}
</span>
<div class="status-right">
{#if !loading && lastUpdatedLabel}
<span class="status-detail">Last updated: {lastUpdatedLabel}</span>
<div class="bar-sep"></div>
{/if}
{#if !loading && updates.length > 0}
<span class="status-count">{updates.length} chapter{updates.length === 1 ? "" : "s"}</span>
{/if}
</div>
</div>
</div>
{#if loading && updates.length === 0}
<div class="timeline" aria-hidden="true">
<section class="day-group">
<div class="day-header">
<span class="day-label skeleton sk-day-label"></span>
<div class="day-rule skeleton sk-day-rule"></div>
</div>
<div class="updates-list">
{#each Array(8) as _, i (i)}
<div class="update-row skeleton-row">
<div class="thumb-skeleton skeleton"></div>
<div class="info-skeleton">
<div class="skeleton sk-title"></div>
<div class="skeleton sk-chapter"></div>
<div class="skeleton sk-meta"></div>
</div>
<div class="end-skeleton skeleton"></div>
</div>
{/each}
</div>
</section>
</div>
{:else if error}
<div class="empty">
<div class="empty-icon-wrap">
<BookOpen size={22} weight="light" />
</div>
<p class="empty-text">Couldn't load updates</p>
<p class="empty-hint">{error}</p>
</div>
{:else if updates.length === 0}
<div class="empty">
<div class="empty-icon-wrap">
<BookOpen size={22} weight="light" />
</div>
<p class="empty-text">No recent library updates</p>
<p class="empty-hint">Run a library update to populate this page.</p>
</div>
{:else}
<div class="timeline">
{#each groups as { label, items } (label)}
<section class="day-group">
<div class="day-header">
<span class="day-label">{label}</span>
<div class="day-rule"></div>
</div>
<div class="updates-list">
{#each items as item (item.id)}
<div class="update-row" class:read={item.isRead}>
<button class="thumb-btn" onclick={() => setActiveManga(mangaStub(item))} title="View series">
<Thumbnail src={item.manga?.thumbnailUrl ?? ""} alt={item.manga?.title ?? "Series cover"} class="thumb" />
</button>
<button class="info-btn" onclick={() => openUpdate(item)} disabled={openingId === item.id}>
<div class="update-info">
<div class="title-row">
<span class="series-title">{item.manga?.title ?? "Unknown series"}</span>
{#if !item.isRead}
<span class="pill">Unread</span>
{/if}
</div>
<span class="chapter-title">{chapterLabel(item)}</span>
{#if (item.lastPageRead ?? 0) > 0 && !item.isRead}
<div class="meta-row">
<span>Resume p.{item.lastPageRead}</span>
</div>
{/if}
</div>
<div class="row-end">
{#if openingId === item.id}
<CircleNotch size={14} weight="light" class="anim-spin" />
{:else}
<BookOpen size={14} weight="light" />
{/if}
</div>
</button>
</div>
{/each}
</div>
</section>
{/each}
</div>
{/if}
</div>
<style>
.root {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.bar-wrap { padding: var(--sp-3) var(--sp-6); flex-shrink: 0; }
.status-bar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3) var(--sp-4); background: var(--bg-surface, var(--bg-raised)); border: 1px solid var(--border-strong, var(--border-dim)); border-radius: var(--radius-md); box-shadow: 0 1px 4px rgba(0,0,0,0.25); }
.status-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-faint); flex-shrink: 0; transition: background var(--t-base); }
.status-dot.active { background: var(--accent); animation: pulse 1.6s ease infinite; }
.status-text { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); flex: 1; letter-spacing: var(--tracking-wide); }
.status-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; }
.status-detail { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.status-count { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.bar-sep { width: 1px; height: 12px; background: var(--border-dim); flex-shrink: 0; }
.timeline {
flex: 1;
overflow-y: auto;
padding: var(--sp-4) var(--sp-6) var(--sp-6);
display: flex;
flex-direction: column;
gap: var(--sp-5);
}
.day-group {
display: flex;
flex-direction: column;
gap: var(--sp-3);
}
.day-header {
display: flex;
align-items: center;
gap: var(--sp-3);
}
.day-label {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
white-space: nowrap;
}
.day-rule {
height: 1px;
flex: 1;
background: var(--border-dim);
}
.updates-list {
display: flex;
flex-direction: column;
gap: var(--sp-2);
}
@keyframes shimmer { from { background-position: -200% 0 } to { background-position: 200% 0 } }
.skeleton {
border-radius: var(--radius-sm);
background: linear-gradient(
90deg,
color-mix(in srgb, var(--bg-overlay, var(--bg-elevated)) 90%, var(--text-primary) 6%) 20%,
color-mix(in srgb, var(--bg-elevated, var(--bg-overlay)) 76%, var(--text-primary) 16%) 50%,
color-mix(in srgb, var(--bg-overlay, var(--bg-elevated)) 90%, var(--text-primary) 6%) 80%
);
background-size: 220% 100%;
animation: shimmer 1.45s ease-in-out infinite;
}
.update-row {
display: flex;
align-items: stretch;
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-raised);
overflow: hidden;
transition: border-color var(--t-fast), opacity var(--t-base), background var(--t-fast);
}
.update-row:has(.info-btn:hover:not(:disabled)),
.update-row:has(.thumb-btn:hover) {
border-color: var(--border-strong);
background: var(--bg-elevated);
}
.update-row.read { opacity: 0.5; }
.skeleton-row {
min-height: 74px;
pointer-events: none;
}
.thumb-skeleton {
width: 34px;
aspect-ratio: 2 / 3;
margin: var(--sp-2) var(--sp-2) var(--sp-2) var(--sp-3);
border-radius: var(--radius-sm);
flex-shrink: 0;
align-self: center;
}
.info-skeleton {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3) var(--sp-2) 0;
}
.sk-title { height: 12px; width: clamp(140px, 42%, 340px); }
.sk-chapter { height: 10px; width: clamp(100px, 30%, 260px); }
.sk-meta { height: 8px; width: clamp(70px, 18%, 180px); }
.end-skeleton {
width: 14px;
height: 14px;
border-radius: 50%;
margin: auto var(--sp-4) auto 0;
opacity: 0.85;
flex-shrink: 0;
}
.sk-day-label {
display: block;
width: 74px;
height: 10px;
border-radius: var(--radius-sm);
}
.sk-day-rule {
opacity: 0.7;
}
.thumb-btn {
width: 52px;
flex-shrink: 0;
padding: var(--sp-2);
background: none;
border: none;
/* border-right: 1px solid var(--border-dim); */
cursor: pointer;
display: flex;
align-items: center;
transition: background var(--t-base);
}
.thumb-btn:hover { background: none; }
:global(.thumb) {
width: 100%;
aspect-ratio: 2 / 3;
display: block;
object-fit: cover;
border-radius: var(--radius-sm);
}
.info-btn {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: var(--sp-3);
padding: var(--sp-2) var(--sp-3);
background: none;
border: none;
cursor: pointer;
text-align: left;
transition: background var(--t-base);
}
.info-btn:hover:not(:disabled) { background: none; }
.info-btn:disabled { cursor: default; opacity: 0.8; }
.update-info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 3px;
}
.title-row {
display: flex;
align-items: center;
gap: var(--sp-2);
min-width: 0;
}
.series-title,
.chapter-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.series-title {
font-family: var(--font-ui);
font-size: var(--text-sm);
color: var(--text-primary);
}
.chapter-title {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-muted);
}
.meta-row {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
}
.pill {
padding: 2px 6px;
border-radius: var(--radius-full);
background: var(--accent-muted);
color: var(--accent-fg);
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
flex-shrink: 0;
}
.row-end {
color: var(--text-faint);
display: flex;
align-items: center;
justify-content: center;
width: 24px;
flex-shrink: 0;
}
.empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: var(--sp-2);
color: var(--text-faint);
padding: var(--sp-6);
text-align: center;
}
.empty-icon-wrap {
width: 52px;
height: 52px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-raised);
border: 1px solid var(--border-dim);
}
.empty-text {
margin: 0;
font-family: var(--font-ui);
font-size: var(--text-sm);
color: var(--text-secondary);
}
.empty-hint {
margin: 0;
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-faint);
}
@keyframes pulse { 0%, 100% { opacity: 1 } 50% { opacity: 0.4 } }
</style>