From f988641446cf0775515e0da1d68ba46f44ebf61b Mon Sep 17 00:00:00 2001 From: Zerebos Date: Thu, 21 May 2026 00:52:26 +0000 Subject: [PATCH 01/12] Add updates page scaffold --- src/api/queries/chapters.ts | 8 +- .../updates/components/Updates.svelte | 419 ++++++++++++++++++ src/shared/chrome/Layout.svelte | 5 +- src/shared/chrome/Sidebar.svelte | 5 +- src/store/app.svelte.ts | 4 +- 5 files changed, 435 insertions(+), 6 deletions(-) create mode 100644 src/features/updates/components/Updates.svelte diff --git a/src/api/queries/chapters.ts b/src/api/queries/chapters.ts index 4c22666..91a4920 100644 --- a/src/api/queries/chapters.ts +++ b/src/api/queries/chapters.ts @@ -2,6 +2,12 @@ export const GET_RECENTLY_UPDATED = ` query GetRecentlyUpdated { chapters(orderBy: FETCHED_AT, orderByType: DESC, first: 300) { nodes { + id + name + chapterNumber + sourceOrder + isRead + lastPageRead mangaId fetchedAt manga { id title thumbnailUrl inLibrary } @@ -19,4 +25,4 @@ export const GET_CHAPTERS = ` } } } -`; \ No newline at end of file +`; diff --git a/src/features/updates/components/Updates.svelte b/src/features/updates/components/Updates.svelte new file mode 100644 index 0000000..636a290 --- /dev/null +++ b/src/features/updates/components/Updates.svelte @@ -0,0 +1,419 @@ + + +
+
+
+ + Updates +
+ +
+ + {#if loading && updates.length === 0} +
+
+ +
+

Loading updates…

+
+ {:else if error} +
+
+ +
+

Couldn't load updates

+

{error}

+
+ {:else if updates.length === 0} +
+
+ +
+

No recent library updates

+

Run a library update to populate this page.

+
+ {:else} +
+ {#each groups as { label, items }} +
+
+ {label} +
+
+ +
+ {#each items as item (item.id)} + + {/each} +
+
+ {/each} +
+ {/if} +
+ + diff --git a/src/shared/chrome/Layout.svelte b/src/shared/chrome/Layout.svelte index 6c4ec74..19b7c39 100644 --- a/src/shared/chrome/Layout.svelte +++ b/src/shared/chrome/Layout.svelte @@ -10,6 +10,7 @@ import Downloads from "@features/downloads/components/Downloads.svelte"; import Extensions from "@features/extensions/components/Extensions.svelte"; import Tracking from "@features/tracking/components/Tracking.svelte"; + import Updates from "@features/updates/components/Updates.svelte";
@@ -24,6 +25,8 @@ {:else if store.navPage === "library"} + {:else if store.navPage === "updates"} + {:else if store.navPage === "search"} {:else if store.navPage === "history"} @@ -45,4 +48,4 @@ .frame { display: flex; padding: 6px 15px 15px; width: 100%; height: 100%; box-sizing: border-box; overflow: hidden; } .shell { display: flex; flex: 1; border-radius: 14px; overflow: hidden; border: 1px solid var(--border-dim); background: var(--bg-base); background-image: var(--bg-image); min-height: 0; min-width: 0; } .main { flex: 1; overflow: hidden; background: var(--bg-surface); transform: translateZ(0); contain: layout style; min-width: 0; } - \ No newline at end of file + diff --git a/src/shared/chrome/Sidebar.svelte b/src/shared/chrome/Sidebar.svelte index 0fc72d6..25060ae 100644 --- a/src/shared/chrome/Sidebar.svelte +++ b/src/shared/chrome/Sidebar.svelte @@ -1,11 +1,12 @@ + +
+
+ Recent +
+ + +
+
+ +
+ {#if tab === "updates"} + + {:else} + + {/if} +
+
+ + diff --git a/src/features/updates/components/Updates.svelte b/src/features/recent/components/UpdatesPanel.svelte similarity index 91% rename from src/features/updates/components/Updates.svelte rename to src/features/recent/components/UpdatesPanel.svelte index 5c874e9..8d89e2e 100644 --- a/src/features/updates/components/Updates.svelte +++ b/src/features/recent/components/UpdatesPanel.svelte @@ -4,7 +4,7 @@ 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 { dayLabel } from "@core/util"; import { buildReaderChapterList } from "@features/series/lib/chapterList"; import Thumbnail from "@shared/manga/Thumbnail.svelte"; import type { Chapter, Manga } from "@types"; @@ -48,6 +48,24 @@ return Array.from(map.entries()).map(([label, items]) => ({ label, items })) as UpdateGroup[]; }); + const lastUpdatedTs = $derived( + store.lastLibraryRefresh > 0 + ? store.lastLibraryRefresh + : (updates.length > 0 ? fetchedAtMs(updates[0]) : null) + ); + + const lastUpdatedLabel = $derived( + lastUpdatedTs + ? new Date(lastUpdatedTs).toLocaleString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + }) + : "Never" + ); + function mangaStub(item: RecentUpdate): Manga { return { id: item.manga?.id ?? item.mangaId, @@ -116,7 +134,8 @@
- Updates + Library updates + Last updated: {lastUpdatedLabel}
@@ -231,6 +249,14 @@ text-transform: uppercase; } + .last-updated { + font-family: var(--font-ui); + font-size: var(--text-2xs); + color: var(--text-faint); + letter-spacing: var(--tracking-wide); + text-transform: none; + } + .icon-btn { display: flex; align-items: center; diff --git a/src/shared/chrome/Layout.svelte b/src/shared/chrome/Layout.svelte index 19b7c39..a91bd61 100644 --- a/src/shared/chrome/Layout.svelte +++ b/src/shared/chrome/Layout.svelte @@ -1,7 +1,6 @@
@@ -25,12 +24,10 @@ {:else if store.navPage === "library"} - {:else if store.navPage === "updates"} - {:else if store.navPage === "search"} {:else if store.navPage === "history"} - + {:else if store.navPage === "downloads"} {:else if store.navPage === "extensions"} diff --git a/src/shared/chrome/Sidebar.svelte b/src/shared/chrome/Sidebar.svelte index 25060ae..1ba045b 100644 --- a/src/shared/chrome/Sidebar.svelte +++ b/src/shared/chrome/Sidebar.svelte @@ -1,14 +1,13 @@
- -
-
- - History -
-
-
- - - {#if search} - - {/if} -
- {#if store.history.length > 0} - - {/if} -
-
- {#if store.readingStats.totalChaptersRead > 0}
@@ -230,100 +201,6 @@ 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; - } - - .header-right { - display: flex; - align-items: center; - gap: var(--sp-2); - } - - .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: 30px; 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); } - .stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); diff --git a/src/features/recent/components/Recent.svelte b/src/features/recent/components/Recent.svelte index 532f06a..e7350e3 100644 --- a/src/features/recent/components/Recent.svelte +++ b/src/features/recent/components/Recent.svelte @@ -1,14 +1,35 @@
Recent +
+ +
+ {#if tab === "updates"} + + {:else} +
+ + historySearch = (e.target as HTMLInputElement).value} + /> + {#if historySearch} + + {/if} +
+ {#if store.history.length > 0} + + {/if} + {/if} +
{#if tab === "updates"} - + updatesRefreshFn = fn} + /> {:else} - + {/if}
@@ -82,6 +142,83 @@ .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; + } + + .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; diff --git a/src/features/recent/components/UpdatesPanel.svelte b/src/features/recent/components/UpdatesPanel.svelte index 8d89e2e..2949387 100644 --- a/src/features/recent/components/UpdatesPanel.svelte +++ b/src/features/recent/components/UpdatesPanel.svelte @@ -1,6 +1,6 @@
-
-
- - Library updates - Last updated: {lastUpdatedLabel} +
+
+
+ + {#if loading}Checking for updates…{:else if error}Update check failed{:else}Up to date{/if} + +
+ {#if !loading && lastCheckedLabel} + Last checked: {lastCheckedLabel} +
+ {/if} + {#if !loading && updates.length > 0} + {updates.length} chapter{updates.length === 1 ? "" : "s"} + {/if} +
-
{#if loading && updates.length === 0} @@ -177,36 +188,38 @@
{#each items as item (item.id)} -
+ -
-
- {item.manga?.title ?? "Unknown series"} - {#if !item.isRead} - Unread +
- -
- {#if openingId === item.id} - - {:else} - - {/if} -
- +
+ {#if openingId === item.id} + + {:else} + + {/if} +
+ +
{/each}
@@ -223,60 +236,21 @@ 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; - } + .bar-wrap { padding: var(--sp-3) var(--sp-6); 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; - } - - .last-updated { - font-family: var(--font-ui); - font-size: var(--text-2xs); - color: var(--text-faint); - letter-spacing: var(--tracking-wide); - text-transform: none; - } - - .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; } + .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-5) var(--sp-6) var(--sp-6); + padding: var(--sp-4) var(--sp-6) var(--sp-6); display: flex; flex-direction: column; gap: var(--sp-5); @@ -316,42 +290,60 @@ } .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); + display: flex; + align-items: stretch; 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); + overflow: hidden; + transition: border-color var(--t-base), transform var(--t-base); } - .update-row:hover:not(:disabled) { - background: var(--bg-raised); + .update-row:has(.info-btn:hover:not(:disabled)) { border-color: var(--border-strong); transform: translateY(-1px); } - .update-row:disabled { cursor: default; opacity: 0.8; } + .update-row.read { opacity: 0.5; } - .thumb-wrap { + .thumb-btn { width: 52px; - aspect-ratio: 2 / 3; - border-radius: var(--radius-md); - overflow: hidden; - background: var(--bg-overlay); - border: 1px solid var(--border-dim); + 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: var(--bg-raised); } + :global(.thumb) { width: 100%; - height: 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: var(--bg-raised); } + .info-btn:disabled { cursor: default; opacity: 0.8; } + .update-info { + flex: 1; min-width: 0; display: flex; flex-direction: column; @@ -409,6 +401,7 @@ align-items: center; justify-content: center; width: 24px; + flex-shrink: 0; } .empty { @@ -447,4 +440,6 @@ font-size: var(--text-xs); color: var(--text-faint); } + + @keyframes pulse { 0%, 100% { opacity: 1 } 50% { opacity: 0.4 } } From 8ef0a14363fcf622aae979fa8f2c7eaf18a32e49 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Thu, 21 May 2026 00:50:49 -0400 Subject: [PATCH 05/12] Add tab icons --- src-tauri/src/commands/system.rs | 1 - .../extensions/components/ExtensionFilters.svelte | 11 ++++++++++- src/features/recent/components/Recent.svelte | 11 +++++++++-- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/commands/system.rs b/src-tauri/src/commands/system.rs index e858648..c650da5 100644 --- a/src-tauri/src/commands/system.rs +++ b/src-tauri/src/commands/system.rs @@ -3,7 +3,6 @@ use crate::server::resolve::strip_unc; #[cfg(target_os = "windows")] use std::path::PathBuf; use tauri::Manager; -use std::path::PathBuf; #[tauri::command] pub fn get_platform_ui_scale(window: tauri::Window) -> f64 { diff --git a/src/features/extensions/components/ExtensionFilters.svelte b/src/features/extensions/components/ExtensionFilters.svelte index 48f7920..e0901fc 100644 --- a/src/features/extensions/components/ExtensionFilters.svelte +++ b/src/features/extensions/components/ExtensionFilters.svelte @@ -1,5 +1,5 @@ {#if loading} -
- +
+ {#each Array(5) as _, i (i)} +
+
+ +
+
+
+
+
+
+
+
+ +
+
+
+
+
+
+
+ {/each}
{:else if queue.length === 0}
Queue is empty.
@@ -49,4 +69,30 @@ \ No newline at end of file diff --git a/src/features/recent/components/HistoryPanel.svelte b/src/features/recent/components/HistoryPanel.svelte index cb0cc86..f705ba2 100644 --- a/src/features/recent/components/HistoryPanel.svelte +++ b/src/features/recent/components/HistoryPanel.svelte @@ -297,6 +297,7 @@ .session-list { display: flex; flex-direction: column; + gap: var(--sp-2); } .session-row { @@ -304,17 +305,16 @@ align-items: center; gap: var(--sp-3); width: 100%; - padding: var(--sp-2) var(--sp-2); + padding: var(--sp-3); border-radius: var(--radius-md); - border: none; - background: none; + border: 1px solid var(--border-dim); + background: var(--bg-raised); text-align: left; cursor: pointer; - transition: background var(--t-fast); + transition: border-color var(--t-fast), background var(--t-fast); } - .session-row:hover { background: var(--bg-raised); } - .session-row:active { background: var(--bg-elevated); } + .session-row:hover { border-color: var(--border-strong); background: var(--bg-elevated); } .thumb-wrap { position: relative; @@ -371,8 +371,8 @@ align-items: center; gap: 4px; font-family: var(--font-ui); - font-size: var(--text-2xs); - color: var(--text-faint); + font-size: var(--text-xs); + color: var(--text-muted); letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; diff --git a/src/features/recent/components/UpdatesPanel.svelte b/src/features/recent/components/UpdatesPanel.svelte index 8b5fffb..c4c9932 100644 --- a/src/features/recent/components/UpdatesPanel.svelte +++ b/src/features/recent/components/UpdatesPanel.svelte @@ -390,7 +390,7 @@ padding: var(--sp-2); background: none; border: none; - border-right: 1px solid var(--border-dim); + /* border-right: 1px solid var(--border-dim); */ cursor: pointer; display: flex; align-items: center; @@ -453,7 +453,7 @@ .chapter-title { font-family: var(--font-ui); font-size: var(--text-xs); - color: var(--text-secondary); + color: var(--text-muted); } .meta-row { From fa7cfdc4e6280a5386c1f2ca4d35dd514e316181 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Thu, 21 May 2026 01:45:35 -0400 Subject: [PATCH 08/12] Use stats boxes on history page --- .../recent/components/HistoryPanel.svelte | 140 ++++++++++-------- 1 file changed, 76 insertions(+), 64 deletions(-) diff --git a/src/features/recent/components/HistoryPanel.svelte b/src/features/recent/components/HistoryPanel.svelte index f705ba2..fa2abc7 100644 --- a/src/features/recent/components/HistoryPanel.svelte +++ b/src/features/recent/components/HistoryPanel.svelte @@ -97,47 +97,6 @@
- {#if store.readingStats.totalChaptersRead > 0} -
-
-
- -
-
- {store.readingStats.currentStreakDays} - day streak -
-
-
-
- -
-
- {store.readingStats.totalChaptersRead} - chapters -
-
-
-
- -
-
- {formatReadTime(store.readingStats.totalMinutesRead)} - read time -
-
-
-
- -
-
- {store.readingStats.totalMangaRead} - series -
-
-
- {/if} - {#if store.history.length === 0}
@@ -155,6 +114,44 @@
{:else}
+ {#if store.readingStats.totalChaptersRead > 0} +
+
+ Reading Stats +
+
+
+
+
+ {store.readingStats.currentStreakDays} + Day streak +
+
+
+
+
+ {store.readingStats.totalChaptersRead} + Chapters read +
+
+
+
+
+ {formatReadTime(store.readingStats.totalMinutesRead)} + Read time +
+
+
+
+
+ {store.readingStats.totalMangaRead} + Series read +
+
+
+
+ {/if} + {#each groups as { label, items }}
@@ -201,63 +198,78 @@ overflow: hidden; } + .stats-section { margin-bottom: var(--sp-5); } + + .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(4, 1fr); - gap: 1px; - background: var(--border-dim); - border-bottom: 1px solid var(--border-dim); - flex-shrink: 0; + grid-template-columns: 1fr 1fr; + gap: var(--sp-2); } .stat-card { display: flex; align-items: center; - gap: var(--sp-2); - padding: var(--sp-3) var(--sp-4); - background: var(--bg-base); - transition: background var(--t-base); + 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.streak .stat-icon-wrap { background: color-mix(in srgb, #f97316 12%, transparent); } - .stat-card.streak .stat-val { color: #f97316; } + .stat-card:hover { border-color: var(--border-base); } .stat-icon-wrap { display: flex; align-items: center; justify-content: center; - width: 24px; - height: 24px; + width: 32px; + height: 32px; border-radius: var(--radius-sm); - background: var(--bg-raised); - color: var(--text-faint); flex-shrink: 0; } - .stat-icon-wrap.fire { color: #f97316; } + .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: 1px; + gap: 2px; min-width: 0; } .stat-val { font-family: var(--font-ui); - font-size: var(--text-sm); - font-weight: var(--weight-semibold); + font-size: var(--text-lg, 1.05rem); + font-weight: var(--weight-medium); color: var(--text-secondary); line-height: 1; - letter-spacing: -0.01em; } - .stat-unit { + .stat-label { font-family: var(--font-ui); - font-size: 9px; + font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); - text-transform: uppercase; white-space: nowrap; } From 6fccf02614d828d24cf98526e94da43af9763e1f Mon Sep 17 00:00:00 2001 From: Zerebos Date: Thu, 21 May 2026 02:06:41 -0400 Subject: [PATCH 09/12] Single line stats --- .../recent/components/HistoryPanel.svelte | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/features/recent/components/HistoryPanel.svelte b/src/features/recent/components/HistoryPanel.svelte index fa2abc7..3398c1d 100644 --- a/src/features/recent/components/HistoryPanel.svelte +++ b/src/features/recent/components/HistoryPanel.svelte @@ -198,8 +198,6 @@ overflow: hidden; } - .stats-section { margin-bottom: var(--sp-5); } - .stats-header { display: flex; align-items: center; @@ -219,7 +217,7 @@ .stats-grid { display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: repeat(auto-fit, minmax(100px, 1fr)); gap: var(--sp-2); } @@ -276,23 +274,29 @@ .timeline { flex: 1; overflow-y: auto; - padding: var(--sp-4) var(--sp-5) var(--sp-6); + 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 { margin-bottom: 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); - padding-bottom: var(--sp-2); } .day-label { font-family: var(--font-ui); - font-size: 9px; + font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; @@ -303,7 +307,6 @@ flex: 1; height: 1px; background: var(--border-dim); - opacity: 0.5; } .session-list { From bd79169f7131b7e1bfc06bf76bab4539957a2069 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Thu, 21 May 2026 02:23:09 -0400 Subject: [PATCH 10/12] Basic caching --- src/core/cache/queryCache.ts | 1 + .../recent/components/UpdatesPanel.svelte | 24 +++++++++++++------ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/core/cache/queryCache.ts b/src/core/cache/queryCache.ts index 4eae1cf..1baae89 100644 --- a/src/core/cache/queryCache.ts +++ b/src/core/cache/queryCache.ts @@ -147,6 +147,7 @@ export const CACHE_GROUPS = { export const CACHE_KEYS = { LIBRARY: "library", + RECENT_UPDATES: "recent_updates", ALL_MANGA: "all_manga_unfiltered", CATEGORIES: "categories", SEARCH: "search_all_manga", diff --git a/src/features/recent/components/UpdatesPanel.svelte b/src/features/recent/components/UpdatesPanel.svelte index c4c9932..f63a865 100644 --- a/src/features/recent/components/UpdatesPanel.svelte +++ b/src/features/recent/components/UpdatesPanel.svelte @@ -3,6 +3,7 @@ import { BookOpen, CircleNotch } from "phosphor-svelte"; import { gql } from "@api/client"; import { GET_RECENTLY_UPDATED, GET_CHAPTERS } 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"; @@ -30,9 +31,10 @@ let openingId = $state(null); let ctrl: AbortController | null = null; + const RECENT_UPDATES_TTL_MS = 60 * 1_000; onMount(() => { - onRegisterRefresh?.(loadUpdates); + onRegisterRefresh?.(() => loadUpdates(true)); void loadUpdates(); }); @@ -46,13 +48,13 @@ } const groups = $derived.by(() => { - const map = new Map(); + const grouped: Record = {}; for (const item of updates) { const label = dayLabel(fetchedAtMs(item)); - if (!map.has(label)) map.set(label, []); - map.get(label)!.push(item); + if (!grouped[label]) grouped[label] = []; + grouped[label].push(item); } - return Array.from(map.entries()).map(([label, items]) => ({ label, items })) as UpdateGroup[]; + return Object.entries(grouped).map(([label, items]) => ({ label, items })) as UpdateGroup[]; }); const lastCheckedTs = $derived( @@ -86,7 +88,7 @@ return "Chapter"; } - async function loadUpdates() { + async function loadUpdates(force = false) { ctrl?.abort(); const nextCtrl = new AbortController(); ctrl = nextCtrl; @@ -94,7 +96,15 @@ error = null; try { - const res = await gql<{ chapters: { nodes: RecentUpdate[] } }>(GET_RECENTLY_UPDATED, {}, nextCtrl.signal); + const key = CACHE_KEYS.RECENT_UPDATES; + if (force) cache.clear(key); + + const res = await cache.get<{ chapters: { nodes: RecentUpdate[] } }>( + key, + () => gql<{ chapters: { nodes: RecentUpdate[] } }>(GET_RECENTLY_UPDATED, {}, nextCtrl.signal), + RECENT_UPDATES_TTL_MS, + CACHE_GROUPS.LIBRARY, + ); if (nextCtrl.signal.aborted) return; updates = res.chapters.nodes From 745b6993de88ab3d94bc7fe1654c616539a584a8 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Thu, 21 May 2026 02:33:06 -0400 Subject: [PATCH 11/12] Actually grab status from server --- src/api/queries/manga.ts | 17 ++++++ src/api/queries/queries.md | 1 + .../recent/components/UpdatesPanel.svelte | 59 +++++++++++++------ 3 files changed, 59 insertions(+), 18 deletions(-) diff --git a/src/api/queries/manga.ts b/src/api/queries/manga.ts index d2656eb..4eceda5 100644 --- a/src/api/queries/manga.ts +++ b/src/api/queries/manga.ts @@ -78,6 +78,23 @@ export const LIBRARY_UPDATE_STATUS = ` } `; +export const GET_LIBRARY_UPDATE_PANEL_STATUS = ` + query GetLibraryUpdatePanelStatus { + libraryUpdateStatus { + jobsInfo { + isRunning + finishedJobs + totalJobs + skippedMangasCount + skippedCategoriesCount + } + } + lastUpdateTimestamp { + timestamp + } + } +`; + export const GET_RESTORE_STATUS = ` query GetRestoreStatus($id: String!) { restoreStatus(id: $id) { mangaProgress state totalManga } diff --git a/src/api/queries/queries.md b/src/api/queries/queries.md index e8dadef..edbebc8 100644 --- a/src/api/queries/queries.md +++ b/src/api/queries/queries.md @@ -11,6 +11,7 @@ | `GET_DOWNLOADED_CHAPTERS_PAGES` | — | Page counts for all downloaded chapters — used for storage stats | | `GET_DOWNLOADS_PATH` | — | `downloadsPath` and `localSourcePath` from settings | | `LIBRARY_UPDATE_STATUS` | — | Current library update job — `jobsInfo` progress and `mangaUpdates` list with new chapters | +| `GET_LIBRARY_UPDATE_PANEL_STATUS` | — | Library updater status + server `lastUpdateTimestamp` for UI status displays | | `GET_RESTORE_STATUS` | `id: String!` | Backup restore job status by job ID — `mangaProgress`, `state`, `totalManga` | | `VALIDATE_BACKUP` | `backup: Upload!` | Validate a backup file before restore — returns missing sources and trackers | | `MANGAS_BY_GENRE` | `filter: MangaFilterInput`, `first: Int`, `offset: Int` | Paginated manga filtered by genre, ordered by `IN_LIBRARY_AT DESC` | diff --git a/src/features/recent/components/UpdatesPanel.svelte b/src/features/recent/components/UpdatesPanel.svelte index f63a865..2416169 100644 --- a/src/features/recent/components/UpdatesPanel.svelte +++ b/src/features/recent/components/UpdatesPanel.svelte @@ -2,7 +2,7 @@ import { onMount, onDestroy } from "svelte"; import { BookOpen, CircleNotch } from "phosphor-svelte"; import { gql } from "@api/client"; - import { GET_RECENTLY_UPDATED, GET_CHAPTERS } from "@api/queries"; + import { GET_RECENTLY_UPDATED, GET_CHAPTERS, GET_LIBRARY_UPDATE_PANEL_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"; @@ -29,6 +29,8 @@ let updates = $state([]); let error = $state(null); let openingId = $state(null); + let updaterRunning = $state(false); + let lastUpdatedTs = $state(null); let ctrl: AbortController | null = null; const RECENT_UPDATES_TTL_MS = 60 * 1_000; @@ -57,13 +59,9 @@ return Object.entries(grouped).map(([label, items]) => ({ label, items })) as UpdateGroup[]; }); - const lastCheckedTs = $derived( - updates.length > 0 ? fetchedAtMs(updates[0]) : null - ); - - const lastCheckedLabel = $derived( - lastCheckedTs - ? new Date(lastCheckedTs).toLocaleString("en-US", { + const lastUpdatedLabel = $derived( + lastUpdatedTs + ? new Date(lastUpdatedTs).toLocaleString("en-US", { month: "short", day: "numeric", year: "numeric", @@ -73,6 +71,17 @@ : 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 mangaStub(item: RecentUpdate): Manga { return { id: item.manga?.id ?? item.mangaId, @@ -99,21 +108,35 @@ const key = CACHE_KEYS.RECENT_UPDATES; if (force) cache.clear(key); - const res = await cache.get<{ chapters: { nodes: RecentUpdate[] } }>( - key, - () => gql<{ chapters: { nodes: RecentUpdate[] } }>(GET_RECENTLY_UPDATED, {}, nextCtrl.signal), - RECENT_UPDATES_TTL_MS, - CACHE_GROUPS.LIBRARY, - ); + 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; + }>(GET_LIBRARY_UPDATE_PANEL_STATUS, {}, nextCtrl.signal).catch(() => null), + ]); + + updaterRunning = statusRes?.libraryUpdateStatus.jobsInfo.isRunning ?? false; + lastUpdatedTs = parseServerTimestamp(statusRes?.lastUpdateTimestamp?.timestamp ?? null); + if (nextCtrl.signal.aborted) return; - updates = res.chapters.nodes + 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; } finally { if (!nextCtrl.signal.aborted) loading = false; } @@ -150,11 +173,11 @@
- {#if loading}Checking for updates…{:else if error}Update check failed{:else}Up to date{/if} + {#if loading}Checking for updates…{:else if error}Update check failed{:else if updaterRunning}Library update in progress{:else}Up to date{/if}
- {#if !loading && lastCheckedLabel} - Last checked: {lastCheckedLabel} + {#if !loading && lastUpdatedLabel} + Last updated: {lastUpdatedLabel}
{/if} {#if !loading && updates.length > 0} From b0efb183e8ac175dfc6a956ce09be842c934c015 Mon Sep 17 00:00:00 2001 From: Zerebos Date: Thu, 21 May 2026 02:43:06 -0400 Subject: [PATCH 12/12] Poll when updating on server --- src/api/queries/manga.ts | 14 --- src/api/queries/queries.md | 3 +- .../recent/components/UpdatesPanel.svelte | 85 +++++++++++++++++-- 3 files changed, 80 insertions(+), 22 deletions(-) diff --git a/src/api/queries/manga.ts b/src/api/queries/manga.ts index 4eceda5..ac89539 100644 --- a/src/api/queries/manga.ts +++ b/src/api/queries/manga.ts @@ -75,20 +75,6 @@ export const LIBRARY_UPDATE_STATUS = ` manga { id title thumbnailUrl unreadCount } } } - } -`; - -export const GET_LIBRARY_UPDATE_PANEL_STATUS = ` - query GetLibraryUpdatePanelStatus { - libraryUpdateStatus { - jobsInfo { - isRunning - finishedJobs - totalJobs - skippedMangasCount - skippedCategoriesCount - } - } lastUpdateTimestamp { timestamp } diff --git a/src/api/queries/queries.md b/src/api/queries/queries.md index edbebc8..7c441a8 100644 --- a/src/api/queries/queries.md +++ b/src/api/queries/queries.md @@ -10,8 +10,7 @@ | `GET_CATEGORIES` | — | All categories with order/settings and their assigned manga (minimal fields) | | `GET_DOWNLOADED_CHAPTERS_PAGES` | — | Page counts for all downloaded chapters — used for storage stats | | `GET_DOWNLOADS_PATH` | — | `downloadsPath` and `localSourcePath` from settings | -| `LIBRARY_UPDATE_STATUS` | — | Current library update job — `jobsInfo` progress and `mangaUpdates` list with new chapters | -| `GET_LIBRARY_UPDATE_PANEL_STATUS` | — | Library updater status + server `lastUpdateTimestamp` for UI status displays | +| `LIBRARY_UPDATE_STATUS` | — | Current library update job (`jobsInfo`, `mangaUpdates`) plus `lastUpdateTimestamp` for server-side update timing | | `GET_RESTORE_STATUS` | `id: String!` | Backup restore job status by job ID — `mangaProgress`, `state`, `totalManga` | | `VALIDATE_BACKUP` | `backup: Upload!` | Validate a backup file before restore — returns missing sources and trackers | | `MANGAS_BY_GENRE` | `filter: MangaFilterInput`, `first: Int`, `offset: Int` | Paginated manga filtered by genre, ordered by `IN_LIBRARY_AT DESC` | diff --git a/src/features/recent/components/UpdatesPanel.svelte b/src/features/recent/components/UpdatesPanel.svelte index 2416169..4539637 100644 --- a/src/features/recent/components/UpdatesPanel.svelte +++ b/src/features/recent/components/UpdatesPanel.svelte @@ -2,7 +2,7 @@ import { onMount, onDestroy } from "svelte"; import { BookOpen, CircleNotch } from "phosphor-svelte"; import { gql } from "@api/client"; - import { GET_RECENTLY_UPDATED, GET_CHAPTERS, GET_LIBRARY_UPDATE_PANEL_STATUS } from "@api/queries"; + 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"; @@ -31,9 +31,13 @@ let openingId = $state(null); let updaterRunning = $state(false); let lastUpdatedTs = $state(null); + let updaterFinishedJobs = $state(null); + let updaterTotalJobs = $state(null); let ctrl: AbortController | null = null; + let statusPollTimer: ReturnType | null = null; const RECENT_UPDATES_TTL_MS = 60 * 1_000; + const UPDATE_STATUS_POLL_MS = 2_000; onMount(() => { onRegisterRefresh?.(() => loadUpdates(true)); @@ -42,6 +46,7 @@ onDestroy(() => { ctrl?.abort(); + stopStatusPolling(); }); function fetchedAtMs(item: Pick): number { @@ -71,6 +76,12 @@ : 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") { @@ -82,6 +93,64 @@ 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, @@ -120,11 +189,12 @@ jobsInfo: { isRunning: boolean }; }; lastUpdateTimestamp: { timestamp: string | number | null } | null; - }>(GET_LIBRARY_UPDATE_PANEL_STATUS, {}, nextCtrl.signal).catch(() => null), + }>(LIBRARY_UPDATE_STATUS, {}, nextCtrl.signal).catch(() => null), ]); - updaterRunning = statusRes?.libraryUpdateStatus.jobsInfo.isRunning ?? false; - lastUpdatedTs = parseServerTimestamp(statusRes?.lastUpdateTimestamp?.timestamp ?? null); + applyUpdateStatus(statusRes); + if (updaterRunning) scheduleStatusPoll(); + else stopStatusPolling(); if (nextCtrl.signal.aborted) return; @@ -137,6 +207,9 @@ updates = []; updaterRunning = false; lastUpdatedTs = null; + updaterFinishedJobs = null; + updaterTotalJobs = null; + stopStatusPolling(); } finally { if (!nextCtrl.signal.aborted) loading = false; } @@ -171,9 +244,9 @@
-
+
- {#if loading}Checking for updates…{:else if error}Update check failed{:else if updaterRunning}Library update in progress{:else}Up to date{/if} + {#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}
{#if !loading && lastUpdatedLabel}