mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bf071dcfc7 | |||
| b0efb183e8 | |||
| 745b6993de | |||
| bd79169f71 | |||
| 6fccf02614 | |||
| fa7cfdc4e6 | |||
| 9c614b38f8 | |||
| 30e50b5a1b | |||
| 8ef0a14363 | |||
| 4e2ad6cae7 | |||
| 9e56b1176c | |||
| d025d07e07 | |||
| f988641446 |
@@ -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 }
|
||||||
@@ -19,4 +25,4 @@ export const GET_CHAPTERS = `
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -75,6 +75,9 @@ export const LIBRARY_UPDATE_STATUS = `
|
|||||||
manga { id title thumbnailUrl unreadCount }
|
manga { id title thumbnailUrl unreadCount }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
lastUpdateTimestamp {
|
||||||
|
timestamp
|
||||||
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
| `GET_CATEGORIES` | — | All categories with order/settings and their assigned manga (minimal fields) |
|
| `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_DOWNLOADED_CHAPTERS_PAGES` | — | Page counts for all downloaded chapters — used for storage stats |
|
||||||
| `GET_DOWNLOADS_PATH` | — | `downloadsPath` and `localSourcePath` from settings |
|
| `GET_DOWNLOADS_PATH` | — | `downloadsPath` and `localSourcePath` from settings |
|
||||||
| `LIBRARY_UPDATE_STATUS` | — | Current library update job — `jobsInfo` progress and `mangaUpdates` list with new chapters |
|
| `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` |
|
| `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 |
|
| `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` |
|
| `MANGAS_BY_GENRE` | `filter: MangaFilterInput`, `first: Int`, `offset: Int` | Paginated manga filtered by genre, ordered by `IN_LIBRARY_AT DESC` |
|
||||||
|
|||||||
Vendored
+1
@@ -147,6 +147,7 @@ export const CACHE_GROUPS = {
|
|||||||
|
|
||||||
export const CACHE_KEYS = {
|
export const CACHE_KEYS = {
|
||||||
LIBRARY: "library",
|
LIBRARY: "library",
|
||||||
|
RECENT_UPDATES: "recent_updates",
|
||||||
ALL_MANGA: "all_manga_unfiltered",
|
ALL_MANGA: "all_manga_unfiltered",
|
||||||
CATEGORIES: "categories",
|
CATEGORIES: "categories",
|
||||||
SEARCH: "search_all_manga",
|
SEARCH: "search_all_manga",
|
||||||
|
|||||||
@@ -23,8 +23,28 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="empty">
|
<div class="list">
|
||||||
<CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" />
|
{#each Array(5) as _, i (i)}
|
||||||
|
<div class="sk-row">
|
||||||
|
<div class="sk-thumb skeleton"></div>
|
||||||
|
|
||||||
|
<div class="sk-info">
|
||||||
|
<div class="skeleton sk-title"></div>
|
||||||
|
<div class="skeleton sk-chapter"></div>
|
||||||
|
<div class="sk-progress-row">
|
||||||
|
<div class="skeleton sk-bar"></div>
|
||||||
|
<div class="skeleton sk-pages"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sk-right">
|
||||||
|
<div class="skeleton sk-state"></div>
|
||||||
|
<div class="sk-actions">
|
||||||
|
<div class="skeleton sk-btn"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else if queue.length === 0}
|
{:else if queue.length === 0}
|
||||||
<div class="empty">Queue is empty.</div>
|
<div class="empty">Queue is empty.</div>
|
||||||
@@ -49,4 +69,30 @@
|
|||||||
<style>
|
<style>
|
||||||
.list { display: flex; flex-direction: column; gap: var(--sp-2); }
|
.list { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
.empty { display: flex; align-items: center; justify-content: center; height: 160px; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
.empty { display: flex; align-items: center; justify-content: center; height: 160px; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
|
||||||
|
|
||||||
|
@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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sk-row { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); pointer-events: none; }
|
||||||
|
.sk-thumb { width: 36px; height: 54px; flex-shrink: 0; }
|
||||||
|
.sk-info { flex: 1; display: flex; flex-direction: column; gap: 5px; overflow: hidden; min-width: 0; }
|
||||||
|
.sk-title { height: 12px; width: clamp(120px, 55%, 280px); }
|
||||||
|
.sk-chapter { height: 10px; width: clamp(80px, 35%, 200px); }
|
||||||
|
.sk-progress-row { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
|
.sk-bar { flex: 1; height: 2px; }
|
||||||
|
.sk-pages { width: 28px; height: 9px; }
|
||||||
|
.sk-right { display: flex; flex-direction: column; align-items: flex-end; gap: var(--sp-1); flex-shrink: 0; }
|
||||||
|
.sk-state { width: 54px; height: 9px; }
|
||||||
|
.sk-actions { display: flex; gap: 2px; }
|
||||||
|
.sk-btn { width: 20px; height: 20px; border-radius: var(--radius-sm); }
|
||||||
</style>
|
</style>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { MagnifyingGlass, ArrowsClockwise, Plus, GitBranch, ArrowCircleUp } from "phosphor-svelte";
|
import { MagnifyingGlass, ArrowsClockwise, Plus, GitBranch, ArrowCircleUp, CheckCircle, Rows, Globe } from "phosphor-svelte";
|
||||||
import { FILTERS, type Filter, type Panel } from "../lib/extensionHelpers";
|
import { FILTERS, type Filter, type Panel } from "../lib/extensionHelpers";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -40,6 +40,15 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{#each FILTERS as f}
|
{#each FILTERS as f}
|
||||||
<button class="tab" class:active={filter === f.id} onclick={() => onFilter(f.id)}>
|
<button class="tab" class:active={filter === f.id} onclick={() => onFilter(f.id)}>
|
||||||
|
{#if f.id === "installed"}
|
||||||
|
<CheckCircle size={11} weight="bold" />
|
||||||
|
{:else if f.id === "available"}
|
||||||
|
<Globe size={11} weight="bold" />
|
||||||
|
{:else if f.id === "updates"}
|
||||||
|
<ArrowCircleUp size={11} weight="bold" />
|
||||||
|
{:else if f.id === "all"}
|
||||||
|
<Rows size={11} weight="bold" />
|
||||||
|
{/if}
|
||||||
{f.id === "updates" && updateCount > 0 ? `Updates (${updateCount})` : f.label}
|
{f.id === "updates" && updateCount > 0 ? `Updates (${updateCount})` : f.label}
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
+90
-198
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { ClockCounterClockwise, Trash, MagnifyingGlass, Books, Fire, BookOpen, Clock, TrendUp } from "phosphor-svelte";
|
import { ClockCounterClockwise, Books, Fire, BookOpen, Clock, TrendUp } from "phosphor-svelte";
|
||||||
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
import Thumbnail from "@shared/manga/Thumbnail.svelte";
|
||||||
import { store, clearHistory, setPreviewManga } from "@store/state.svelte";
|
import { store, setPreviewManga } from "@store/state.svelte";
|
||||||
import { gql } from "@api/client";
|
import { gql } from "@api/client";
|
||||||
import { GET_LIBRARY } from "@api/queries/manga";
|
import { GET_LIBRARY } from "@api/queries/manga";
|
||||||
import { cache, CACHE_KEYS } from "@core/cache";
|
import { cache, CACHE_KEYS } from "@core/cache";
|
||||||
@@ -10,6 +10,13 @@
|
|||||||
import type { Manga } from "@types";
|
import type { Manga } from "@types";
|
||||||
import { timeAgo, dayLabel, formatReadTime } from "@core/util";
|
import { timeAgo, dayLabel, formatReadTime } from "@core/util";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
search: string;
|
||||||
|
confirmClear: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { search, confirmClear }: Props = $props();
|
||||||
|
|
||||||
let libraryManga = $state<Manga[]>([]);
|
let libraryManga = $state<Manga[]>([]);
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -24,9 +31,6 @@
|
|||||||
return libraryManga.find(m => m.id === mangaId)?.thumbnailUrl ?? fallback ?? "";
|
return libraryManga.find(m => m.id === mangaId)?.thumbnailUrl ?? fallback ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
let search = $state("");
|
|
||||||
let confirmClear = $state(false);
|
|
||||||
|
|
||||||
const SESSION_GAP_MS = 30 * 60 * 1000;
|
const SESSION_GAP_MS = 30 * 60 * 1000;
|
||||||
|
|
||||||
interface Session {
|
interface Session {
|
||||||
@@ -90,83 +94,9 @@
|
|||||||
}
|
}
|
||||||
return Array.from(map.entries()).map(([label, items]) => ({ label, items }));
|
return Array.from(map.entries()).map(([label, items]) => ({ label, items }));
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleClear() {
|
|
||||||
if (!confirmClear) { confirmClear = true; setTimeout(() => confirmClear = false, 3000); return; }
|
|
||||||
clearHistory(); confirmClear = false;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="root anim-fade-in">
|
<div class="root anim-fade-in">
|
||||||
|
|
||||||
<div class="header">
|
|
||||||
<div class="heading-group">
|
|
||||||
<ClockCounterClockwise size={13} weight="light" class="heading-icon" />
|
|
||||||
<span class="heading">History</span>
|
|
||||||
</div>
|
|
||||||
<div class="header-right">
|
|
||||||
<div class="search-wrap">
|
|
||||||
<MagnifyingGlass size={11} class="search-icon" weight="light" />
|
|
||||||
<input class="search" placeholder="Search…" bind:value={search} />
|
|
||||||
{#if search}
|
|
||||||
<button class="search-clear" onclick={() => search = ""}>×</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if store.history.length > 0}
|
|
||||||
<button
|
|
||||||
class="clear-btn"
|
|
||||||
class:confirm={confirmClear}
|
|
||||||
onclick={handleClear}
|
|
||||||
title={confirmClear ? "Click again to confirm" : "Clear history"}
|
|
||||||
>
|
|
||||||
<Trash size={12} weight="light" />
|
|
||||||
{#if confirmClear}<span class="clear-label">Confirm?</span>{/if}
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if store.readingStats.totalChaptersRead > 0}
|
|
||||||
<div class="stats-grid">
|
|
||||||
<div class="stat-card streak">
|
|
||||||
<div class="stat-icon-wrap fire">
|
|
||||||
<Fire size={12} weight="fill" />
|
|
||||||
</div>
|
|
||||||
<div class="stat-body">
|
|
||||||
<span class="stat-val">{store.readingStats.currentStreakDays}</span>
|
|
||||||
<span class="stat-unit">day streak</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-icon-wrap">
|
|
||||||
<BookOpen size={12} weight="light" />
|
|
||||||
</div>
|
|
||||||
<div class="stat-body">
|
|
||||||
<span class="stat-val">{store.readingStats.totalChaptersRead}</span>
|
|
||||||
<span class="stat-unit">chapters</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-icon-wrap">
|
|
||||||
<Clock size={12} weight="light" />
|
|
||||||
</div>
|
|
||||||
<div class="stat-body">
|
|
||||||
<span class="stat-val">{formatReadTime(store.readingStats.totalMinutesRead)}</span>
|
|
||||||
<span class="stat-unit">read time</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="stat-card">
|
|
||||||
<div class="stat-icon-wrap">
|
|
||||||
<TrendUp size={12} weight="light" />
|
|
||||||
</div>
|
|
||||||
<div class="stat-body">
|
|
||||||
<span class="stat-val">{store.readingStats.totalMangaRead}</span>
|
|
||||||
<span class="stat-unit">series</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if store.history.length === 0}
|
{#if store.history.length === 0}
|
||||||
<div class="empty">
|
<div class="empty">
|
||||||
<div class="empty-icon-wrap">
|
<div class="empty-icon-wrap">
|
||||||
@@ -184,6 +114,44 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="timeline">
|
<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 }}
|
{#each groups as { label, items }}
|
||||||
<div class="day-group">
|
<div class="day-group">
|
||||||
<div class="day-header">
|
<div class="day-header">
|
||||||
@@ -230,180 +198,105 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header {
|
.stats-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
padding-bottom: var(--sp-2);
|
||||||
padding: var(--sp-4) var(--sp-6);
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.heading-group {
|
.stats-title {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
}
|
|
||||||
|
|
||||||
:global(.heading-icon) { color: var(--text-faint); }
|
|
||||||
|
|
||||||
.heading {
|
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-size: var(--text-xs);
|
font-size: var(--text-2xs);
|
||||||
font-weight: var(--weight-medium);
|
color: var(--text-faint);
|
||||||
color: var(--text-muted);
|
|
||||||
letter-spacing: var(--tracking-wider);
|
letter-spacing: var(--tracking-wider);
|
||||||
text-transform: uppercase;
|
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 {
|
.stats-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(4, 1fr);
|
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
|
||||||
gap: 1px;
|
gap: var(--sp-2);
|
||||||
background: var(--border-dim);
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--sp-2);
|
gap: var(--sp-3);
|
||||||
padding: var(--sp-3) var(--sp-4);
|
background: var(--bg-raised);
|
||||||
background: var(--bg-base);
|
border: 1px solid var(--border-dim);
|
||||||
transition: background var(--t-base);
|
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:hover { border-color: var(--border-base); }
|
||||||
.stat-card.streak .stat-val { color: #f97316; }
|
|
||||||
|
|
||||||
.stat-icon-wrap {
|
.stat-icon-wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 24px;
|
width: 32px;
|
||||||
height: 24px;
|
height: 32px;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--bg-raised);
|
|
||||||
color: var(--text-faint);
|
|
||||||
flex-shrink: 0;
|
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 {
|
.stat-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1px;
|
gap: 2px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-val {
|
.stat-val {
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-size: var(--text-sm);
|
font-size: var(--text-lg, 1.05rem);
|
||||||
font-weight: var(--weight-semibold);
|
font-weight: var(--weight-medium);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
letter-spacing: -0.01em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-unit {
|
.stat-label {
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-size: 9px;
|
font-size: var(--text-2xs);
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
letter-spacing: var(--tracking-wide);
|
letter-spacing: var(--tracking-wide);
|
||||||
text-transform: uppercase;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.timeline {
|
.timeline {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
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-width: thin;
|
||||||
scrollbar-color: var(--border-dim) transparent;
|
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 {
|
.day-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--sp-3);
|
gap: var(--sp-3);
|
||||||
padding-bottom: var(--sp-2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.day-label {
|
.day-label {
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-size: 9px;
|
font-size: var(--text-2xs);
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
letter-spacing: var(--tracking-wider);
|
letter-spacing: var(--tracking-wider);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -414,12 +307,12 @@
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: var(--border-dim);
|
background: var(--border-dim);
|
||||||
opacity: 0.5;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-list {
|
.session-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
gap: var(--sp-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-row {
|
.session-row {
|
||||||
@@ -427,17 +320,16 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--sp-3);
|
gap: var(--sp-3);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: var(--sp-2) var(--sp-2);
|
padding: var(--sp-3);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
border: none;
|
border: 1px solid var(--border-dim);
|
||||||
background: none;
|
background: var(--bg-raised);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
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:hover { border-color: var(--border-strong); background: var(--bg-elevated); }
|
||||||
.session-row:active { background: var(--bg-elevated); }
|
|
||||||
|
|
||||||
.thumb-wrap {
|
.thumb-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -494,8 +386,8 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
font-size: var(--text-2xs);
|
font-size: var(--text-xs);
|
||||||
color: var(--text-faint);
|
color: var(--text-muted);
|
||||||
letter-spacing: var(--tracking-wide);
|
letter-spacing: var(--tracking-wide);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -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>
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { store } from "@store/state.svelte";
|
import { store } from "@store/state.svelte";
|
||||||
import Sidebar from "@shared/chrome/Sidebar.svelte";
|
import Sidebar from "@shared/chrome/Sidebar.svelte";
|
||||||
import RecentActivity from "@shared/chrome/RecentActivity.svelte";
|
|
||||||
import Library from "@features/library/components/Library.svelte";
|
import Library from "@features/library/components/Library.svelte";
|
||||||
import SeriesDetail from "@features/series/components/SeriesDetail.svelte";
|
import SeriesDetail from "@features/series/components/SeriesDetail.svelte";
|
||||||
import Home from "@features/home/components/Home.svelte";
|
import Home from "@features/home/components/Home.svelte";
|
||||||
@@ -10,6 +9,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 Recent from "@features/recent/components/Recent.svelte";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="frame">
|
<div class="frame">
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
{:else if store.navPage === "search"}
|
{:else if store.navPage === "search"}
|
||||||
<Search />
|
<Search />
|
||||||
{:else if store.navPage === "history"}
|
{:else if store.navPage === "history"}
|
||||||
<RecentActivity />
|
<Recent />
|
||||||
{:else if store.navPage === "downloads"}
|
{:else if store.navPage === "downloads"}
|
||||||
<Downloads />
|
<Downloads />
|
||||||
{:else if store.navPage === "extensions"}
|
{:else if store.navPage === "extensions"}
|
||||||
@@ -45,4 +45,4 @@
|
|||||||
.frame { display: flex; padding: 6px 15px 15px; width: 100%; height: 100%; box-sizing: border-box; overflow: hidden; }
|
.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; }
|
.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; }
|
.main { flex: 1; overflow: hidden; background: var(--bg-surface); transform: translateZ(0); contain: layout style; min-width: 0; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
{ 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: "search", label: "Search", icon: MagnifyingGlass },
|
{ id: "search", label: "Search", icon: MagnifyingGlass },
|
||||||
{ id: "history", label: "History", icon: ClockCounterClockwise },
|
{ id: "history", label: "Recent", icon: ClockCounterClockwise },
|
||||||
{ id: "downloads", label: "Downloads", icon: DownloadSimple },
|
{ id: "downloads", label: "Downloads", icon: DownloadSimple },
|
||||||
{ id: "extensions", label: "Extensions", icon: PuzzlePiece },
|
{ id: "extensions", label: "Extensions", icon: PuzzlePiece },
|
||||||
{ id: "tracking", label: "Tracking", icon: ChartLineUp },
|
{ id: "tracking", label: "Tracking", icon: ChartLineUp },
|
||||||
@@ -93,4 +93,4 @@
|
|||||||
.settings-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
.settings-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
|
||||||
.settings-btn.anims { transition: color var(--t-base), background var(--t-base), transform var(--t-slow); }
|
.settings-btn.anims { transition: color var(--t-base), background var(--t-base), transform var(--t-slow); }
|
||||||
.settings-btn.anims:hover { transform: rotate(30deg); }
|
.settings-btn.anims:hover { transform: rotate(30deg); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -31,4 +31,4 @@ export function setSearchPrefill(next: string) { app.setSearchPrefill(next);
|
|||||||
export function setSearchQuery(next: string) { app.setSearchQuery(next); }
|
export function setSearchQuery(next: string) { app.setSearchQuery(next); }
|
||||||
export function setGenreFilter(next: string) { app.setGenreFilter(next); }
|
export function setGenreFilter(next: string) { app.setGenreFilter(next); }
|
||||||
export function saveScroll(key: string, top: number) { app.saveScroll(key, top); }
|
export function saveScroll(key: string, top: number) { app.saveScroll(key, top); }
|
||||||
export function getScroll(key: string): number { return app.getScroll(key); }
|
export function getScroll(key: string): number { return app.getScroll(key); }
|
||||||
|
|||||||
Reference in New Issue
Block a user