mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Chore: Temporary Home-Page (Until Fixed with Rewrite)
This commit is contained in:
@@ -1,283 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { derived } from "svelte/store";
|
|
||||||
import { ClockCounterClockwise, Trash, MagnifyingGlass, Play, Books } from "phosphor-svelte";
|
|
||||||
import { thumbUrl } from "../../lib/client";
|
|
||||||
import { history, settings, activeManga, activeChapter, activeChapterList, openReader } from "../../store";
|
|
||||||
import type { HistoryEntry } from "../../store";
|
|
||||||
|
|
||||||
let search = "";
|
|
||||||
|
|
||||||
function timeAgo(ts: number): string {
|
|
||||||
const diff = Date.now() - ts, m = Math.floor(diff / 60000);
|
|
||||||
if (m < 1) return "Just now";
|
|
||||||
if (m < 60) return `${m}m ago`;
|
|
||||||
const h = Math.floor(m / 60);
|
|
||||||
if (h < 24) return `${h}h ago`;
|
|
||||||
const d = Math.floor(h / 24);
|
|
||||||
if (d < 7) return `${d}d ago`;
|
|
||||||
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function dayLabel(ts: number): string {
|
|
||||||
const d = new Date(ts), now = new Date();
|
|
||||||
if (d.toDateString() === now.toDateString()) return "Today";
|
|
||||||
const yest = new Date(now); yest.setDate(now.getDate() - 1);
|
|
||||||
if (d.toDateString() === yest.toDateString()) return "Yesterday";
|
|
||||||
return d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" });
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatReadTime(m: number): string {
|
|
||||||
if (m < 1) return "< 1 min";
|
|
||||||
if (m < 60) return `${m} min`;
|
|
||||||
const h = Math.floor(m / 60), r = m % 60;
|
|
||||||
return r === 0 ? `${h}h` : `${h}h ${r}m`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const SESSION_GAP_MS = 30 * 60 * 1000;
|
|
||||||
|
|
||||||
interface Session {
|
|
||||||
mangaId: number; mangaTitle: string; thumbnailUrl: string;
|
|
||||||
latestChapterId: number; latestChapterName: string; latestPageNumber: number;
|
|
||||||
firstChapterName: string; chapterCount: number; readAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSessions(entries: HistoryEntry[]): Session[] {
|
|
||||||
if (!entries.length) return [];
|
|
||||||
const sessions: Session[] = [];
|
|
||||||
let i = 0;
|
|
||||||
while (i < entries.length) {
|
|
||||||
const anchor = entries[i];
|
|
||||||
const group: HistoryEntry[] = [anchor];
|
|
||||||
let j = i + 1;
|
|
||||||
while (j < entries.length) {
|
|
||||||
const next = entries[j];
|
|
||||||
if (next.mangaId === anchor.mangaId && anchor.readAt - next.readAt <= SESSION_GAP_MS) { group.push(next); j++; }
|
|
||||||
else break;
|
|
||||||
}
|
|
||||||
const latest = group[0], oldest = group[group.length - 1];
|
|
||||||
sessions.push({
|
|
||||||
mangaId: latest.mangaId, mangaTitle: latest.mangaTitle, thumbnailUrl: latest.thumbnailUrl,
|
|
||||||
latestChapterId: latest.chapterId, latestChapterName: latest.chapterName,
|
|
||||||
latestPageNumber: latest.pageNumber, firstChapterName: oldest.chapterName,
|
|
||||||
chapterCount: group.length, readAt: latest.readAt,
|
|
||||||
});
|
|
||||||
i = j;
|
|
||||||
}
|
|
||||||
return sessions;
|
|
||||||
}
|
|
||||||
|
|
||||||
$: filtered = search.trim()
|
|
||||||
? $history.filter((e) => e.mangaTitle.toLowerCase().includes(search.toLowerCase()) || e.chapterName.toLowerCase().includes(search.toLowerCase()))
|
|
||||||
: $history;
|
|
||||||
|
|
||||||
$: sessions = buildSessions(filtered);
|
|
||||||
|
|
||||||
$: groups = (() => {
|
|
||||||
const map = new Map<string, Session[]>();
|
|
||||||
for (const s of sessions) {
|
|
||||||
const l = dayLabel(s.readAt);
|
|
||||||
if (!map.has(l)) map.set(l, []);
|
|
||||||
map.get(l)!.push(s);
|
|
||||||
}
|
|
||||||
return Array.from(map.entries()).map(([label, items]) => ({ label, items }));
|
|
||||||
})();
|
|
||||||
|
|
||||||
$: stats = $history.length ? {
|
|
||||||
uniqueChapters: new Set($history.map((e) => e.chapterId)).size,
|
|
||||||
uniqueManga: new Set($history.map((e) => e.mangaId)).size,
|
|
||||||
estimatedMinutes: Math.round(new Set($history.map((e) => e.chapterId)).size * 4.5),
|
|
||||||
} : null;
|
|
||||||
|
|
||||||
function resume(session: Session) {
|
|
||||||
const ch = $activeChapterList.find((c) => c.id === session.latestChapterId);
|
|
||||||
if (ch && $activeChapterList.length > 0) openReader(ch, $activeChapterList);
|
|
||||||
else activeManga.set({ id: session.mangaId, title: session.mangaTitle, thumbnailUrl: session.thumbnailUrl } as any);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="root">
|
|
||||||
<div class="header">
|
|
||||||
<h1 class="heading">History</h1>
|
|
||||||
<div class="header-right">
|
|
||||||
<div class="search-wrap">
|
|
||||||
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
|
||||||
<input class="search" placeholder="Search history…" bind:value={search} />
|
|
||||||
{#if search}
|
|
||||||
<button class="search-clear" on:click={() => search = ""}>×</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{#if $history.length > 0}
|
|
||||||
<button class="clear-btn" on:click={() => history.set([])} title="Clear all history">
|
|
||||||
<Trash size={14} weight="light" />
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if stats}
|
|
||||||
<div class="stats-bar">
|
|
||||||
<span class="stat-item">
|
|
||||||
<span class="stat-val">{stats.uniqueChapters}</span>
|
|
||||||
<span class="stat-label">chapters read</span>
|
|
||||||
</span>
|
|
||||||
<span class="stat-div"></span>
|
|
||||||
<span class="stat-item">
|
|
||||||
<span class="stat-val">{stats.uniqueManga}</span>
|
|
||||||
<span class="stat-label">series</span>
|
|
||||||
</span>
|
|
||||||
<span class="stat-div"></span>
|
|
||||||
<span class="stat-item">
|
|
||||||
<span class="stat-val">{formatReadTime(stats.estimatedMinutes)}</span>
|
|
||||||
<span class="stat-label">est. read time</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if $history.length === 0}
|
|
||||||
<div class="empty">
|
|
||||||
<ClockCounterClockwise size={32} weight="light" class="empty-icon" />
|
|
||||||
<p class="empty-text">No reading history yet</p>
|
|
||||||
<p class="empty-hint">Chapters you read will appear here</p>
|
|
||||||
</div>
|
|
||||||
{:else if sessions.length === 0}
|
|
||||||
<div class="empty">
|
|
||||||
<Books size={28} weight="light" class="empty-icon" />
|
|
||||||
<p class="empty-text">No results for "{search}"</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="list">
|
|
||||||
{#each groups as { label, items }}
|
|
||||||
<div class="group">
|
|
||||||
<p class="group-label">{label}</p>
|
|
||||||
{#each items as session}
|
|
||||||
<button class="row" on:click={() => resume(session)}>
|
|
||||||
<div class="thumb-wrap">
|
|
||||||
<img src={thumbUrl(session.thumbnailUrl)} alt={session.mangaTitle} class="thumb" />
|
|
||||||
{#if session.chapterCount > 1}
|
|
||||||
<span class="session-badge">{session.chapterCount}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="info">
|
|
||||||
<span class="manga-title">{session.mangaTitle}</span>
|
|
||||||
<span class="chapter-name">
|
|
||||||
{#if session.chapterCount > 1}
|
|
||||||
<span class="chapter-range">
|
|
||||||
{session.firstChapterName}
|
|
||||||
<span class="range-sep">→</span>
|
|
||||||
{session.latestChapterName}
|
|
||||||
</span>
|
|
||||||
{:else}
|
|
||||||
{session.latestChapterName}
|
|
||||||
{#if session.latestPageNumber > 1}
|
|
||||||
<span class="page-badge">p.{session.latestPageNumber}</span>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span class="time">{timeAgo(session.readAt)}</span>
|
|
||||||
<Play size={12} weight="fill" class="play-icon" />
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
|
||||||
|
|
||||||
.header {
|
|
||||||
display: flex; align-items: center; justify-content: space-between;
|
|
||||||
padding: var(--sp-5) var(--sp-6) var(--sp-3); flex-shrink: 0;
|
|
||||||
border-bottom: 1px solid var(--border-dim);
|
|
||||||
}
|
|
||||||
.heading {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal);
|
|
||||||
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
|
||||||
}
|
|
||||||
.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: 9px; color: var(--text-faint); pointer-events: none; }
|
|
||||||
.search {
|
|
||||||
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-md); padding: 5px 28px 5px 26px;
|
|
||||||
color: var(--text-primary); font-size: var(--text-sm); width: 180px; outline: none;
|
|
||||||
transition: border-color var(--t-base);
|
|
||||||
}
|
|
||||||
.search::placeholder { color: var(--text-faint); }
|
|
||||||
.search:focus { border-color: var(--border-strong); }
|
|
||||||
.search-clear {
|
|
||||||
position: absolute; right: 7px; color: var(--text-faint); font-size: 14px; 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; justify-content: center;
|
|
||||||
width: 28px; height: 28px; border-radius: var(--radius-md);
|
|
||||||
color: var(--text-faint); transition: color var(--t-base), background var(--t-base);
|
|
||||||
}
|
|
||||||
.clear-btn:hover { color: var(--color-error); background: var(--color-error-bg); }
|
|
||||||
|
|
||||||
.stats-bar {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-3);
|
|
||||||
padding: var(--sp-2) var(--sp-6); border-bottom: 1px solid var(--border-dim);
|
|
||||||
flex-shrink: 0; background: var(--bg-raised);
|
|
||||||
}
|
|
||||||
.stat-item { display: flex; align-items: baseline; gap: 5px; }
|
|
||||||
.stat-val { font-family: var(--font-ui); font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--accent-fg); }
|
|
||||||
.stat-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
|
||||||
.stat-div { width: 1px; height: 12px; background: var(--border-dim); flex-shrink: 0; }
|
|
||||||
|
|
||||||
.list { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-6); }
|
|
||||||
.group { margin-bottom: var(--sp-5); }
|
|
||||||
.group-label {
|
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint);
|
|
||||||
letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
|
||||||
margin-bottom: var(--sp-2); padding: 0 var(--sp-1);
|
|
||||||
}
|
|
||||||
.row {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-3);
|
|
||||||
width: 100%; padding: 8px var(--sp-2); border-radius: var(--radius-md);
|
|
||||||
border: 1px solid transparent; background: none; text-align: left; cursor: pointer;
|
|
||||||
transition: background var(--t-fast), border-color var(--t-fast);
|
|
||||||
}
|
|
||||||
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
|
|
||||||
.row:hover :global(.play-icon) { opacity: 1; }
|
|
||||||
|
|
||||||
.thumb-wrap { position: relative; flex-shrink: 0; }
|
|
||||||
.thumb {
|
|
||||||
width: 36px; height: 52px; border-radius: var(--radius-sm); object-fit: cover;
|
|
||||||
display: block; background: var(--bg-raised); border: 1px solid var(--border-dim);
|
|
||||||
}
|
|
||||||
.session-badge {
|
|
||||||
position: absolute; bottom: -4px; right: -6px;
|
|
||||||
background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg);
|
|
||||||
font-family: var(--font-ui); font-size: 9px; font-weight: 600;
|
|
||||||
padding: 1px 4px; border-radius: 6px; line-height: 1.4; pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; }
|
|
||||||
.manga-title {
|
|
||||||
font-size: var(--text-base); font-weight: var(--weight-medium);
|
|
||||||
color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.chapter-name {
|
|
||||||
font-size: var(--text-sm); color: var(--text-muted);
|
|
||||||
display: flex; align-items: center; gap: var(--sp-1); min-width: 0;
|
|
||||||
}
|
|
||||||
.chapter-range {
|
|
||||||
display: flex; align-items: center; gap: 5px;
|
|
||||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text-muted);
|
|
||||||
}
|
|
||||||
.range-sep { color: var(--text-faint); font-size: 10px; flex-shrink: 0; }
|
|
||||||
.page-badge { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
|
||||||
.time { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; white-space: nowrap; }
|
|
||||||
:global(.play-icon) { color: var(--text-faint); flex-shrink: 0; opacity: 0; transition: opacity var(--t-base); }
|
|
||||||
|
|
||||||
.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); }
|
|
||||||
:global(.empty-icon) { color: var(--text-faint); }
|
|
||||||
.empty-text { font-size: var(--text-base); color: var(--text-muted); }
|
|
||||||
.empty-hint { font-size: var(--text-sm); color: var(--text-faint); }
|
|
||||||
</style>
|
|
||||||
@@ -4,10 +4,10 @@
|
|||||||
import Home from "../pages/Home.svelte";
|
import Home from "../pages/Home.svelte";
|
||||||
import Library from "../pages/Library.svelte";
|
import Library from "../pages/Library.svelte";
|
||||||
import SeriesDetail from "../pages/SeriesDetail.svelte";
|
import SeriesDetail from "../pages/SeriesDetail.svelte";
|
||||||
import History from "../history/History.svelte";
|
import History from "../pages/History.svelte";
|
||||||
import Search from "../search/Search.svelte";
|
import Search from "../pages/Search.svelte";
|
||||||
import Discover from "../pages/Discover.svelte";
|
import Discover from "../pages/Discover.svelte";
|
||||||
import Downloads from "../downloads/Downloads.svelte";
|
import Downloads from "../pages/Downloads.svelte";
|
||||||
import Extensions from "../pages/Extensions.svelte";
|
import Extensions from "../pages/Extensions.svelte";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
import type { Manga, Source } from "../../lib/types";
|
import type { Manga, Source } from "../../lib/types";
|
||||||
import ContextMenu from "../shared/ContextMenu.svelte";
|
import ContextMenu from "../shared/ContextMenu.svelte";
|
||||||
import type { MenuEntry } from "../shared/ContextMenu.svelte";
|
import type { MenuEntry } from "../shared/ContextMenu.svelte";
|
||||||
import SourceBrowse from "../sources/SourceBrowse.svelte";
|
import SourceBrowse from "../shared/SourceBrowse.svelte";
|
||||||
|
|
||||||
// ── Config ────────────────────────────────────────────────────────────────────
|
// ── Config ────────────────────────────────────────────────────────────────────
|
||||||
const GENRE_TABS = ["All", "Action", "Romance", "Fantasy", "Comedy", "Drama", "Horror", "Sci-Fi", "Adventure", "Thriller"];
|
const GENRE_TABS = ["All", "Action", "Romance", "Fantasy", "Comedy", "Drama", "Horror", "Sci-Fi", "Adventure", "Thriller"];
|
||||||
|
|||||||
@@ -0,0 +1,403 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
ClockCounterClockwise, Trash, MagnifyingGlass, Play, Books,
|
||||||
|
X as XIcon,
|
||||||
|
} from "phosphor-svelte";
|
||||||
|
import { thumbUrl, gql } from "../../lib/client";
|
||||||
|
import { GET_CHAPTERS } from "../../lib/queries";
|
||||||
|
import {
|
||||||
|
history, readingStats, openReader,
|
||||||
|
} from "../../store";
|
||||||
|
import type { HistoryEntry } from "../../store";
|
||||||
|
|
||||||
|
let search = "";
|
||||||
|
let confirmClearAll = false;
|
||||||
|
|
||||||
|
function timeAgo(ts: number): string {
|
||||||
|
const diff = Date.now() - ts, m = Math.floor(diff / 60000);
|
||||||
|
if (m < 1) return "Just now";
|
||||||
|
if (m < 60) return `${m}m ago`;
|
||||||
|
const h = Math.floor(m / 60);
|
||||||
|
if (h < 24) return `${h}h ago`;
|
||||||
|
const d = Math.floor(h / 24);
|
||||||
|
if (d < 7) return `${d}d ago`;
|
||||||
|
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function dayLabel(ts: number): string {
|
||||||
|
const d = new Date(ts), now = new Date();
|
||||||
|
if (d.toDateString() === now.toDateString()) return "Today";
|
||||||
|
const yest = new Date(now); yest.setDate(now.getDate() - 1);
|
||||||
|
if (d.toDateString() === yest.toDateString()) return "Yesterday";
|
||||||
|
const weekAgo = new Date(now); weekAgo.setDate(now.getDate() - 7);
|
||||||
|
if (d > weekAgo) return d.toLocaleDateString("en-US", { weekday: "long" });
|
||||||
|
return d.toLocaleDateString("en-US", {
|
||||||
|
month: "long", day: "numeric",
|
||||||
|
year: d.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatReadTime(mins: number): string {
|
||||||
|
if (mins < 1) return `${Math.round(mins * 60)}s`;
|
||||||
|
if (mins < 60) return `${Math.round(mins)}m`;
|
||||||
|
const h = Math.floor(mins / 60), r = mins % 60;
|
||||||
|
if (h < 24) return r === 0 ? `${h}h` : `${h}h ${r}m`;
|
||||||
|
const d = Math.floor(h / 24), rh = h % 24;
|
||||||
|
return rh === 0 ? `${d}d` : `${d}d ${rh}h`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SESSION_GAP_MS = 30 * 60 * 1000;
|
||||||
|
|
||||||
|
interface Session {
|
||||||
|
mangaId: number;
|
||||||
|
mangaTitle: string;
|
||||||
|
thumbnailUrl: string;
|
||||||
|
latestChapterId: number;
|
||||||
|
latestChapterName: string;
|
||||||
|
latestPageNumber: number;
|
||||||
|
firstChapterName: string;
|
||||||
|
chapterCount: number;
|
||||||
|
readAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSessions(entries: HistoryEntry[]): Session[] {
|
||||||
|
if (!entries.length) return [];
|
||||||
|
const sessions: Session[] = [];
|
||||||
|
let i = 0;
|
||||||
|
while (i < entries.length) {
|
||||||
|
const anchor = entries[i];
|
||||||
|
const group: HistoryEntry[] = [anchor];
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < entries.length) {
|
||||||
|
const next = entries[j];
|
||||||
|
if (next.mangaId === anchor.mangaId && anchor.readAt - next.readAt <= SESSION_GAP_MS) {
|
||||||
|
group.push(next); j++;
|
||||||
|
} else break;
|
||||||
|
}
|
||||||
|
const latest = group[0], oldest = group[group.length - 1];
|
||||||
|
sessions.push({
|
||||||
|
mangaId: latest.mangaId,
|
||||||
|
mangaTitle: latest.mangaTitle,
|
||||||
|
thumbnailUrl: latest.thumbnailUrl,
|
||||||
|
latestChapterId: latest.chapterId,
|
||||||
|
latestChapterName: latest.chapterName,
|
||||||
|
latestPageNumber: latest.pageNumber,
|
||||||
|
firstChapterName: oldest.chapterName,
|
||||||
|
chapterCount: group.length,
|
||||||
|
readAt: latest.readAt,
|
||||||
|
});
|
||||||
|
i = j;
|
||||||
|
}
|
||||||
|
return sessions;
|
||||||
|
}
|
||||||
|
|
||||||
|
$: filtered = search.trim()
|
||||||
|
? $history.filter((e) =>
|
||||||
|
e.mangaTitle.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
e.chapterName.toLowerCase().includes(search.toLowerCase())
|
||||||
|
)
|
||||||
|
: $history;
|
||||||
|
|
||||||
|
$: sessions = buildSessions(filtered);
|
||||||
|
|
||||||
|
$: groups = (() => {
|
||||||
|
const map = new Map<string, Session[]>();
|
||||||
|
for (const s of sessions) {
|
||||||
|
const l = dayLabel(s.readAt);
|
||||||
|
if (!map.has(l)) map.set(l, []);
|
||||||
|
map.get(l)!.push(s);
|
||||||
|
}
|
||||||
|
return Array.from(map.entries()).map(([label, items]) => ({ label, items }));
|
||||||
|
})();
|
||||||
|
|
||||||
|
$: stats = {
|
||||||
|
uniqueChapters: new Set($history.map((e) => e.chapterId)).size,
|
||||||
|
uniqueManga: new Set($history.map((e) => e.mangaId)).size,
|
||||||
|
estimatedMinutes: Math.round(new Set($history.map((e) => e.chapterId)).size * 4.5),
|
||||||
|
};
|
||||||
|
|
||||||
|
function clearAll() {
|
||||||
|
history.set([]);
|
||||||
|
confirmClearAll = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearManga(mangaId: number, e: MouseEvent) {
|
||||||
|
e.stopPropagation();
|
||||||
|
history.update((h) => h.filter((x) => x.mangaId !== mangaId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resume(session: Session) {
|
||||||
|
try {
|
||||||
|
const d = await gql<{ chapters: { nodes: any[] } }>(GET_CHAPTERS, { mangaId: session.mangaId });
|
||||||
|
const chapters = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
|
||||||
|
const ch = chapters.find((c) => c.id === session.latestChapterId) ?? chapters[0];
|
||||||
|
if (ch) openReader(ch, chapters);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="root">
|
||||||
|
<div class="header">
|
||||||
|
<h1 class="heading">History</h1>
|
||||||
|
<div class="header-right">
|
||||||
|
<div class="search-wrap">
|
||||||
|
<MagnifyingGlass size={12} class="search-icon" weight="light" />
|
||||||
|
<input class="search" placeholder="Search history…" bind:value={search} />
|
||||||
|
{#if search}
|
||||||
|
<button class="search-clear" on:click={() => (search = "")}>
|
||||||
|
<XIcon size={10} weight="bold" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if $history.length > 0}
|
||||||
|
{#if confirmClearAll}
|
||||||
|
<div class="confirm-row">
|
||||||
|
<span class="confirm-label">Clear all activity?</span>
|
||||||
|
<button class="confirm-yes" on:click={clearAll}>Clear</button>
|
||||||
|
<button class="confirm-no" on:click={() => (confirmClearAll = false)}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<button class="clear-btn" on:click={() => (confirmClearAll = true)} title="Clear all activity">
|
||||||
|
<Trash size={13} weight="light" />
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-bar">
|
||||||
|
<span class="stat-item">
|
||||||
|
<span class="stat-val">{stats.uniqueChapters}</span>
|
||||||
|
<span class="stat-label">chapters</span>
|
||||||
|
</span>
|
||||||
|
<span class="stat-sep"></span>
|
||||||
|
<span class="stat-item">
|
||||||
|
<span class="stat-val">{stats.uniqueManga}</span>
|
||||||
|
<span class="stat-label">series</span>
|
||||||
|
</span>
|
||||||
|
<span class="stat-sep"></span>
|
||||||
|
<span class="stat-item">
|
||||||
|
<span class="stat-val">{formatReadTime(stats.estimatedMinutes)}</span>
|
||||||
|
<span class="stat-label">est. time</span>
|
||||||
|
</span>
|
||||||
|
{#if $readingStats.currentStreakDays > 0}
|
||||||
|
<span class="stat-sep"></span>
|
||||||
|
<span class="stat-item">
|
||||||
|
<span class="stat-val">{$readingStats.currentStreakDays}d</span>
|
||||||
|
<span class="stat-label">streak</span>
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if $history.length === 0}
|
||||||
|
<div class="empty">
|
||||||
|
<ClockCounterClockwise size={32} weight="light" class="empty-icon" />
|
||||||
|
<p class="empty-text">No reading history yet</p>
|
||||||
|
<p class="empty-hint">Chapters you read will appear here</p>
|
||||||
|
</div>
|
||||||
|
{:else if sessions.length === 0}
|
||||||
|
<div class="empty">
|
||||||
|
<Books size={28} weight="light" class="empty-icon" />
|
||||||
|
<p class="empty-text">No results for "{search}"</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="list">
|
||||||
|
{#each groups as { label, items } (label)}
|
||||||
|
<div class="group">
|
||||||
|
<p class="group-label">
|
||||||
|
<span>{label}</span>
|
||||||
|
<span class="group-count">{items.length}</span>
|
||||||
|
</p>
|
||||||
|
{#each items as session (session.latestChapterId + ":" + session.readAt)}
|
||||||
|
<div class="row-wrap">
|
||||||
|
<button class="row" on:click={() => resume(session)}>
|
||||||
|
<div class="thumb-wrap">
|
||||||
|
<img
|
||||||
|
src={thumbUrl(session.thumbnailUrl)}
|
||||||
|
alt={session.mangaTitle}
|
||||||
|
class="thumb"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
{#if session.chapterCount > 1}
|
||||||
|
<span class="session-badge">{session.chapterCount}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="info">
|
||||||
|
<span class="manga-title">{session.mangaTitle}</span>
|
||||||
|
<span class="chapter-name">
|
||||||
|
{#if session.chapterCount > 1}
|
||||||
|
<span class="chapter-range">
|
||||||
|
{session.firstChapterName}
|
||||||
|
<span class="range-sep">→</span>
|
||||||
|
{session.latestChapterName}
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
{session.latestChapterName}
|
||||||
|
{#if session.latestPageNumber > 1}
|
||||||
|
<span class="page-badge">p.{session.latestPageNumber}</span>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="time">{timeAgo(session.readAt)}</span>
|
||||||
|
<Play size={11} weight="fill" class="play-icon" />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="row-delete"
|
||||||
|
on:click={(e) => clearManga(session.mangaId, e)}
|
||||||
|
title="Remove {session.mangaTitle} from history"
|
||||||
|
aria-label="Remove from history"
|
||||||
|
>
|
||||||
|
<XIcon size={9} weight="bold" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: var(--sp-5) var(--sp-6) var(--sp-3); flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.heading {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal);
|
||||||
|
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.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: 9px; color: var(--text-faint); pointer-events: none; }
|
||||||
|
.search {
|
||||||
|
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-md); padding: 5px 28px 5px 26px;
|
||||||
|
color: var(--text-primary); font-size: var(--text-sm); width: 180px; outline: none;
|
||||||
|
transition: border-color var(--t-base);
|
||||||
|
}
|
||||||
|
.search::placeholder { color: var(--text-faint); }
|
||||||
|
.search:focus { border-color: var(--border-strong); }
|
||||||
|
.search-clear {
|
||||||
|
position: absolute; right: 7px; display: flex; align-items: center; justify-content: center;
|
||||||
|
color: var(--text-faint); background: none; border: none; cursor: pointer;
|
||||||
|
padding: 2px; transition: color var(--t-base);
|
||||||
|
}
|
||||||
|
.search-clear:hover { color: var(--text-muted); }
|
||||||
|
|
||||||
|
.confirm-row { display: flex; align-items: center; gap: var(--sp-2); }
|
||||||
|
.confirm-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
.confirm-yes {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs); padding: 4px 10px;
|
||||||
|
border-radius: var(--radius-sm); border: 1px solid var(--color-error);
|
||||||
|
background: var(--color-error-bg); color: var(--color-error); cursor: pointer;
|
||||||
|
transition: filter var(--t-base);
|
||||||
|
}
|
||||||
|
.confirm-yes:hover { filter: brightness(1.15); }
|
||||||
|
.confirm-no {
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs); padding: 4px 10px;
|
||||||
|
border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
|
||||||
|
background: none; color: var(--text-faint); cursor: pointer;
|
||||||
|
transition: background var(--t-base);
|
||||||
|
}
|
||||||
|
.confirm-no:hover { background: var(--bg-raised); color: var(--text-muted); }
|
||||||
|
|
||||||
|
.clear-btn {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
width: 28px; height: 28px; border-radius: var(--radius-md);
|
||||||
|
color: var(--text-faint); background: none; border: none; cursor: pointer;
|
||||||
|
transition: color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
|
.clear-btn:hover { color: var(--color-error); background: var(--color-error-bg); }
|
||||||
|
|
||||||
|
.stats-bar {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-3);
|
||||||
|
padding: 0 var(--sp-6) var(--sp-3); flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.stat-item { display: flex; align-items: baseline; gap: 4px; }
|
||||||
|
.stat-val { font-family: var(--font-ui); font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); }
|
||||||
|
.stat-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
|
.stat-sep { width: 1px; height: 10px; background: var(--border-dim); flex-shrink: 0; }
|
||||||
|
|
||||||
|
.list { flex: 1; overflow-y: auto; padding: 0 var(--sp-4) var(--sp-6); scrollbar-width: none; }
|
||||||
|
.list::-webkit-scrollbar { display: none; }
|
||||||
|
|
||||||
|
.group { margin-bottom: var(--sp-4); }
|
||||||
|
.group-label {
|
||||||
|
display: 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;
|
||||||
|
padding: var(--sp-1) var(--sp-2) var(--sp-2);
|
||||||
|
}
|
||||||
|
.group-count {
|
||||||
|
font-family: var(--font-ui); font-size: 9px; color: var(--text-faint);
|
||||||
|
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
||||||
|
padding: 1px 5px; border-radius: var(--radius-full); letter-spacing: 0;
|
||||||
|
text-transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-wrap {
|
||||||
|
display: flex; align-items: center;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
transition: background var(--t-fast);
|
||||||
|
}
|
||||||
|
.row-wrap:hover { background: var(--bg-raised); }
|
||||||
|
.row-wrap:hover .row-delete { opacity: 1; }
|
||||||
|
|
||||||
|
.row {
|
||||||
|
flex: 1; display: flex; align-items: center; gap: var(--sp-3);
|
||||||
|
padding: 8px var(--sp-2); border-radius: var(--radius-md);
|
||||||
|
border: none; background: none; text-align: left; cursor: pointer; min-width: 0;
|
||||||
|
}
|
||||||
|
.row:hover :global(.play-icon) { opacity: 1; }
|
||||||
|
|
||||||
|
.row-delete {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
flex-shrink: 0; width: 22px; height: 22px; border-radius: var(--radius-sm);
|
||||||
|
border: none; background: none; color: var(--text-faint); cursor: pointer;
|
||||||
|
opacity: 0; transition: opacity var(--t-base), color var(--t-base), background var(--t-base);
|
||||||
|
margin-right: var(--sp-1);
|
||||||
|
}
|
||||||
|
.row-delete:hover { color: var(--color-error); background: var(--color-error-bg); }
|
||||||
|
|
||||||
|
.thumb-wrap { position: relative; flex-shrink: 0; }
|
||||||
|
.thumb {
|
||||||
|
width: 36px; height: 52px; border-radius: var(--radius-sm); object-fit: cover;
|
||||||
|
display: block; background: var(--bg-raised); border: 1px solid var(--border-dim);
|
||||||
|
}
|
||||||
|
.session-badge {
|
||||||
|
position: absolute; bottom: -4px; right: -6px;
|
||||||
|
background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg);
|
||||||
|
font-family: var(--font-ui); font-size: 9px; font-weight: 600;
|
||||||
|
padding: 1px 4px; border-radius: 6px; line-height: 1.4; pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; }
|
||||||
|
.manga-title {
|
||||||
|
font-size: var(--text-base); font-weight: var(--weight-medium);
|
||||||
|
color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.chapter-name {
|
||||||
|
font-size: var(--text-sm); color: var(--text-muted);
|
||||||
|
display: flex; align-items: center; gap: var(--sp-1); min-width: 0;
|
||||||
|
}
|
||||||
|
.chapter-range {
|
||||||
|
display: flex; align-items: center; gap: 5px;
|
||||||
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.range-sep { color: var(--text-faint); font-size: 10px; flex-shrink: 0; }
|
||||||
|
.page-badge { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
|
||||||
|
.time { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; white-space: nowrap; }
|
||||||
|
:global(.play-icon) { color: var(--text-faint); flex-shrink: 0; opacity: 0; transition: opacity var(--t-base); }
|
||||||
|
|
||||||
|
.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); }
|
||||||
|
:global(.empty-icon) { color: var(--text-faint); }
|
||||||
|
.empty-text { font-size: var(--text-base); color: var(--text-muted); }
|
||||||
|
.empty-hint { font-size: var(--text-sm); color: var(--text-faint); }
|
||||||
|
|
||||||
|
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||||
|
</style>
|
||||||
@@ -28,9 +28,12 @@
|
|||||||
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
||||||
}
|
}
|
||||||
function formatReadTime(mins: number): string {
|
function formatReadTime(mins: number): string {
|
||||||
if (mins < 60) return `${mins}m`;
|
if (mins < 1) return `${Math.round(mins * 60)}s`;
|
||||||
|
if (mins < 60) return `${Math.round(mins)}m`;
|
||||||
const h = Math.floor(mins / 60), r = mins % 60;
|
const h = Math.floor(mins / 60), r = mins % 60;
|
||||||
return r === 0 ? `${h}h` : `${h}h ${r}m`;
|
if (h < 24) return r === 0 ? `${h}h` : `${h}h ${r}m`;
|
||||||
|
const d = Math.floor(h / 24), rh = h % 24;
|
||||||
|
return rh === 0 ? `${d}d` : `${d}d ${rh}h`;
|
||||||
}
|
}
|
||||||
function focusEl(node: HTMLElement) { node.focus(); }
|
function focusEl(node: HTMLElement) { node.focus(); }
|
||||||
|
|
||||||
@@ -209,7 +212,7 @@
|
|||||||
: [];
|
: [];
|
||||||
$: recentHistory = $history.slice(0, 8);
|
$: recentHistory = $history.slice(0, 8);
|
||||||
$: stats = $readingStats;
|
$: stats = $readingStats;
|
||||||
$: hasStats = stats.totalChaptersRead > 0 || stats.totalMangaRead > 0;
|
$: hasStats = true;
|
||||||
|
|
||||||
function handleRowWheel(e: WheelEvent) {
|
function handleRowWheel(e: WheelEvent) {
|
||||||
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
|
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
|
||||||
@@ -437,90 +440,88 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- ══ BOTTOM ROW: Completed (left) + Stats (right) ═══════════════════════ -->
|
<!-- ══ BOTTOM ROW ══════════════════════════════════════════════════════════ -->
|
||||||
<div class="bottom-row">
|
<div class="bottom-row">
|
||||||
|
|
||||||
<!-- Left: Recently Completed -->
|
<!-- Left: Completed -->
|
||||||
<div class="bottom-col">
|
<div class="bottom-col">
|
||||||
{#if completedManga.length > 0}
|
<div class="bottom-section-hd">
|
||||||
<div class="section-header">
|
|
||||||
<span class="section-title"><CheckCircle size={10} weight="bold" /> Completed</span>
|
<span class="section-title"><CheckCircle size={10} weight="bold" /> Completed</span>
|
||||||
<button class="see-all" on:click={() => navPage.set("library")}>
|
{#if completedManga.length > 0}
|
||||||
View all <ArrowRight size={9} weight="bold" />
|
<button class="see-all" on:click={() => navPage.set("library")}>View all <ArrowRight size={9} weight="bold" /></button>
|
||||||
</button>
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{#if completedManga.length > 0}
|
||||||
<div class="mini-row" on:wheel|preventDefault={handleRowWheel}>
|
<div class="mini-row" on:wheel|preventDefault={handleRowWheel}>
|
||||||
{#each completedManga as m (m.id)}
|
{#each completedManga as m (m.id)}
|
||||||
<button class="mini-card" on:click={() => previewManga.set(m)}>
|
<button class="mini-card" on:click={() => previewManga.set(m)}>
|
||||||
<div class="mini-cover-wrap">
|
<div class="mini-cover-wrap">
|
||||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="mini-cover" loading="lazy" decoding="async" />
|
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="mini-cover" loading="lazy" decoding="async" />
|
||||||
<span class="mini-check"><CheckCircle size={12} weight="fill" /></span>
|
<div class="mini-gradient"></div>
|
||||||
|
<div class="mini-footer">
|
||||||
|
<p class="mini-card-title">{m.title}</p>
|
||||||
|
{#if m.source?.displayName}<p class="mini-card-source">{m.source.displayName}</p>{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="mini-title">{m.title}</p>
|
|
||||||
</button>
|
</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="section-header">
|
|
||||||
<span class="section-title"><CheckCircle size={10} weight="bold" /> Completed</span>
|
|
||||||
</div>
|
|
||||||
<p class="bottom-empty">Finish a manga to see it here</p>
|
<p class="bottom-empty">Finish a manga to see it here</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="bottom-divider"></div>
|
||||||
|
|
||||||
<!-- Right: Stats -->
|
<!-- Right: Stats -->
|
||||||
<div class="bottom-col stats-col">
|
<div class="bottom-col">
|
||||||
<div class="section-header">
|
<div class="bottom-section-hd">
|
||||||
<span class="section-title"><TrendUp size={10} weight="bold" /> Your Stats</span>
|
<span class="section-title"><TrendUp size={10} weight="bold" /> Your Stats</span>
|
||||||
</div>
|
</div>
|
||||||
{#if hasStats}
|
|
||||||
<div class="stats-grid">
|
<div class="stats-grid">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-icon-wrap stat-fire"><Fire size={18} weight="fill" /></div>
|
<div class="stat-icon-wrap stat-fire"><Fire size={16} weight="fill" /></div>
|
||||||
<div class="stat-body">
|
<div class="stat-body">
|
||||||
<span class="stat-val">{stats.currentStreakDays}</span>
|
<span class="stat-val">{stats.currentStreakDays}</span>
|
||||||
<span class="stat-label">Day streak</span>
|
<span class="stat-label">Day streak</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-icon-wrap stat-accent"><BookOpen size={18} weight="light" /></div>
|
<div class="stat-icon-wrap stat-accent"><BookOpen size={16} weight="light" /></div>
|
||||||
<div class="stat-body">
|
<div class="stat-body">
|
||||||
<span class="stat-val">{stats.totalChaptersRead}</span>
|
<span class="stat-val">{stats.totalChaptersRead}</span>
|
||||||
<span class="stat-label">Chapters read</span>
|
<span class="stat-label">Chapters read</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-icon-wrap stat-neutral"><Clock size={18} weight="light" /></div>
|
<div class="stat-icon-wrap stat-neutral"><Clock size={16} weight="light" /></div>
|
||||||
<div class="stat-body">
|
<div class="stat-body">
|
||||||
<span class="stat-val">{formatReadTime(stats.totalMinutesRead)}</span>
|
<span class="stat-val">{formatReadTime(stats.totalMinutesRead)}</span>
|
||||||
<span class="stat-label">Total read time</span>
|
<span class="stat-label">Read time</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-icon-wrap stat-neutral"><TrendUp size={18} weight="light" /></div>
|
<div class="stat-icon-wrap stat-neutral"><TrendUp size={16} weight="light" /></div>
|
||||||
<div class="stat-body">
|
<div class="stat-body">
|
||||||
<span class="stat-val">{stats.totalMangaRead}</span>
|
<span class="stat-val">{stats.totalMangaRead}</span>
|
||||||
<span class="stat-label">Series started</span>
|
<span class="stat-label">Series started</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-icon-wrap stat-green"><CheckCircle size={18} weight="light" /></div>
|
<div class="stat-icon-wrap stat-green"><CheckCircle size={16} weight="light" /></div>
|
||||||
<div class="stat-body">
|
<div class="stat-body">
|
||||||
<span class="stat-val">{completedIds.length}</span>
|
<span class="stat-val">{completedIds.length}</span>
|
||||||
<span class="stat-label">Completed</span>
|
<span class="stat-label">Completed</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-icon-wrap stat-neutral"><CalendarBlank size={18} weight="light" /></div>
|
<div class="stat-icon-wrap stat-neutral"><CalendarBlank size={16} weight="light" /></div>
|
||||||
<div class="stat-body">
|
<div class="stat-body">
|
||||||
<span class="stat-val">{stats.longestStreakDays}d</span>
|
<span class="stat-val">{stats.longestStreakDays}d</span>
|
||||||
<span class="stat-label">Best streak</span>
|
<span class="stat-label">Best streak</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
|
||||||
<p class="bottom-empty">Start reading to see your stats</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -781,8 +782,8 @@
|
|||||||
.ch-view-all:hover { color: var(--accent-fg); }
|
.ch-view-all:hover { color: var(--accent-fg); }
|
||||||
|
|
||||||
/* ══ SECTIONS ════════════════════════════════════════════════════════════════ */
|
/* ══ SECTIONS ════════════════════════════════════════════════════════════════ */
|
||||||
.section { padding: var(--sp-5) 0 0; }
|
.section { border-top: 1px solid var(--border-dim); margin-top: var(--sp-4); }
|
||||||
.section-header { display: flex; align-items: center; justify-content: space-between; padding: 0 var(--sp-5) var(--sp-3); }
|
.section-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-5) var(--sp-2); }
|
||||||
.section-title { display: inline-flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
.section-title { display: inline-flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
|
||||||
.see-all { display: flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base); }
|
.see-all { display: flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base); }
|
||||||
.see-all:hover { color: var(--accent-fg); }
|
.see-all:hover { color: var(--accent-fg); }
|
||||||
@@ -801,38 +802,52 @@
|
|||||||
|
|
||||||
/* ── Bottom row ───────────────────────────────────────────────────────────── */
|
/* ── Bottom row ───────────────────────────────────────────────────────────── */
|
||||||
.bottom-row {
|
.bottom-row {
|
||||||
display: grid; grid-template-columns: 1fr 1fr;
|
display: grid; grid-template-columns: 1fr 1px 1fr;
|
||||||
gap: var(--sp-5); padding: var(--sp-5) var(--sp-5) 0;
|
padding: 0 var(--sp-5) 0; margin-top: var(--sp-4);
|
||||||
align-items: start;
|
border-top: 1px solid var(--border-dim); align-items: start;
|
||||||
}
|
}
|
||||||
.bottom-col { display: flex; flex-direction: column; min-width: 0; }
|
.bottom-divider { background: var(--border-dim); align-self: stretch; min-height: 100%; }
|
||||||
.bottom-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-2) var(--sp-1); }
|
.bottom-col { display: flex; flex-direction: column; min-width: 0; padding-top: var(--sp-3); }
|
||||||
|
.bottom-col:first-child { padding-right: var(--sp-5); }
|
||||||
|
.bottom-col:last-child { padding-left: var(--sp-5); }
|
||||||
|
.bottom-section-hd { display: flex; align-items: center; justify-content: space-between; padding-bottom: var(--sp-2); }
|
||||||
|
.bottom-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-2) 0; }
|
||||||
|
|
||||||
/* Mini row (completed) */
|
/* Completed cards — Discover format */
|
||||||
.mini-row { display: flex; gap: var(--sp-3); overflow-x: auto; scrollbar-width: none; padding-bottom: var(--sp-1); }
|
.mini-row { display: flex; gap: var(--sp-3); overflow-x: auto; scrollbar-width: none; padding-bottom: var(--sp-2); }
|
||||||
.mini-row::-webkit-scrollbar { display: none; }
|
.mini-row::-webkit-scrollbar { display: none; }
|
||||||
.mini-card { flex-shrink: 0; width: 90px; background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
.mini-card { flex-shrink: 0; width: 120px; background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
|
||||||
.mini-card:hover .mini-cover { filter: brightness(1.08); }
|
.mini-card:hover .mini-cover { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); }
|
||||||
.mini-card:hover .mini-title { color: var(--text-primary); }
|
.mini-card:hover { will-change: transform; }
|
||||||
.mini-cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); }
|
.mini-cover-wrap {
|
||||||
.mini-cover { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); }
|
position: relative; aspect-ratio: 2/3; overflow: hidden;
|
||||||
.mini-check { position: absolute; top: var(--sp-1); right: var(--sp-1); color: #22c55e; filter: drop-shadow(0 1px 3px rgba(0,0,0,0.5)); }
|
border-radius: var(--radius-md); background: var(--bg-raised);
|
||||||
.mini-title { margin-top: var(--sp-1); font-size: var(--text-2xs); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); }
|
border: 1px solid var(--border-dim); box-shadow: 0 2px 12px rgba(0,0,0,0.35);
|
||||||
|
|
||||||
/* Stats grid — 2×3 cards */
|
|
||||||
.stats-col { }
|
|
||||||
.stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-3); }
|
|
||||||
.stat-card {
|
|
||||||
display: flex; align-items: center; gap: var(--sp-3);
|
|
||||||
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
|
||||||
border-radius: var(--radius-lg); padding: var(--sp-3) var(--sp-4);
|
|
||||||
}
|
}
|
||||||
.stat-icon-wrap { display: flex; align-items: center; justify-content: center; width: 32px; height: 32px; border-radius: var(--radius-md); flex-shrink: 0; }
|
.mini-cover { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.15s ease, transform 0.15s ease; }
|
||||||
|
.mini-gradient { position: absolute; inset: 0; background: linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.1) 55%, transparent 75%); pointer-events: none; }
|
||||||
|
.mini-footer { position: absolute; bottom: 0; left: 0; right: 0; padding: var(--sp-2); pointer-events: none; }
|
||||||
|
.mini-card-title {
|
||||||
|
font-size: var(--text-xs); font-weight: var(--weight-medium);
|
||||||
|
color: rgba(255,255,255,0.92); line-height: var(--leading-snug);
|
||||||
|
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
|
||||||
|
text-shadow: 0 1px 4px rgba(0,0,0,0.7);
|
||||||
|
}
|
||||||
|
.mini-card-source { font-family: var(--font-ui); font-size: 9px; color: rgba(255,255,255,0.45); letter-spacing: var(--tracking-wide); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
|
||||||
|
/* Stats grid */
|
||||||
|
.stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-2); }
|
||||||
|
.stat-card {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-2);
|
||||||
|
background: var(--bg-raised); border: 1px solid var(--border-dim);
|
||||||
|
border-radius: var(--radius-md); padding: var(--sp-2) var(--sp-3);
|
||||||
|
}
|
||||||
|
.stat-icon-wrap { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); flex-shrink: 0; }
|
||||||
.stat-fire { background: rgba(251,146,60,0.15); color: #fb923c; }
|
.stat-fire { background: rgba(251,146,60,0.15); color: #fb923c; }
|
||||||
.stat-accent { background: var(--accent-muted); color: var(--accent-fg); }
|
.stat-accent { background: var(--accent-muted); color: var(--accent-fg); }
|
||||||
.stat-neutral { background: var(--bg-overlay); color: var(--text-faint); }
|
.stat-neutral { background: var(--bg-overlay); color: var(--text-faint); }
|
||||||
.stat-green { background: rgba(34,197,94,0.12); color: #22c55e; }
|
.stat-green { background: rgba(34,197,94,0.12); color: #22c55e; }
|
||||||
.stat-body { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
.stat-body { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
|
||||||
.stat-val { font-family: var(--font-ui); font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); line-height: 1; }
|
.stat-val { font-family: var(--font-ui); font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); line-height: 1; }
|
||||||
.stat-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; }
|
.stat-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; }
|
||||||
|
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
DEFAULT_TTL_MS,
|
DEFAULT_TTL_MS,
|
||||||
CACHE_GROUPS.LIBRARY,
|
CACHE_GROUPS.LIBRARY,
|
||||||
).then((nodes) => {
|
).then((nodes) => {
|
||||||
allMangaUnfiltered = dedupeMangaByTitle(dedupeMangaById(nodes), $settings.mangaLinks);
|
allMangaUnfiltered = dedupeMangaById(nodes);
|
||||||
}).catch(console.error);
|
}).catch(console.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { gql } from "../../lib/client";
|
import { gql } from "../../lib/client";
|
||||||
import { GET_DOWNLOADS_PATH } from "../../lib/queries";
|
import { GET_DOWNLOADS_PATH } from "../../lib/queries";
|
||||||
import { settings, settingsOpen, history, updateSettings, resetKeybinds, addFolder, removeFolder, renameFolder, toggleFolderTab } from "../../store";
|
import { settings, settingsOpen, history, updateSettings, resetKeybinds, addFolder, removeFolder, renameFolder, toggleFolderTab, clearHistory, wipeAllData } from "../../store";
|
||||||
import { cache } from "../../lib/cache";
|
import { cache } from "../../lib/cache";
|
||||||
import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind } from "../../lib/keybinds";
|
import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind } from "../../lib/keybinds";
|
||||||
import type { Settings, FitMode, Theme } from "../../store";
|
import type { Settings, FitMode, Theme } from "../../store";
|
||||||
@@ -442,7 +442,14 @@
|
|||||||
<p class="section-title">History</p>
|
<p class="section-title">History</p>
|
||||||
<div class="step-row">
|
<div class="step-row">
|
||||||
<div class="toggle-info"><span class="toggle-label">Reading history</span><span class="toggle-desc">{$history.length} entries stored</span></div>
|
<div class="toggle-info"><span class="toggle-label">Reading history</span><span class="toggle-desc">{$history.length} entries stored</span></div>
|
||||||
<button class="danger-btn" on:click={() => history.set([])} disabled={$history.length === 0}>Clear history</button>
|
<button class="danger-btn" on:click={clearHistory} disabled={$history.length === 0}>Clear activity</button>
|
||||||
|
</div>
|
||||||
|
<div class="step-row">
|
||||||
|
<div class="toggle-info">
|
||||||
|
<span class="toggle-label">Full data cleanse</span>
|
||||||
|
<span class="toggle-desc">Removes history, stats, completed list, hero pins, and manga links</span>
|
||||||
|
</div>
|
||||||
|
<button class="danger-btn" on:click={wipeAllData}>Wipe all data</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user