Add updates page scaffold

This commit is contained in:
Zerebos
2026-05-21 00:52:26 +00:00
parent 3dad4bc729
commit f988641446
5 changed files with 435 additions and 6 deletions
+6
View File
@@ -2,6 +2,12 @@ export const GET_RECENTLY_UPDATED = `
query GetRecentlyUpdated { query GetRecentlyUpdated {
chapters(orderBy: FETCHED_AT, orderByType: DESC, first: 300) { chapters(orderBy: FETCHED_AT, orderByType: DESC, first: 300) {
nodes { nodes {
id
name
chapterNumber
sourceOrder
isRead
lastPageRead
mangaId mangaId
fetchedAt fetchedAt
manga { id title thumbnailUrl inLibrary } manga { id title thumbnailUrl inLibrary }
@@ -0,0 +1,419 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { ArrowsClockwise, BookOpen, CircleNotch } from "phosphor-svelte";
import { gql } from "@api/client";
import { GET_RECENTLY_UPDATED, GET_CHAPTERS } from "@api/queries";
import { store, openReader, setActiveManga, addToast } from "@store/state.svelte";
import { dayLabel, timeAgo } from "@core/util";
import { buildReaderChapterList } from "@features/series/lib/chapterList";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import type { Chapter, Manga } from "@types";
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 loading = $state(true);
let error = $state<string | null>(null);
let openingId = $state<number | null>(null);
let ctrl: AbortController | null = null;
onMount(() => {
void loadUpdates();
});
onDestroy(() => {
ctrl?.abort();
});
const groups = $derived.by(() => {
const map = new Map<string, RecentUpdate[]>();
for (const item of updates) {
const ts = item.fetchedAt ? new Date(item.fetchedAt).getTime() : 0;
const label = dayLabel(Number.isFinite(ts) ? ts : 0);
if (!map.has(label)) map.set(label, []);
map.get(label)!.push(item);
}
return Array.from(map.entries()).map(([label, items]) => ({ label, items })) as UpdateGroup[];
});
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() {
ctrl?.abort();
ctrl = new AbortController();
loading = true;
error = null;
try {
const res = await gql<{ chapters: { nodes: RecentUpdate[] } }>(GET_RECENTLY_UPDATED, {}, ctrl.signal);
if (ctrl.signal.aborted) return;
updates = res.chapters.nodes
.filter(item => item.manga?.inLibrary)
.sort((a, b) => new Date(b.fetchedAt ?? 0).getTime() - new Date(a.fetchedAt ?? 0).getTime());
} catch (e: any) {
if (ctrl.signal.aborted) return;
error = e?.message ?? "Failed to load updates";
updates = [];
} finally {
if (!ctrl.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="header">
<div class="heading-group">
<ArrowsClockwise size={13} weight="light" class="heading-icon" />
<span class="heading">Updates</span>
</div>
<button class="icon-btn" onclick={loadUpdates} disabled={loading} title="Refresh updates">
{#if loading}<CircleNotch size={14} weight="light" class="anim-spin" />
{:else}<ArrowsClockwise size={14} weight="bold" />{/if}
</button>
</div>
{#if loading && updates.length === 0}
<div class="empty">
<div class="empty-icon-wrap">
<CircleNotch size={22} weight="light" class="anim-spin" />
</div>
<p class="empty-text">Loading updates…</p>
</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 }}
<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)}
<button class="update-row" onclick={() => openUpdate(item)} disabled={openingId === item.id}>
<div class="thumb-wrap">
<Thumbnail src={item.manga?.thumbnailUrl ?? ""} alt={item.manga?.title ?? "Series cover"} class="thumb" />
</div>
<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>
<div class="meta-row">
<span>{timeAgo(new Date(item.fetchedAt ?? 0).getTime())}</span>
{#if (item.lastPageRead ?? 0) > 0 && !item.isRead}
<span>· Resume p.{item.lastPageRead}</span>
{/if}
</div>
</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>
{/each}
</div>
</section>
{/each}
</div>
{/if}
</div>
<style>
.root {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-4) var(--sp-6);
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0;
}
.heading-group {
display: flex;
align-items: center;
gap: var(--sp-2);
}
:global(.heading-icon) { color: var(--text-faint); }
.heading {
font-family: var(--font-ui);
font-size: var(--text-xs);
font-weight: var(--weight-medium);
color: var(--text-muted);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
.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;
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; }
.timeline {
flex: 1;
overflow-y: auto;
padding: var(--sp-5) 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);
}
.update-row {
display: grid;
grid-template-columns: 52px minmax(0, 1fr) auto;
align-items: center;
gap: var(--sp-3);
width: 100%;
padding: var(--sp-2);
border-radius: var(--radius-lg);
border: 1px solid var(--border-dim);
background: var(--bg-base);
cursor: pointer;
text-align: left;
transition: background var(--t-base), border-color var(--t-base), transform var(--t-base);
}
.update-row:hover:not(:disabled) {
background: var(--bg-raised);
border-color: var(--border-strong);
transform: translateY(-1px);
}
.update-row:disabled { cursor: default; opacity: 0.8; }
.thumb-wrap {
width: 52px;
aspect-ratio: 2 / 3;
border-radius: var(--radius-md);
overflow: hidden;
background: var(--bg-overlay);
border: 1px solid var(--border-dim);
}
:global(.thumb) {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.update-info {
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-secondary);
}
.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;
}
.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);
}
</style>
+3
View File
@@ -10,6 +10,7 @@
import Downloads from "@features/downloads/components/Downloads.svelte"; import Downloads from "@features/downloads/components/Downloads.svelte";
import Extensions from "@features/extensions/components/Extensions.svelte"; import Extensions from "@features/extensions/components/Extensions.svelte";
import Tracking from "@features/tracking/components/Tracking.svelte"; import Tracking from "@features/tracking/components/Tracking.svelte";
import Updates from "@features/updates/components/Updates.svelte";
</script> </script>
<div class="frame"> <div class="frame">
@@ -24,6 +25,8 @@
<Home /> <Home />
{:else if store.navPage === "library"} {:else if store.navPage === "library"}
<Library /> <Library />
{:else if store.navPage === "updates"}
<Updates />
{:else if store.navPage === "search"} {:else if store.navPage === "search"}
<Search /> <Search />
{:else if store.navPage === "history"} {:else if store.navPage === "history"}
+2 -1
View File
@@ -1,11 +1,12 @@
<script lang="ts"> <script lang="ts">
import { House, Books, MagnifyingGlass, ClockCounterClockwise, DownloadSimple, PuzzlePiece, GearSix, ChartLineUp } from "phosphor-svelte"; import { House, Books, MagnifyingGlass, ArrowsClockwise, ClockCounterClockwise, DownloadSimple, PuzzlePiece, GearSix, ChartLineUp } from "phosphor-svelte";
import { store } from "@store/state.svelte"; import { store } from "@store/state.svelte";
import type { NavPage } from "@store/state.svelte"; import type { NavPage } from "@store/state.svelte";
const TABS: { id: NavPage; label: string; icon: any }[] = [ const TABS: { id: NavPage; label: string; icon: any }[] = [
{ id: "home", label: "Home", icon: House }, { id: "home", label: "Home", icon: House },
{ id: "library", label: "Library", icon: Books }, { id: "library", label: "Library", icon: Books },
{ id: "updates", label: "Updates", icon: ArrowsClockwise },
{ id: "search", label: "Search", icon: MagnifyingGlass }, { id: "search", label: "Search", icon: MagnifyingGlass },
{ id: "history", label: "History", icon: ClockCounterClockwise }, { id: "history", label: "History", icon: ClockCounterClockwise },
{ id: "downloads", label: "Downloads", icon: DownloadSimple }, { id: "downloads", label: "Downloads", icon: DownloadSimple },
+1 -1
View File
@@ -1,5 +1,5 @@
export type NavPage = export type NavPage =
| "home" | "library" | "sources" | "explore" | "home" | "library" | "updates" | "sources" | "explore"
| "downloads" | "extensions" | "history" | "search" | "tracking"; | "downloads" | "extensions" | "history" | "search" | "tracking";
class AppStore { class AppStore {