Chore: Completed Splash-Screen & Iniital Tauri Wire-Up

This commit is contained in:
Youwes09
2026-06-02 08:27:37 -05:00
parent c5243ba30c
commit 18027baee1
45 changed files with 2981 additions and 2013 deletions
+12 -2
View File
@@ -1,5 +1,15 @@
export { loadAllStores, persistSettings, persistLibrary, persistUpdates } from "./persist";
export type { PersistedData } from "./persist";
export {
loadSettings, saveSettings,
loadLibrary, saveLibrary,
loadUpdates, saveUpdates,
loadBackups, saveBackups,
} from "./persist";
export type {
PersistedSettings,
PersistedLibrary,
PersistedUpdates,
PersistedBackups,
} from "./persist";
export { vaultExists, lockVault, unlockVault, clearVault, rekeyVault } from "./credentialVault";
export type { VaultPayload } from "./credentialVault";
@@ -1,9 +1,10 @@
<script lang="ts">
import { Play, ArrowRight, BookOpen, Clock } from "phosphor-svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import type { HistoryEntry } from "@store/state.svelte";
import type { Manga } from "@types";
import { timeAgo } from "../lib/homeHelpers";
import { Play, ArrowRight, BookOpen, Clock } from 'phosphor-svelte'
import { timeAgo } from '$lib/components/home/homeHelpers'
import Thumbnail from '$lib/components/shared/manga/Thumbnail.svelte'
import { resolvedCover } from '$lib/core/cover/coverResolver'
import type { Manga } from '$lib/types'
import type { ReadSession } from '$lib/types/history'
let {
entries,
@@ -12,15 +13,15 @@
onviewhistory,
onopenlibrary,
}: {
entries: HistoryEntry[];
libraryManga: Manga[];
onresume: (entry: HistoryEntry) => void;
onviewhistory: () => void;
onopenlibrary: () => void;
} = $props();
entries: ReadSession[]
libraryManga: Manga[]
onresume: (entry: ReadSession) => void
onviewhistory: () => void
onopenlibrary: () => void
} = $props()
function thumbFor(entry: HistoryEntry): string {
return libraryManga.find(m => m.id === entry.mangaId)?.thumbnailUrl ?? entry.thumbnailUrl ?? "";
function coverFor(entry: ReadSession): string {
return libraryManga.find(m => m.id === entry.mangaId)?.thumbnailUrl ?? entry.thumbnailUrl ?? ''
}
</script>
@@ -36,16 +37,16 @@
<div class="list">
{#if entries.length > 0}
{#each entries as entry (entry.chapterId)}
{#each entries as entry (entry.id)}
<button class="row" onclick={() => onresume(entry)}>
<Thumbnail src={thumbFor(entry)} alt={entry.mangaTitle} class="row-thumb" />
<Thumbnail src={resolvedCover(entry.mangaId, coverFor(entry))} alt={entry.mangaTitle} class="row-thumb" />
<div class="row-info">
<span class="row-title">{entry.mangaTitle}</span>
<span class="row-sub">
{entry.chapterName}{entry.pageNumber > 1 ? ` · p.${entry.pageNumber}` : ""}
{entry.endChapterName}{entry.endPage > 1 ? ` · p.${entry.endPage}` : ''}
</span>
</div>
<span class="row-time">{timeAgo(entry.readAt)}</span>
<span class="row-time">{timeAgo(entry.endedAt)}</span>
<span class="row-play"><Play size={10} weight="fill" /></span>
</button>
{/each}
@@ -75,34 +76,19 @@
.section { border-top: 1px solid var(--border-dim); flex-shrink: 0; }
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
display: flex; align-items: center; justify-content: space-between;
padding: var(--sp-3) var(--sp-4) var(--sp-2);
}
.section-title {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
display: inline-flex; align-items: center; gap: var(--sp-2);
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
}
.see-all {
display: flex;
align-items: center;
gap: 4px;
font-family: var(--font-ui);
font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--text-faint);
background: none;
border: none;
cursor: pointer;
padding: 0;
display: flex; align-items: center; gap: 4px;
font-family: var(--font-ui); font-size: var(--text-xs);
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); }
@@ -110,54 +96,31 @@
.list { display: flex; flex-direction: column; padding: 0 var(--sp-3); overflow: hidden; }
.row {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: 7px var(--sp-2);
border-radius: var(--radius-md);
border: 1px solid transparent;
background: none;
text-align: left;
cursor: pointer;
width: 100%;
display: flex; align-items: center; gap: var(--sp-3);
padding: 7px var(--sp-2); border-radius: var(--radius-md);
border: 1px solid transparent; background: none;
text-align: left; cursor: pointer; width: 100%;
transition: background var(--t-fast), border-color var(--t-fast);
}
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.row:hover .row-play { opacity: 1; }
:global(.row-thumb) {
width: 33px;
height: 48px;
border-radius: var(--radius-sm);
object-fit: cover;
flex-shrink: 0;
border: 1px solid var(--border-dim);
width: 33px; height: 48px; border-radius: var(--radius-sm);
object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim);
}
.row-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.row-title {
font-size: var(--text-base);
font-weight: var(--weight-medium);
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.row-sub {
font-family: var(--font-ui);
font-size: var(--text-sm);
color: var(--text-muted);
letter-spacing: var(--tracking-wide);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-muted);
letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.row-time {
font-family: var(--font-ui);
font-size: var(--text-sm);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
flex-shrink: 0;
font-family: var(--font-ui); font-size: var(--text-sm);
color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0;
}
.row-play { color: var(--accent-fg); flex-shrink: 0; opacity: 0; transition: opacity var(--t-base); }
@@ -170,29 +133,18 @@
.placeholder { position: relative; }
.placeholder-overlay {
position: absolute;
left: 0; right: 0; top: 0; bottom: -1px;
display: flex;
align-items: flex-end;
justify-content: center;
padding-bottom: var(--sp-4);
position: absolute; left: 0; right: 0; top: 0; bottom: -1px;
display: flex; align-items: flex-end; justify-content: center; padding-bottom: var(--sp-4);
pointer-events: none;
background: linear-gradient(to bottom, transparent 0%, rgba(0,0,0,0.6) 100%);
}
.placeholder-cta {
pointer-events: all;
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-ui);
font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
padding: 7px 16px;
border-radius: var(--radius-full);
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.13);
color: rgba(255,255,255,0.62);
cursor: pointer;
display: inline-flex; align-items: center; gap: 6px;
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 7px 16px; border-radius: var(--radius-full);
background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.13);
color: rgba(255,255,255,0.62); cursor: pointer;
transition: background var(--t-base), color var(--t-base);
}
.placeholder-cta:hover { background: rgba(255,255,255,0.14); color: rgba(255,255,255,0.9); }
+150 -285
View File
@@ -1,15 +1,18 @@
<script lang="ts">
import { Play, ArrowRight, ArrowLeft, BookOpen, Clock, ListBullets, PushPin, X as XIcon } from "phosphor-svelte";
import type { Manga, Chapter } from "@types";
import type { HistoryEntry } from "@store/state.svelte";
import { store, setGenreFilter, setNavPage } from "@store/state.svelte";
import { timeAgo } from "../lib/homeHelpers";
import { Play, ArrowRight, ArrowLeft, BookOpen, Clock, ListBullets, PushPin, X as XIcon } from 'phosphor-svelte'
import { goto } from '$app/navigation'
import { timeAgo } from '$lib/components/home/homeHelpers'
import Thumbnail from '$lib/components/shared/manga/Thumbnail.svelte'
import { resolvedCover } from '$lib/core/cover/coverResolver'
import type { Manga } from '$lib/types'
import type { Chapter } from '$lib/types'
import type { ReadSession } from '$lib/types/history'
interface HeroSlot {
kind: "continue" | "pinned" | "empty";
entry?: HistoryEntry;
manga?: Manga;
slotIndex: number;
kind: 'continue' | 'pinned' | 'empty'
entry?: ReadSession
manga?: Manga
slotIndex: number
}
let {
@@ -33,29 +36,29 @@
onunpin,
onviewall,
}: {
resolvedSlots: HeroSlot[];
activeIdx: number;
heroThumb: string;
heroTitle: string;
heroManga: Manga | null | undefined;
heroEntry: HistoryEntry | null;
heroMangaId: number | null;
heroChapters: Chapter[];
heroNewChapter: Chapter | null;
loadingHeroChapters: boolean;
resuming: boolean;
onresume: () => void;
onopenchapter: (ch: Chapter) => void;
oncyclenext: () => void;
oncycleprev: () => void;
ongotoslot: (i: number) => void;
onopenpicker: (i: 1 | 2 | 3) => void;
onunpin: (i: 1 | 2 | 3) => void;
onviewall: () => void;
} = $props();
resolvedSlots: HeroSlot[]
activeIdx: number
heroThumb: string
heroTitle: string
heroManga: Manga | null | undefined
heroEntry: ReadSession | null
heroMangaId: number | null
heroChapters: Chapter[]
heroNewChapter: Chapter | null
loadingHeroChapters: boolean
resuming: boolean
onresume: () => void
onopenchapter: (ch: Chapter) => void
oncyclenext: () => void
oncycleprev: () => void
ongotoslot: (i: number) => void
onopenpicker: (i: 1 | 2 | 3) => void
onunpin: (i: 1 | 2 | 3) => void
onviewall: () => void
} = $props()
const activeSlot = $derived(resolvedSlots[activeIdx]);
const TOTAL_SLOTS = 4;
const activeSlot = $derived(resolvedSlots[activeIdx])
const TOTAL_SLOTS = 4
</script>
<div class="hero-stage">
@@ -71,12 +74,12 @@
<button
class="hero-cover-col"
onclick={onresume}
disabled={resuming || activeSlot?.kind === "empty"}
aria-label={heroTitle ? `Resume ${heroTitle}` : "No manga selected"}
disabled={resuming || activeSlot?.kind === 'empty'}
aria-label={heroTitle ? `Resume ${heroTitle}` : 'No manga selected'}
>
{#if heroThumb}
<img src={heroThumb} alt={heroTitle} class="hero-cover" loading="eager" decoding="async" />
{#if activeSlot?.kind === "continue"}
<Thumbnail src={resolvedCover(heroMangaId ?? 0, heroThumb)} alt={heroTitle} class="hero-cover" loading="eager" />
{#if activeSlot?.kind === 'continue'}
<div class="cover-resume-hint"><Play size={20} weight="fill" /></div>
{/if}
{:else}
@@ -85,12 +88,12 @@
</button>
<div class="hero-details">
{#if activeSlot?.kind === "empty"}
{#if activeSlot?.kind === 'empty'}
<p class="hero-empty-title">Nothing here yet</p>
<p class="hero-empty-sub">
{activeSlot.slotIndex === 0
? "Read a manga to see it here"
: "Pin a manga or keep reading to fill this slot"}
? 'Read a manga to see it here'
: 'Pin a manga or keep reading to fill this slot'}
</p>
{#if activeSlot.slotIndex !== 0}
<button class="hero-cta" onclick={() => onopenpicker(activeSlot.slotIndex as 1 | 2 | 3)}>
@@ -99,7 +102,7 @@
{/if}
{:else}
<div class="hero-tags">
{#if activeSlot?.kind === "continue"}
{#if activeSlot?.kind === 'continue'}
<span class="hero-tag hero-tag-reading"><Play size={8} weight="fill" /> Reading</span>
{:else}
<span class="hero-tag hero-tag-pinned"><PushPin size={8} weight="fill" /> Pinned</span>
@@ -110,7 +113,7 @@
{#each (heroManga?.genre ?? []).slice(0, 3) as g}
<button
class="hero-tag hero-tag-genre"
onclick={() => { setGenreFilter(g); setNavPage("explore"); }}
onclick={() => goto(`/browse?genre=${encodeURIComponent(g)}`)}
>{g}</button>
{/each}
</div>
@@ -121,9 +124,9 @@
{#if heroEntry}
<p class="hero-progress">
<Clock size={10} weight="light" />
{heroEntry.chapterName}
{#if heroEntry.pageNumber > 1}<span class="hero-prog-page"> · p.{heroEntry.pageNumber}</span>{/if}
<span class="hero-prog-time">{timeAgo(heroEntry.readAt)}</span>
{heroEntry.endChapterName}
{#if heroEntry.endPage > 1}<span class="hero-prog-page"> · p.{heroEntry.endPage}</span>{/if}
<span class="hero-prog-time">{timeAgo(heroEntry.endedAt)}</span>
</p>
{/if}
@@ -132,17 +135,17 @@
{/if}
<div class="hero-actions">
{#if activeSlot?.kind === "continue"}
{#if activeSlot?.kind === 'continue'}
<button class="hero-cta" onclick={onresume} disabled={resuming}>
<Play size={11} weight="fill" />{resuming ? "Loading…" : "Resume"}
<Play size={11} weight="fill" />{resuming ? 'Loading…' : 'Resume'}
</button>
{:else if heroManga}
<button class="hero-cta" onclick={() => store.previewManga = heroManga!}>
<button class="hero-cta" onclick={() => goto(`/series/${heroManga!.id}`)}>
<BookOpen size={11} weight="light" /> View manga
</button>
{/if}
{#if activeSlot?.slotIndex !== 0}
{#if activeSlot?.kind === "pinned"}
{#if activeSlot?.kind === 'pinned'}
<button class="hero-cta-ghost" onclick={() => onunpin(activeSlot.slotIndex as 1 | 2 | 3)}>
<XIcon size={10} weight="bold" /> Unpin
</button>
@@ -164,7 +167,7 @@
<button
class="hero-dot"
class:active={activeIdx === i}
class:pinned={slot.kind === "pinned"}
class:pinned={slot.kind === 'pinned'}
onclick={() => ongotoslot(i)}
aria-label="Slot {i + 1}"
></button>
@@ -182,7 +185,7 @@
<ListBullets size={11} weight="bold" /><span>Up Next</span>
</div>
{#if activeSlot?.kind === "empty"}
{#if activeSlot?.kind === 'empty'}
<p class="hero-chapters-empty">No chapters to show</p>
{:else if loadingHeroChapters}
{#each Array(4) as _}
@@ -198,7 +201,7 @@
<p class="hero-chapters-empty">No chapters available</p>
{:else}
{#each heroChapters as ch (ch.id)}
{@const isCurrent = heroEntry?.chapterId === ch.id}
{@const isCurrent = heroEntry?.endChapterId === ch.id}
<button
class="chapter-row"
class:chapter-row-current={isCurrent}
@@ -208,14 +211,14 @@
<span class="ch-num">Ch.{ch.chapterNumber % 1 === 0 ? Math.floor(ch.chapterNumber) : ch.chapterNumber}</span>
<div class="ch-info">
<span class="ch-name">{ch.name}</span>
{#if isCurrent && heroEntry && heroEntry.pageNumber > 1}
<span class="ch-meta">p.{heroEntry.pageNumber} · in progress</span>
{#if isCurrent && heroEntry && heroEntry.endPage > 1}
<span class="ch-meta">p.{heroEntry.endPage} · in progress</span>
{:else if ch.isRead}
<span class="ch-meta ch-read">Read</span>
{:else if ch.uploadDate}
<span class="ch-meta">
{new Date(Number(ch.uploadDate) > 1e10 ? Number(ch.uploadDate) : Number(ch.uploadDate) * 1000)
.toLocaleDateString("en-US", { month: "short", day: "numeric" })}
.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</span>
{/if}
</div>
@@ -274,316 +277,178 @@
background: var(--bg-raised);
padding: 0;
border: none;
border-right: 1px solid rgba(255, 255, 255, 0.07);
border-right: 1px solid rgba(255,255,255,0.07);
}
.hero-cover-col:hover .hero-cover { filter: brightness(1.1) saturate(1.05); }
.hero-cover-col:hover :global(.hero-cover) { filter: brightness(1.1) saturate(1.05); }
.hero-cover-col:hover .cover-resume-hint { opacity: 1; }
.hero-cover { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.22s ease; }
:global(.hero-cover) { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.22s ease; }
.hero-cover-empty {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-overlay);
color: var(--text-faint);
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
background: var(--bg-overlay); color: var(--text-faint);
}
.cover-resume-hint {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
background: rgba(0, 0, 0, 0.38);
opacity: 0;
transition: opacity 0.18s ease;
pointer-events: none;
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
color: #fff; background: rgba(0,0,0,0.38);
opacity: 0; transition: opacity 0.18s ease; pointer-events: none;
}
.hero-details {
position: relative;
z-index: 2;
flex: 1;
min-width: 0;
position: relative; z-index: 2;
flex: 1; min-width: 0;
padding: var(--sp-5) var(--sp-5) var(--sp-4);
display: flex;
flex-direction: column;
gap: var(--sp-2);
display: flex; flex-direction: column; gap: var(--sp-2);
overflow: hidden;
border-right: 1px solid rgba(255, 255, 255, 0.05);
border-right: 1px solid rgba(255,255,255,0.05);
}
.hero-tags { display: flex; flex-wrap: wrap; gap: 5px; flex-shrink: 0; }
.hero-tag {
display: inline-flex;
align-items: center;
gap: 5px;
font-family: var(--font-ui);
font-size: 9px;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
padding: 3px 8px;
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.6);
border: 1px solid rgba(255, 255, 255, 0.13);
display: inline-flex; align-items: center; gap: 5px;
font-family: var(--font-ui); font-size: 9px;
letter-spacing: var(--tracking-wide); text-transform: uppercase;
padding: 3px 8px; border-radius: var(--radius-sm);
background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.6);
border: 1px solid rgba(255,255,255,0.13);
}
.hero-tag-reading { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); }
.hero-tag-pinned { background: rgba(168, 132, 232, 0.18); color: #c4a8f0; border-color: rgba(168, 132, 232, 0.28); }
.hero-tag-new { background: rgba(74, 222, 128, 0.15); color: #86efac; border-color: rgba(74, 222, 128, 0.25); }
.hero-tag-pinned { background: rgba(168,132,232,0.18); color: #c4a8f0; border-color: rgba(168,132,232,0.28); }
.hero-tag-new { background: rgba(74,222,128,0.15); color: #86efac; border-color: rgba(74,222,128,0.25); }
.hero-tag-genre { cursor: pointer; transition: background 0.15s, color 0.15s; }
.hero-tag-genre:hover { background: rgba(255, 255, 255, 0.18); color: rgba(255, 255, 255, 0.9); }
.hero-tag-genre:hover { background: rgba(255,255,255,0.18); color: rgba(255,255,255,0.9); }
.hero-title {
font-size: var(--text-xl);
font-weight: var(--weight-semibold);
color: #fff;
line-height: var(--leading-tight);
margin: 0;
flex-shrink: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.55);
letter-spacing: -0.01em;
font-size: var(--text-xl); font-weight: var(--weight-semibold);
color: #fff; line-height: var(--leading-tight); margin: 0; flex-shrink: 0;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
text-shadow: 0 2px 12px rgba(0,0,0,0.55); letter-spacing: -0.01em;
}
.hero-author {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: rgba(255, 255, 255, 0.45);
letter-spacing: var(--tracking-wide);
flex-shrink: 0;
font-family: var(--font-ui); font-size: var(--text-xs);
color: rgba(255,255,255,0.45); letter-spacing: var(--tracking-wide); flex-shrink: 0;
}
.hero-progress {
display: flex;
align-items: center;
gap: 5px;
flex-shrink: 0;
font-family: var(--font-ui);
font-size: var(--text-xs);
color: rgba(255, 255, 255, 0.55);
letter-spacing: var(--tracking-wide);
display: flex; align-items: center; gap: 5px; flex-shrink: 0;
font-family: var(--font-ui); font-size: var(--text-xs);
color: rgba(255,255,255,0.55); letter-spacing: var(--tracking-wide);
}
.hero-prog-page { color: rgba(255, 255, 255, 0.35); }
.hero-prog-time { margin-left: auto; color: rgba(255, 255, 255, 0.3); }
.hero-prog-page { color: rgba(255,255,255,0.35); }
.hero-prog-time { margin-left: auto; color: rgba(255,255,255,0.3); }
.hero-desc {
font-size: var(--text-xs);
color: rgba(255, 255, 255, 0.38);
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
flex-shrink: 0;
}
.hero-empty-title {
font-size: var(--text-base);
font-weight: var(--weight-medium);
color: rgba(255, 255, 255, 0.48);
flex-shrink: 0;
font-size: var(--text-xs); color: rgba(255,255,255,0.38); line-height: 1.6;
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; flex-shrink: 0;
}
.hero-empty-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: rgba(255,255,255,0.48); flex-shrink: 0; }
.hero-empty-sub {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: rgba(255, 255, 255, 0.26);
letter-spacing: var(--tracking-wide);
line-height: var(--leading-snug);
font-family: var(--font-ui); font-size: var(--text-xs);
color: rgba(255,255,255,0.26); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug);
}
.hero-actions { display: flex; gap: var(--sp-2); flex-shrink: 0; flex-wrap: wrap; margin-top: var(--sp-1); }
.hero-cta {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-ui);
font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
padding: 7px 18px;
border-radius: var(--radius-md);
background: var(--accent-muted);
border: 1px solid var(--accent-dim);
color: var(--accent-fg);
cursor: pointer;
transition: filter var(--t-base);
white-space: nowrap;
display: inline-flex; align-items: center; gap: 6px;
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 7px 18px; border-radius: var(--radius-md);
background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg);
cursor: pointer; transition: filter var(--t-base); white-space: nowrap;
}
.hero-cta:hover:not(:disabled) { filter: brightness(1.18); }
.hero-cta:disabled { opacity: 0.5; cursor: default; }
.hero-cta-ghost {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-ui);
font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
padding: 7px 14px;
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.11);
color: rgba(255, 255, 255, 0.48);
cursor: pointer;
transition: background var(--t-base), color var(--t-base);
white-space: nowrap;
display: inline-flex; align-items: center; gap: 6px;
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 7px 14px; border-radius: var(--radius-md);
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.11);
color: rgba(255,255,255,0.48); cursor: pointer;
transition: background var(--t-base), color var(--t-base); white-space: nowrap;
}
.hero-cta-ghost:hover { background: rgba(255, 255, 255, 0.12); color: rgba(255, 255, 255, 0.82); }
.hero-cta-ghost:hover { background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.82); }
.hero-nav-row {
display: flex;
align-items: center;
gap: var(--sp-2);
flex-shrink: 0;
margin-top: auto;
padding-top: var(--sp-3);
border-top: 1px solid rgba(255, 255, 255, 0.07);
display: flex; align-items: center; gap: var(--sp-2);
flex-shrink: 0; margin-top: auto; padding-top: var(--sp-3);
border-top: 1px solid rgba(255,255,255,0.07);
}
.hero-nav-btn {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.11);
color: rgba(255, 255, 255, 0.55);
cursor: pointer;
flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
width: 22px; height: 22px; border-radius: 50%;
background: rgba(255,255,255,0.08); border: 1px solid rgba(255,255,255,0.11);
color: rgba(255,255,255,0.55); cursor: pointer; flex-shrink: 0;
transition: background var(--t-base), color var(--t-base);
}
.hero-nav-btn:hover { background: rgba(255, 255, 255, 0.18); color: #fff; }
.hero-nav-btn:hover { background: rgba(255,255,255,0.18); color: #fff; }
.hero-dots { display: flex; gap: 5px; align-items: center; }
.hero-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
border: none;
cursor: pointer;
padding: 0;
width: 5px; height: 5px; border-radius: 50%;
background: rgba(255,255,255,0.2); border: none; cursor: pointer; padding: 0;
transition: background var(--t-base), transform var(--t-base), width var(--t-base);
}
.hero-dot:hover { background: rgba(255, 255, 255, 0.48); }
.hero-dot:hover { background: rgba(255,255,255,0.48); }
.hero-dot.active { background: #fff; width: 14px; border-radius: 3px; }
.hero-dot.pinned { background: rgba(168, 132, 232, 0.5); }
.hero-dot.pinned { background: rgba(168,132,232,0.5); }
.hero-dot.pinned.active { background: #c4a8f0; }
.hero-counter {
font-family: var(--font-ui);
font-size: 10px;
color: rgba(255, 255, 255, 0.28);
letter-spacing: var(--tracking-wide);
margin-left: auto;
font-family: var(--font-ui); font-size: 10px;
color: rgba(255,255,255,0.28); letter-spacing: var(--tracking-wide); margin-left: auto;
}
.hero-chapters {
position: relative;
z-index: 2;
width: clamp(180px, 30%, 232px);
flex-shrink: 0;
display: flex;
flex-direction: column;
padding: var(--sp-4) var(--sp-3);
gap: 1px;
overflow: hidden;
position: relative; z-index: 2;
width: clamp(180px, 30%, 232px); flex-shrink: 0;
display: flex; flex-direction: column;
padding: var(--sp-4) var(--sp-3); gap: 1px; overflow: hidden;
}
.hero-chapters-header {
display: flex;
align-items: center;
gap: var(--sp-2);
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: rgba(255, 255, 255, 0.35);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
padding-bottom: var(--sp-2);
margin-bottom: var(--sp-1);
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
flex-shrink: 0;
display: flex; align-items: center; gap: var(--sp-2);
font-family: var(--font-ui); font-size: var(--text-2xs);
color: rgba(255,255,255,0.35); letter-spacing: var(--tracking-wider); text-transform: uppercase;
padding-bottom: var(--sp-2); margin-bottom: var(--sp-1);
border-bottom: 1px solid rgba(255,255,255,0.07); flex-shrink: 0;
}
.hero-chapters-empty {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: rgba(255, 255, 255, 0.22);
letter-spacing: var(--tracking-wide);
padding: var(--sp-3) 0;
font-family: var(--font-ui); font-size: var(--text-xs);
color: rgba(255,255,255,0.22); letter-spacing: var(--tracking-wide); padding: var(--sp-3) 0;
}
.chapter-row {
display: flex;
align-items: center;
gap: var(--sp-2);
width: 100%;
padding: 7px var(--sp-2);
border-radius: var(--radius-sm);
background: none;
border: none;
text-align: left;
cursor: pointer;
display: flex; align-items: center; gap: var(--sp-2); width: 100%;
padding: 7px var(--sp-2); border-radius: var(--radius-sm);
background: none; border: none; text-align: left; cursor: pointer;
transition: background var(--t-fast);
}
.chapter-row:hover { background: rgba(255, 255, 255, 0.07); }
.chapter-row-current { background: rgba(255, 255, 255, 0.1) !important; }
.chapter-row:hover { background: rgba(255,255,255,0.07); }
.chapter-row-current { background: rgba(255,255,255,0.1) !important; }
.ch-num {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: rgba(255, 255, 255, 0.32);
letter-spacing: var(--tracking-wide);
flex-shrink: 0;
min-width: 36px;
font-family: var(--font-ui); font-size: var(--text-2xs);
color: rgba(255,255,255,0.32); letter-spacing: var(--tracking-wide); flex-shrink: 0; min-width: 36px;
}
.chapter-row-current .ch-num { color: var(--accent-fg); }
.ch-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.ch-name {
font-size: var(--text-xs);
color: rgba(255, 255, 255, 0.72);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chapter-row-read .ch-name { color: rgba(255, 255, 255, 0.32); }
.ch-name { font-size: var(--text-xs); color: rgba(255,255,255,0.72); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chapter-row-read .ch-name { color: rgba(255,255,255,0.32); }
.chapter-row-current .ch-name { color: #fff; font-weight: var(--weight-medium); }
.ch-meta {
font-family: var(--font-ui);
font-size: 9px;
color: rgba(255, 255, 255, 0.26);
letter-spacing: var(--tracking-wide);
}
.ch-read { color: rgba(255, 255, 255, 0.18); }
.ch-meta { font-family: var(--font-ui); font-size: 9px; color: rgba(255,255,255,0.26); letter-spacing: var(--tracking-wide); }
.ch-read { color: rgba(255,255,255,0.18); }
:global(.ch-play-icon) { color: var(--accent-fg); flex-shrink: 0; }
.chapter-row-sk { display: flex; gap: var(--sp-2); padding: 7px var(--sp-2); align-items: center; }
.sk-info { flex: 1; display: flex; flex-direction: column; gap: 4px; }
.sk { background: rgba(255, 255, 255, 0.06); border-radius: var(--radius-sm); animation: pulse 1.6s ease-in-out infinite; }
.sk { background: rgba(255,255,255,0.06); border-radius: var(--radius-sm); animation: pulse 1.6s ease-in-out infinite; }
.sk-num { width: 32px; height: 10px; flex-shrink: 0; }
.sk-name { height: 11px; width: 85%; }
.sk-meta { height: 9px; width: 50%; }
.ch-view-all {
display: flex;
align-items: center;
gap: 4px;
margin-top: auto;
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: rgba(255, 255, 255, 0.28);
letter-spacing: var(--tracking-wide);
background: none;
border: none;
cursor: pointer;
padding: var(--sp-2) var(--sp-2) 0;
transition: color var(--t-base);
display: flex; align-items: center; gap: 4px; margin-top: auto;
font-family: var(--font-ui); font-size: var(--text-2xs);
color: rgba(255,255,255,0.28); letter-spacing: var(--tracking-wide);
background: none; border: none; cursor: pointer;
padding: var(--sp-2) var(--sp-2) 0; transition: color var(--t-base);
}
.ch-view-all:hover { color: var(--accent-fg); }
@keyframes backdropIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes backdropIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes pulse { 0%, 100% { opacity: 0.4 } 50% { opacity: 0.7 } }
</style>
+14 -225
View File
@@ -1,244 +1,33 @@
<script lang="ts">
import { ArrowLeft, ArrowRight, Sparkle } from "phosphor-svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import type { Manga } from "@types";
import type { HistoryEntry } from "@store/state.svelte";
import { fetchRecommendations, topGenres } from "../lib/recommendations";
import type { RecommendedManga } from "../lib/recommendations";
import { Sparkle } from 'phosphor-svelte'
import type { Manga } from '$lib/types'
import type { ReadSession } from '$lib/types/history'
let {
libraryManga,
history,
onopenrecommended,
}: {
libraryManga: Manga[];
history: HistoryEntry[];
onopenrecommended: (m: Manga) => void;
} = $props();
const CARD_MIN_WIDTH = 100;
const GAP = 12;
const ROWS = 2;
let containerEl: HTMLDivElement | undefined = $state();
let containerWidth = $state(0);
$effect(() => {
if (!containerEl) return;
const ro = new ResizeObserver(([entry]) => {
containerWidth = entry.contentRect.width;
});
ro.observe(containerEl);
return () => ro.disconnect();
});
const cols = $derived(containerWidth > 0 ? Math.max(1, Math.floor((containerWidth + GAP) / (CARD_MIN_WIDTH + GAP))) : 6);
const visibleCount = $derived(cols * ROWS);
const gridStyle = $derived(`grid-template-columns: repeat(${cols}, 1fr);`);
let allRecs: RecommendedManga[] = $state([]);
let loading = $state(false);
let _ctrl: AbortController | null = null;
$effect(() => {
const _history = history;
const _library = libraryManga;
if (!_history.length || !_library.length) { allRecs = []; return; }
_ctrl?.abort();
const ctrl = new AbortController();
_ctrl = ctrl;
loading = true;
fetchRecommendations(_history, _library, ctrl.signal)
.then(r => { if (!ctrl.signal.aborted) { allRecs = r; loading = false; } })
.catch(() => { if (!ctrl.signal.aborted) loading = false; });
});
const genres = $derived(topGenres(history, libraryManga));
let genreIdx = $state(0);
const activeGenre = $derived(genres[genreIdx] ?? null);
const visibleRecs = $derived(
(activeGenre
? allRecs.filter(r => r.matchedGenres.some(g => g.toLowerCase() === activeGenre.toLowerCase()))
: allRecs
).slice(0, visibleCount)
);
function prev() { genreIdx = (genreIdx - 1 + genres.length) % genres.length; }
function next() { genreIdx = (genreIdx + 1) % genres.length; }
libraryManga: Manga[]
history: ReadSession[]
onopenrecommended: (m: Manga) => void
} = $props()
</script>
<div class="col">
<div class="col-header">
<span class="col-title">
<Sparkle size={10} weight="bold" /> Recommended
</span>
{#if genres.length > 1}
<div class="genre-switcher">
<button class="nav-btn" onclick={prev}><ArrowLeft size={9} weight="bold" /></button>
<span class="genre-label">{activeGenre}</span>
<button class="nav-btn" onclick={next}><ArrowRight size={9} weight="bold" /></button>
</div>
{/if}
</div>
<div class="grid-container" bind:this={containerEl}>
{#if loading}
<p class="empty-msg">Loading…</p>
{:else if visibleRecs.length > 0}
<div class="card-grid" style={gridStyle}>
{#each visibleRecs as r (r.manga.id)}
<button class="card" onclick={() => onopenrecommended(r.manga)}>
<div class="card-cover-wrap">
<Thumbnail src={r.manga.thumbnailUrl} alt={r.manga.title} class="card-cover" />
<div class="card-gradient"></div>
<div class="card-footer">
<p class="card-title">{r.manga.title}</p>
<p class="card-badge">{r.matchedGenres.slice(0, 2).join(" · ")}</p>
</div>
</div>
</button>
{/each}
</div>
{:else}
<p class="empty-msg">No recommendations found</p>
{/if}
<span class="col-title"><Sparkle size={10} weight="bold" /> Recommended</span>
</div>
<p class="stub">Recommendations coming soon</p>
</div>
<style>
.col { display: flex; flex-direction: column; min-width: 0; height: 100%; }
.col-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: var(--sp-2);
flex-shrink: 0;
}
.col-header { display: flex; align-items: center; justify-content: space-between; padding-bottom: var(--sp-2); flex-shrink: 0; }
.col-title {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
.genre-switcher {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.genre-label {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
min-width: 48px;
text-align: center;
}
.nav-btn {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
cursor: pointer;
padding: 2px;
color: var(--text-faint);
transition: color var(--t-base);
}
.nav-btn:hover { color: var(--accent-fg); }
.grid-container {
flex: 1;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.card-grid {
display: grid;
grid-template-rows: repeat(2, auto);
grid-auto-rows: 0;
overflow: hidden;
gap: var(--sp-3);
align-content: start;
}
.card {
width: 100%;
background: none;
border: none;
padding: 0;
cursor: pointer;
text-align: left;
}
.card:hover :global(.card-cover) { filter: brightness(1.1) saturate(1.05); transform: scale(1.02); }
.card-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);
box-shadow: 0 2px 14px rgba(0, 0, 0, 0.38);
}
:global(.card-cover) {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: filter 0.15s ease, transform 0.15s ease;
}
.card-gradient {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.08) 55%, transparent 75%);
pointer-events: none;
}
.card-footer {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: var(--sp-2);
pointer-events: none;
}
.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);
}
.card-badge {
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;
}
.empty-msg {
font-family: var(--font-ui);
font-size: var(--text-sm);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
padding: var(--sp-1) 0;
display: inline-flex; align-items: center; gap: var(--sp-2);
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
}
.stub { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
</style>
+21 -52
View File
@@ -1,20 +1,15 @@
<script lang="ts">
import { Fire, BookOpen, Clock, TrendUp, Bell, CalendarBlank } from "phosphor-svelte";
import { formatReadTime } from "../lib/homeHelpers";
import { Fire, BookOpen, Clock, TrendUp, Bell, CalendarBlank } from 'phosphor-svelte'
import { formatReadTime } from '$lib/components/home/homeHelpers'
import type { ReadingStats } from '$lib/types/history'
let {
stats,
updateCount,
}: {
stats: {
currentStreakDays: number;
totalChaptersRead: number;
totalMinutesRead: number;
totalMangaRead: number;
longestStreakDays: number;
};
updateCount: number;
} = $props();
stats: ReadingStats
updateCount: number
} = $props()
</script>
<div class="col">
@@ -69,65 +64,39 @@
<style>
.col { display: flex; flex-direction: column; min-width: 0; }
.col-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: var(--sp-2);
}
.col-header { display: flex; align-items: center; justify-content: space-between; padding-bottom: var(--sp-2); }
.col-title {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
display: inline-flex; align-items: center; gap: var(--sp-2);
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
}
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-2); }
.card {
display: flex;
align-items: center;
gap: var(--sp-3);
background: var(--bg-raised);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
padding: var(--sp-3);
display: flex; align-items: center; gap: var(--sp-3);
background: var(--bg-raised); border: 1px solid var(--border-dim);
border-radius: var(--radius-md); padding: var(--sp-3);
transition: border-color var(--t-fast);
}
.card:hover { border-color: var(--border-base); }
.icon-wrap {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: var(--radius-sm);
flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
width: 32px; height: 32px; border-radius: var(--radius-sm); flex-shrink: 0;
}
.fire { background: rgba(251, 146, 60, 0.15); color: #fb923c; }
.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); }
.green { background: rgba(34, 197, 94, 0.12); color: #22c55e; }
.green { background: rgba(34,197,94,0.12); color: #22c55e; }
.body { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.val {
font-family: var(--font-ui);
font-size: var(--text-lg, 1.05rem);
font-weight: var(--weight-medium);
color: var(--text-secondary);
line-height: 1;
font-family: var(--font-ui); font-size: var(--text-lg, 1.05rem);
font-weight: var(--weight-medium); color: var(--text-secondary); line-height: 1;
}
.label {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
white-space: nowrap;
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap;
}
</style>
+2 -1
View File
@@ -41,6 +41,7 @@
"@tauri-apps/plugin-store": "^2.4.3",
"capacitor-native-biometric": "^4.2.2",
"clsx": "^2.1.1",
"phosphor-svelte": "^3.1.0"
"phosphor-svelte": "^3.1.0",
"tauri-plugin-discord-rpc-api": "github:Youwes09/tauri-plugin-discord-rpc"
}
}
+11
View File
@@ -50,6 +50,9 @@ importers:
phosphor-svelte:
specifier: ^3.1.0
version: 3.1.0(svelte@5.55.5(@typescript-eslint/types@8.57.1))(vite@8.0.10)
tauri-plugin-discord-rpc-api:
specifier: github:Youwes09/tauri-plugin-discord-rpc
version: https://codeload.github.com/Youwes09/tauri-plugin-discord-rpc/tar.gz/d2fd312945d0573153e0e7e2d2dfb131acecc52c
devDependencies:
'@sveltejs/adapter-node':
specifier: ^5.5.4
@@ -835,6 +838,10 @@ packages:
resolution: {integrity: sha512-2uCs/LZ9us+AktdzYJM8OcxQ8qnPS1kpaO7syGT/MgO+6Qr1Ybl+TqPq+97u7PHqmmMlye5ZkoyXONy5mjjAbw==}
engines: {node: '>=18'}
tauri-plugin-discord-rpc-api@https://codeload.github.com/Youwes09/tauri-plugin-discord-rpc/tar.gz/d2fd312945d0573153e0e7e2d2dfb131acecc52c:
resolution: {tarball: https://codeload.github.com/Youwes09/tauri-plugin-discord-rpc/tar.gz/d2fd312945d0573153e0e7e2d2dfb131acecc52c}
version: 0.1.0
tinyglobby@0.2.16:
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
engines: {node: '>=12.0.0'}
@@ -1536,6 +1543,10 @@ snapshots:
transitivePeerDependencies:
- '@typescript-eslint/types'
tauri-plugin-discord-rpc-api@https://codeload.github.com/Youwes09/tauri-plugin-discord-rpc/tar.gz/d2fd312945d0573153e0e7e2d2dfb131acecc52c:
dependencies:
'@tauri-apps/api': 2.11.0
tinyglobby@0.2.16:
dependencies:
fdir: 6.5.0(picomatch@4.0.4)
+16
View File
@@ -2,6 +2,10 @@ import { initRequestManager } from '$lib/request-manager'
import { initPlatformService } from '$lib/platform-service'
import { appState } from '$lib/state/app.svelte'
import { configureAuth, probeServer } from '$lib/core/auth'
import { loadSettings, loadLibrary, loadUpdates } from '$lib/core/persistence/persist'
import { loadSettingsIntoState } from '$lib/state/settings.svelte'
import { historyState } from '$lib/state/history.svelte'
import { readerState } from '$lib/state/reader.svelte'
const KEY_URL = 'moku_server_url'
const KEY_AUTH = 'moku_auth_config'
@@ -52,6 +56,18 @@ async function boot() {
appState.platform = detectPlatform()
appState.version = await platformAdapter.getVersion()
const [settingsData, libraryData, _updatesData] = await Promise.all([
loadSettings(),
loadLibrary(),
loadUpdates(),
])
await loadSettingsIntoState(settingsData.settings)
readerState.bookmarks = libraryData.bookmarks
readerState.markers = libraryData.markers
historyState.load(libraryData.sessions, libraryData.dailyReadCounts)
const savedUrl = (await platformAdapter.getCredential(KEY_URL)) ?? 'http://127.0.0.1:4567'
const savedAuthRaw = await platformAdapter.getCredential(KEY_AUTH)
const savedAuth: SavedAuth = savedAuthRaw ? JSON.parse(savedAuthRaw) : { mode: 'NONE' }
+428 -141
View File
@@ -1,136 +1,415 @@
<script lang="ts">
import { onMount } from 'svelte'
import logoUrl from '$lib/assets/moku-icon-splash.svg'
import { mountCardCanvas, ringGeometry, animateRingProgress } from '$lib/components/chrome/splashCanvas'
import { onMount } from "svelte";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { settingsState } from "$lib/state/settings.svelte";
import logoUrl from "$lib/assets/moku-icon-splash.svg";
interface Props {
mode?: 'loading' | 'idle'
ringFull?: boolean
failed?: boolean
notConfigured?: boolean
showCards?: boolean
onReady?: () => void
onRetry?: () => void
onBypass?: () => void
onDismiss?: () => void
mode?: "loading" | "idle";
ringFull?: boolean;
failed?: boolean;
notConfigured?: boolean;
showCards?: boolean;
showFps?: boolean;
onReady?: () => void;
onRetry?: () => void;
onBypass?: () => void;
onDismiss?: () => void;
}
let {
mode = 'loading',
ringFull = false,
failed = false,
notConfigured = false,
showCards = true,
onReady,
onRetry,
onBypass,
onDismiss,
}: Props = $props()
mode = "loading", ringFull = false, failed = false,
notConfigured = false, showCards = true, showFps = false,
onReady, onRetry, onBypass, onDismiss,
}: Props = $props();
const EXIT_MS = 320
const RING_R = 70
const RING_PAD = 12
const { size: ringSize, c: ringC, circ: ringCirc } = ringGeometry(RING_R, RING_PAD)
const serverAuthActive = $derived(
settingsState.settings.serverAuthMode === "BASIC_AUTH" || settingsState.settings.serverAuthMode === "UI_LOGIN"
);
const LOGO_LOADING = 140
const LOGO_IDLE = 128
const lockEnabled = $derived(
settingsState.settings.appLockEnabled &&
(settingsState.settings.appLockPin?.length ?? 0) >= 4 &&
(mode === "idle" || !serverAuthActive)
);
let dots = $state('')
let ringProg = $state(0.025)
let exiting = $state(false)
let exitLock = false
let pinEntry = $state('')
let pinShake = $state(false)
let pinVisible = $state(false)
let pinUnlocked = $state(false)
let pinEntry = $state("");
let pinShake = $state(false);
let pinUnlocked = $state(false);
let pinVisible = $state(false);
let uiScale = $state(1);
let fpsEl = $state<HTMLSpanElement | undefined>(undefined);
const ringArc = $derived(ringCirc * Math.min(Math.max(ringProg, 0.025), 0.999))
const logoLoadingSize = 140;
const logoIdleSize = 128;
const logoLockSize = 96;
const ringR = 70;
const ringPad = 12;
const ringSize = (ringR + ringPad) * 2;
const ringC = ringR + ringPad;
const ringCirc = 2 * Math.PI * ringR;
const ringArc = $derived(ringCirc * Math.min(Math.max(ringProg, 0.025), 0.999));
function submitPin() {
if (pinEntry === settingsState.settings.appLockPin) {
pinUnlocked = true;
pinEntry = "";
if (mode === "idle") triggerExit(onDismiss);
} else {
pinShake = true;
pinEntry = "";
setTimeout(() => (pinShake = false), 500);
}
}
function onPinKey(e: KeyboardEvent) {
if (e.key === "Enter") { submitPin(); return; }
if (e.key === "Backspace") { pinEntry = pinEntry.slice(0, -1); return; }
if (/^\d$/.test(e.key)) {
pinEntry = (pinEntry + e.key).slice(0, 8);
if (pinEntry.length >= (settingsState.settings.appLockPin?.length ?? 4)) submitPin();
}
}
const EXIT_MS = 320;
const PHASE1_TARGET = 0.85;
const PHASE1_MS = 3000;
const PHASE2_TARGET = 0.95;
const PHASE2_MS = 10000;
let dots = $state("");
let ringProg = $state(0.025);
let exiting = $state(false);
let exitLock = false;
function triggerExit(cb?: () => void) {
if (exitLock) return
exitLock = true
exiting = true
setTimeout(() => cb?.(), EXIT_MS)
if (exitLock) return;
exitLock = true;
exiting = true;
setTimeout(() => cb?.(), EXIT_MS);
}
function submitPin(correctPin: string) {
if (pinEntry === correctPin) {
pinUnlocked = true
pinEntry = ''
if (mode === 'idle') triggerExit(onDismiss)
let animFrame: number;
let animStart: number | null = null;
let animPhase = 1;
function animateRing(ts: number) {
if (exitLock) return;
if (animStart === null) animStart = ts;
const elapsed = ts - animStart;
if (animPhase === 1) {
const t = Math.min(elapsed / PHASE1_MS, 1);
ringProg = 0.025 + (1 - Math.pow(1 - t, 3)) * (PHASE1_TARGET - 0.025);
if (t >= 1) { animPhase = 2; animStart = ts; }
} else {
pinShake = true
pinEntry = ''
setTimeout(() => (pinShake = false), 500)
const t = Math.min(elapsed / PHASE2_MS, 1);
ringProg = PHASE1_TARGET + (1 - Math.pow(1 - t, 4)) * (PHASE2_TARGET - PHASE1_TARGET);
}
animFrame = requestAnimationFrame(animateRing);
}
function onPinKey(e: KeyboardEvent, correctPin: string, pinLen: number) {
if (e.key === 'Enter') { submitPin(correctPin); return }
if (e.key === 'Backspace') { pinEntry = pinEntry.slice(0, -1); return }
if (/^\d$/.test(e.key)) {
pinEntry = (pinEntry + e.key).slice(0, 8)
if (pinEntry.length >= pinLen) submitPin(correctPin)
}
$effect(() => {
if (mode === "loading" && !failed && !notConfigured && !ringFull) {
animStart = null;
animPhase = 1;
animFrame = requestAnimationFrame(animateRing);
return () => cancelAnimationFrame(animFrame);
}
});
$effect(() => {
if (!ringFull) {
exitLock = false
exiting = false
return
exitLock = false;
exiting = false;
return;
}
if (failed || notConfigured) return
triggerExit(onReady)
})
cancelAnimationFrame(animFrame);
animFrame = 0;
ringProg = 1;
if (lockEnabled && !pinUnlocked) {
setTimeout(() => (pinVisible = true), 400);
} else {
setTimeout(() => triggerExit(onReady), 650);
}
});
$effect(() => {
if (pinUnlocked && mode !== 'idle') triggerExit(onReady)
})
const needsPin =
(mode === "idle" && lockEnabled) ||
(mode === "loading" && lockEnabled && ringFull && !pinUnlocked);
if (!needsPin) return;
window.addEventListener("keydown", onPinKey);
return () => window.removeEventListener("keydown", onPinKey);
});
onMount(() => {
const stopDots = setInterval(() => {
dots = dots.length >= 3 ? '' : dots + '.'
}, 420)
$effect(() => {
if (pinUnlocked && mode !== "idle") triggerExit(onReady);
});
if (mode === 'loading' && !failed && !notConfigured) {
const stopAnim = animateRingProgress(p => (ringProg = p))
return () => { clearInterval(stopDots); stopAnim() }
onMount(async () => {
try {
const win = getCurrentWindow();
uiScale = await win.scaleFactor();
} catch {
uiScale = window.devicePixelRatio || 1;
}
if (mode === 'idle' && onDismiss) {
const handler = () => triggerExit(onDismiss)
const dotsInterval = setInterval(() => {
dots = dots.length >= 3 ? "" : dots + ".";
}, 420);
if (mode === "idle" && onDismiss) {
if (lockEnabled) return () => clearInterval(dotsInterval);
const handler = () => triggerExit(onDismiss);
const t = setTimeout(() => {
window.addEventListener('keydown', handler, { once: true })
window.addEventListener('mousedown', handler, { once: true })
window.addEventListener('touchstart', handler, { once: true })
}, 200)
window.addEventListener("keydown", handler, { once: true });
window.addEventListener("mousedown", handler, { once: true });
window.addEventListener("touchstart", handler, { once: true });
}, 200);
return () => {
clearTimeout(t)
clearInterval(stopDots)
window.removeEventListener('keydown', handler)
window.removeEventListener('mousedown', handler)
window.removeEventListener('touchstart', handler)
clearTimeout(t);
clearInterval(dotsInterval);
window.removeEventListener("keydown", handler);
window.removeEventListener("mousedown", handler);
window.removeEventListener("touchstart", handler);
};
}
return () => clearInterval(dotsInterval);
});
interface CardDef { cx: number; w: number; h: number; lines: number; alpha: number; speed: number; cycleSec: number; phase: number; travel: number; yStart: number; angleStart: number; tilt: number; }
interface CardTrig { cosA: number; sinA: number; tiltRad: number; }
interface RenderState { cards: CardDef[]; trigs: CardTrig[]; stamps: HTMLCanvasElement[]; vignette: HTMLCanvasElement; CW: number; CH: number; scale: number; }
const LAYER_CFG = [
{ wMin: 26, wMax: 40, speedMin: 30, speedMax: 50, alpha: 0.22 },
{ wMin: 38, wMax: 56, speedMin: 52, speedMax: 80, alpha: 0.35 },
{ wMin: 54, wMax: 76, speedMin: 85, speedMax: 120, alpha: 0.50 },
] as const;
const BUF = 80, COLS = 14;
function hash(n: number): number {
let x = Math.imul(n ^ (n >>> 16), 0x45d9f3b);
x = Math.imul(x ^ (x >>> 16), 0x45d9f3b);
return ((x ^ (x >>> 16)) >>> 0) / 0xffffffff;
}
function buildCards(vw: number, vh: number) {
const cards: CardDef[] = [];
const laneW = vw / COLS;
for (let layer = 0; layer < 3; layer++) {
const cfg = LAYER_CFG[layer];
for (let col = 0; col < COLS; col++) {
const seed = col * 31 + layer * 97 + 7;
const w = cfg.wMin + hash(seed + 1) * (cfg.wMax - cfg.wMin);
const h = w * 1.44;
const speed = cfg.speedMin + hash(seed + 5) * (cfg.speedMax - cfg.speedMin);
const travel = vh + h + BUF;
cards.push({
cx: (col + 0.5) * laneW + (hash(seed + 2) * 2 - 1) * Math.max(0, (laneW - w) / 2 - 2),
w, h,
lines: 1 + Math.floor(hash(seed + 7) * 3),
alpha: cfg.alpha,
speed,
cycleSec: travel / speed,
phase: ((col / COLS) + hash(seed + 6) * 0.6 + layer * 0.23) % 1,
travel,
yStart: vh + h / 2 + BUF / 2,
angleStart: hash(seed + 3) * 50 - 25,
tilt: (hash(seed + 4) * 2 - 1) * 18,
});
}
}
const trigs: CardTrig[] = cards.map(c => ({
cosA: Math.cos(c.angleStart * (Math.PI / 180)),
sinA: Math.sin(c.angleStart * (Math.PI / 180)),
tiltRad: c.tilt * (Math.PI / 180),
}));
return { cards, trigs };
}
function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
ctx.beginPath();
ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.arcTo(x + w, y, x + w, y + r, r);
ctx.lineTo(x + w, y + h - r); ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
ctx.lineTo(x + r, y + h); ctx.arcTo(x, y + h, x, y + h - r, r);
ctx.lineTo(x, y + r); ctx.arcTo(x, y, x + r, y, r);
ctx.closePath();
}
const STAMP_PAD = 6;
function buildStamp(c: CardDef, dpr: number): HTMLCanvasElement {
const oc = document.createElement("canvas");
oc.width = Math.round(Math.ceil(c.w + STAMP_PAD * 2) * dpr);
oc.height = Math.round(Math.ceil(c.h + STAMP_PAD * 2) * dpr);
const ctx = oc.getContext("2d")!;
ctx.scale(dpr, dpr);
const x0 = STAMP_PAD, y0 = STAMP_PAD;
const coverH = c.w * 0.72 * 1.05;
const lineY0 = y0 + 3 + coverH + 5;
ctx.fillStyle = "rgba(0,0,0,0.5)"; rrect(ctx, x0 + 2, y0 + 2, c.w, c.h, 4); ctx.fill();
ctx.fillStyle = "rgba(255,255,255,0.07)"; rrect(ctx, x0, y0, c.w, c.h, 4); ctx.fill();
ctx.strokeStyle = "rgba(255,255,255,0.75)";
ctx.lineWidth = 1.2; rrect(ctx, x0, y0, c.w, c.h, 4); ctx.stroke();
ctx.fillStyle = "rgba(255,255,255,0.15)"; rrect(ctx, x0 + 3, y0 + 3, c.w - 6, coverH, 3); ctx.fill();
ctx.fillStyle = "rgba(255,255,255,0.08)"; rrect(ctx, x0 + 3, y0 + 3, (c.w - 6) * 0.45, coverH, 3); ctx.fill();
for (let li = 0; li < c.lines; li++) {
ctx.fillStyle = li === 0 ? "rgba(255,255,255,0.35)" : "rgba(255,255,255,0.20)";
ctx.fillRect(x0 + 4, lineY0 + li * 8, (c.w - 8) * (li === 0 ? 0.78 : 0.52), li === 0 ? 3 : 2);
}
return oc;
}
function buildVignette(vw: number, vh: number, dpr: number): HTMLCanvasElement {
const oc = document.createElement("canvas");
oc.width = Math.round(vw * dpr);
oc.height = Math.round(vh * dpr);
const ctx = oc.getContext("2d")!;
ctx.scale(dpr, dpr);
const g = ctx.createRadialGradient(vw / 2, vh / 2, 0, vw / 2, vh / 2, Math.max(vw, vh) * 0.65);
g.addColorStop(0, "rgba(0,0,0,0)");
g.addColorStop(0.4, "rgba(0,0,0,0)");
g.addColorStop(0.7, "rgba(0,0,0,0.25)");
g.addColorStop(1, "rgba(0,0,0,0.65)");
ctx.fillStyle = g;
ctx.fillRect(0, 0, vw, vh);
return oc;
}
function drawFrame(
ctx: CanvasRenderingContext2D, t: number, cw: number, ch: number, dpr: number,
cards: CardDef[], trigs: CardTrig[], stamps: HTMLCanvasElement[], vignette: HTMLCanvasElement,
) {
ctx.clearRect(0, 0, cw, ch);
for (let i = 0; i < cards.length; i++) {
const c = cards[i];
const p = ((t / c.cycleSec) + c.phase) % 1;
const alpha = p < 0.07 ? (p / 0.07) * c.alpha : p > 0.86 ? ((1 - p) / 0.14) * c.alpha : c.alpha;
if (alpha < 0.005) continue;
const cy = c.yStart - p * c.travel;
const tg = trigs[i];
const delta = tg.tiltRad * p;
const cos = tg.cosA * Math.cos(delta) - tg.sinA * Math.sin(delta);
const sin = tg.sinA * Math.cos(delta) + tg.cosA * Math.sin(delta);
ctx.globalAlpha = alpha;
ctx.setTransform(cos * dpr, sin * dpr, -sin * dpr, cos * dpr, c.cx * dpr, cy * dpr);
const sw = stamps[i].width / dpr, sh = stamps[i].height / dpr;
ctx.drawImage(stamps[i], -sw / 2, -sh / 2, sw, sh);
}
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.globalAlpha = 1;
ctx.drawImage(vignette, 0, 0, cw, ch);
}
let fps = 0, fpsFrames = 0, fpsLast = 0;
function tickFps(now: number) {
fpsFrames++;
if (now - fpsLast >= 500) {
fps = Math.round(fpsFrames / ((now - fpsLast) / 1000));
fpsFrames = 0;
fpsLast = now;
if (fpsEl) fpsEl.textContent = `${fps} fps`;
}
}
return () => clearInterval(stopDots)
})
function mountCanvas(el: HTMLCanvasElement) {
const ctx = el.getContext("2d")!;
let live: RenderState | null = null;
let lastLogW = 0, lastLogH = 0, lastScale = 0, buildGen = 0;
async function syncSize() {
const gen = ++buildGen;
const scale = window.devicePixelRatio || 1;
const logW = el.offsetWidth || el.parentElement?.offsetWidth || 800;
const logH = el.offsetHeight || el.parentElement?.offsetHeight || 600;
const phys = { width: Math.round(logW * scale), height: Math.round(logH * scale) };
if (gen !== buildGen) return;
if (logW === lastLogW && logH === lastLogH && scale === lastScale) return;
lastLogW = logW; lastLogH = logH; lastScale = scale;
const built = buildCards(logW, logH);
const stamps = built.cards.map(c => buildStamp(c, scale));
const vig = buildVignette(logW, logH, scale);
el.width = phys.width; el.height = phys.height;
live = { cards: built.cards, trigs: built.trigs, stamps, vignette: vig, CW: phys.width, CH: phys.height, scale };
}
const ro = new ResizeObserver(() => syncSize());
ro.observe(el);
syncSize();
let raf = 0, t0 = -1, paused = false;
function frame(now: number) {
if (paused) { raf = 0; return; }
raf = requestAnimationFrame(frame);
if (!live) return;
if (t0 < 0) t0 = now;
if (showFps) tickFps(now);
const { cards, trigs, stamps, vignette, CW, CH, scale } = live;
drawFrame(ctx, (now - t0) / 1000, CW, CH, scale, cards, trigs, stamps, vignette);
}
function pause() { paused = true; t0 = -1; }
function resume() { if (!paused) return; paused = false; raf = requestAnimationFrame(frame); }
function onVisibility() { document.hidden ? pause() : resume(); }
document.addEventListener("visibilitychange", onVisibility);
let unlistenFocus: Promise<() => void> | null = null;
try {
const win = getCurrentWindow();
unlistenFocus = win.onFocusChanged(({ payload: focused }) => {
focused ? resume() : pause();
});
} catch { }
raf = requestAnimationFrame(frame);
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
document.removeEventListener("visibilitychange", onVisibility);
unlistenFocus?.then(f => f());
};
}
</script>
<div class="splash" class:exiting>
<div class="splash" class:exiting style="cursor: {mode === 'idle' && !lockEnabled ? 'pointer' : 'default'}">
{#if showCards}
<canvas
style="position:absolute;inset:0;pointer-events:none;width:100%;height:100%"
use:mountCardCanvas
></canvas>
<canvas style="position:absolute;inset:0;pointer-events:none;width:100%;height:100%" use:mountCanvas></canvas>
{#if showFps}
<span bind:this={fpsEl} style="position:absolute;top:8px;right:8px;font-family:var(--font-ui);font-size:10px;color:var(--text-faint);z-index:2;pointer-events:none"></span>
{/if}
{/if}
{#if mode === 'idle'}
<div class="center">
<div class="logo-wrap" style="width:{LOGO_IDLE}px;height:{LOGO_IDLE}px;margin-bottom:32px">
{#if mode === "idle" && lockEnabled}
<div style="z-index:1;display:flex;flex-direction:column;align-items:center;gap:var(--sp-6)">
<div style="position:relative;width:{logoLockSize}px;height:{logoLockSize}px">
<div class="logo-glow"></div>
<img src={logoUrl} alt="Moku" class="logo-breathe" style="width:{LOGO_IDLE}px;height:{LOGO_IDLE}px;border-radius:28px" />
<img src={logoUrl} alt="Moku" class="logo-breathe" style="width:{logoLockSize}px;height:{logoLockSize}px;border-radius:22px;display:block;position:relative" />
</div>
<div class="pin-card">
<p class="pin-label">Enter PIN</p>
<div class="pin-block">
<div class="pin-dots" class:pin-shake={pinShake}>
{#each Array(settingsState.settings.appLockPin?.length ?? 4) as _, i}
<div class="pin-dot" class:pin-dot-filled={i < pinEntry.length}></div>
{/each}
</div>
<button class="pin-submit-btn" onclick={submitPin} tabindex="-1" aria-label="Submit PIN">Unlock</button>
</div>
</div>
</div>
{:else if mode === "idle"}
<div style="z-index:1;display:flex;flex-direction:column;align-items:center">
<div style="position:relative;width:{logoIdleSize}px;height:{logoIdleSize}px;margin-bottom:32px">
<div class="logo-glow"></div>
<img src={logoUrl} alt="Moku" class="logo-breathe" style="width:{logoIdleSize}px;height:{logoIdleSize}px;border-radius:28px;display:block;position:relative" />
</div>
<p class="hint">press any key to continue</p>
</div>
@@ -138,83 +417,91 @@
{:else}
<div style="position:relative;width:{ringSize}px;height:{ringSize}px;margin-bottom:20px;display:flex;align-items:center;justify-content:center">
{#if !failed && !notConfigured}
<svg
width={ringSize}
height={ringSize}
class="ring"
class:ring-hide={pinVisible}
style="position:absolute;top:0;left:0;pointer-events:none"
>
<circle cx={ringC} cy={ringC} r={RING_R} fill="none" stroke="var(--border-base)" stroke-width="2"/>
<circle cx={ringC} cy={ringC} r={RING_R} fill="none" stroke="var(--accent)" stroke-width="2"
<svg width={ringSize} height={ringSize}
class="loading-ring"
class:ring-hide={lockEnabled && pinVisible}
style="position:absolute;top:0;left:0;pointer-events:none">
<circle cx={ringC} cy={ringC} r={ringR} fill="none" stroke="var(--border-base)" stroke-width="2" />
<circle cx={ringC} cy={ringC} r={ringR} fill="none" stroke="var(--accent)" stroke-width="2"
stroke-linecap="round"
stroke-dasharray="{ringArc} {ringCirc}"
transform="rotate(-90 {ringC} {ringC})"
style="transition: stroke-dasharray 0.4s cubic-bezier(0.4,0,0.2,1)"
/>
style="transition: stroke-dasharray 0.4s cubic-bezier(0.4,0,0.2,1)" />
</svg>
{/if}
<img src={logoUrl} alt="Moku" style="width:{LOGO_LOADING}px;height:{LOGO_LOADING}px;border-radius:32px;display:block;position:relative"/>
<img src={logoUrl} alt="Moku" style="width:{logoLoadingSize}px;height:{logoLoadingSize}px;border-radius:32px;display:block;position:relative" />
</div>
<div class="bottom-area">
<div class="status-slot" class:status-slot-hide={pinVisible}>
<div class="bottom-area" style="z-index:1">
<div class="status-slot" class:status-slot-hide={lockEnabled && pinVisible}>
{#if failed || notConfigured}
<div class="error-box anim-fade-up">
<p class="error-label">{failed ? 'Could not reach server' : 'Server not configured'}</p>
<p class="error-label">{failed ? "Could not reach server" : "Server not configured"}</p>
<div class="error-actions">
<button class="err-btn" onclick={() => onRetry?.()}>Retry</button>
<button class="err-btn err-btn--primary" onclick={() => onBypass?.()}>Enter app</button>
</div>
</div>
{:else}
<p class="status-text">{ringFull ? '' : `Initializing server${dots}`}</p>
<p class="status-text">{ringFull ? "" : `Initializing server${dots}`}</p>
{/if}
</div>
{#if lockEnabled}
<div class="pin-slot" class:pin-slot-visible={pinVisible}>
<div class="pin-card">
<p class="pin-label">Enter PIN</p>
<div class="pin-block">
<div class="pin-dots" class:pin-shake={pinShake}>
{#each Array(settingsState.settings.appLockPin?.length ?? 4) as _, i}
<div class="pin-dot" class:pin-dot-filled={i < pinEntry.length}></div>
{/each}
</div>
<button class="pin-submit-btn" onclick={submitPin} tabindex="-1" aria-label="Submit PIN">Unlock</button>
</div>
</div>
</div>
{/if}
</div>
{/if}
</div>
<style>
.splash {
position: fixed;
inset: 0;
z-index: 9999;
background: var(--bg-base);
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
animation: spIn 0.35s cubic-bezier(0,0,0.2,1) both;
}
.splash { position: fixed; inset: 0; z-index: 9999; background: var(--bg-base); overflow: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; animation: spIn 0.35s cubic-bezier(0,0,0.2,1) both; }
.splash.exiting { animation: spOut 320ms cubic-bezier(0.4,0,1,1) both; }
@keyframes spIn { from { opacity:0; transform:scale(1.015) } to { opacity:1; transform:scale(1) } }
@keyframes spOut { from { opacity:1; transform:scale(1) } to { opacity:0; transform:scale(0.96) } }
@keyframes logoBreathe { 0%,100% { transform:scale(1); filter:drop-shadow(0 0 0px rgba(255,255,255,0)) } 50% { transform:scale(1.04); filter:drop-shadow(0 0 18px rgba(255,255,255,0.12)) } }
@keyframes hintFade { 0%,100% { opacity:0.35 } 50% { opacity:0.7 } }
@keyframes pinShake { 0%,100% { transform:translateX(0) } 20%,60% { transform:translateX(-6px) } 40%,80% { transform:translateX(6px) } }
.center { z-index:1; display:flex; flex-direction:column; align-items:center; }
.logo-glow { position: absolute; inset: -20px; border-radius: 50%; background: radial-gradient(circle, rgba(255,255,255,0.06) 0%, transparent 70%); animation: logoBreathe 4s ease-in-out infinite; }
.logo-breathe { animation: logoBreathe 4s ease-in-out infinite; }
.hint { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: 0.22em; text-transform: uppercase; margin: 0; user-select: none; animation: hintFade 3.5s ease-in-out infinite; }
.logo-wrap { position:relative; }
.logo-glow { position:absolute; inset:-20px; border-radius:50%; background:radial-gradient(circle, rgba(255,255,255,0.06) 0%, transparent 70%); animation:logoBreathe 4s ease-in-out infinite; }
.logo-breathe { animation:logoBreathe 4s ease-in-out infinite; display:block; position:relative; }
.hint { font-family:var(--font-ui); font-size:10px; color:var(--text-faint); letter-spacing:0.22em; text-transform:uppercase; margin:0; user-select:none; animation:hintFade 3.5s ease-in-out infinite; }
.error-box { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 16px 20px; border-radius: var(--radius-lg); background: var(--bg-surface); border: 1px solid var(--border-base); min-width: 200px; text-align: center; }
.error-label { font-family: var(--font-ui); font-size: 11px; font-weight: 500; color: var(--text-muted); letter-spacing: 0.06em; margin: 0; }
.error-actions { display: flex; gap: 6px; }
.err-btn { padding: 5px 14px; border-radius: var(--radius-md); border: 1px solid var(--border-base); background: transparent; color: var(--text-muted); cursor: pointer; font-family: var(--font-ui); font-size: 11px; letter-spacing: 0.04em; transition: border-color 0.15s, color 0.15s; }
.err-btn:hover { border-color: var(--border-strong); color: var(--text-secondary); }
.err-btn--primary { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
.err-btn--primary:hover { border-color: var(--accent); color: var(--accent-bright); }
.ring { transition:opacity 0.5s ease; }
.ring-hide { opacity:0; }
.bottom-area { display: flex; align-items: center; justify-content: center; min-height: 48px; position: relative; }
.status-slot { display: flex; align-items: center; justify-content: center; transition: opacity 0.35s ease; position: absolute; }
.status-slot-hide { opacity: 0; pointer-events: none; }
.status-text { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: 0.12em; margin: 0; min-width: 160px; text-align: center; }
.loading-ring { transition: opacity 0.5s ease; }
.ring-hide { opacity: 0; }
.bottom-area { display:flex; align-items:center; justify-content:center; min-height:48px; position:relative; }
.status-slot { display:flex; align-items:center; justify-content:center; transition:opacity 0.35s ease; position:absolute; }
.status-slot-hide { opacity:0; pointer-events:none; }
.status-text { font-family:var(--font-ui); font-size:10px; color:var(--text-faint); letter-spacing:0.12em; margin:0; min-width:160px; text-align:center; }
.error-box { display:flex; flex-direction:column; align-items:center; gap:12px; padding:16px 20px; border-radius:var(--radius-lg); background:var(--bg-surface); border:1px solid var(--border-base); min-width:200px; text-align:center; }
.error-label { font-family:var(--font-ui); font-size:11px; font-weight:500; color:var(--text-muted); letter-spacing:0.06em; margin:0; }
.error-actions { display:flex; gap:6px; }
.err-btn { padding:5px 14px; border-radius:var(--radius-md); border:1px solid var(--border-base); background:transparent; color:var(--text-muted); cursor:pointer; font-family:var(--font-ui); font-size:11px; letter-spacing:0.04em; transition:border-color 0.15s, color 0.15s; }
.err-btn:hover { border-color:var(--border-strong); color:var(--text-secondary); }
.err-btn--primary { border-color:var(--accent-dim); color:var(--accent-fg); background:var(--accent-muted); }
.err-btn--primary:hover { border-color:var(--accent); color:var(--accent-bright); }
.pin-slot { display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); position: absolute; opacity: 0; transform: translateY(4px); transition: opacity 0.4s ease, transform 0.4s ease; pointer-events: none; }
.pin-slot-visible { opacity: 1; transform: translateY(0); pointer-events: auto; }
.pin-card { background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); padding: var(--sp-5) var(--sp-6); display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); box-shadow: 0 24px 60px rgba(0,0,0,0.6); }
.pin-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; margin: 0; }
.pin-block { display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); position: relative; }
.pin-dots { display: flex; gap: 12px; align-items: center; }
.pin-dot { width: 10px; height: 10px; border-radius: 50%; border: 1px solid var(--border-strong); background: transparent; transition: background 0.12s, border-color 0.12s; }
.pin-dot-filled { background: var(--accent); border-color: var(--accent); }
.pin-shake { animation: pinShake 0.42s ease; }
.pin-submit-btn { opacity: 0; width: 1px; height: 1px; overflow: hidden; padding: 0; border: none; background: none; cursor: pointer; pointer-events: auto; position: absolute; }
</style>
+175 -78
View File
@@ -1,62 +1,79 @@
<script lang="ts">
import { onMount } from 'svelte'
import { detectOs } from '$lib/components/chrome/titlebarOs'
import type { OsKind } from '$lib/components/chrome/titlebarOs'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { platform } from '@tauri-apps/plugin-os'
import { invoke } from '@tauri-apps/api/core'
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
let { onClose }: { onClose: () => void } = $props()
const { }: {} = $props()
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
const win = getCurrentWindow()
const os = platform()
const isMac = os === 'macos'
const isWindows = os === 'windows'
let os: OsKind = $state('unknown')
let isFullscreen = $state(false)
let closeDialogOpen = $state(false)
let closeRemember = $state(false)
onMount(async () => {
if (!isTauri) return
const { getCurrentWindow } = await import('@tauri-apps/api/window')
const win = getCurrentWindow()
os = await detectOs()
isFullscreen = await win.isFullscreen()
const unlisten = await win.onResized(async () => {
const unlistenResize = await win.onResized(async () => {
isFullscreen = await win.isFullscreen()
})
return unlisten
const unlistenClose = await win.listen('tauri://close-requested', handleCloseRequested)
return () => {
unlistenResize()
unlistenClose()
}
})
const isMac = $derived(os === 'macos')
const isWindows = $derived(os === 'windows')
async function minimize() {
const { getCurrentWindow } = await import('@tauri-apps/api/window')
getCurrentWindow().minimize()
async function doQuit() {
if (settingsState.settings.autoStartServer) {
await Promise.race([
invoke('kill_server').catch(() => {}),
new Promise(res => setTimeout(res, 2000)),
])
}
await invoke('exit_app')
}
async function toggleMaximize() {
const { getCurrentWindow } = await import('@tauri-apps/api/window')
getCurrentWindow().toggleMaximize()
async function doHide() {
await win.hide()
}
async function exitFullscreen() {
const { getCurrentWindow } = await import('@tauri-apps/api/window')
getCurrentWindow().setFullscreen(false)
async function handleCloseRequested() {
const action = settingsState.settings.closeAction ?? 'ask'
if (action === 'tray') { await doHide(); return }
if (action === 'quit') { await doQuit(); return }
closeDialogOpen = true
}
async function confirmClose(choice: 'tray' | 'quit') {
closeDialogOpen = false
if (closeRemember) updateSettings({ closeAction: choice })
closeRemember = false
if (choice === 'tray') await doHide()
else await doQuit()
}
</script>
{#if !isFullscreen}
<div class="bar" data-tauri-drag-region>
{#if isMac}<div class="mac-spacer" data-tauri-drag-region></div>{/if}
{#if isMac}<div class="mac-spacer"></div>{/if}
<span class="title" data-tauri-drag-region>Moku</span>
{#if !isMac}
<div class="controls">
<button onclick={minimize} title="Minimize" aria-label="Minimize">
<svg width="10" height="1" viewBox="0 0 10 1"><line x1="0" y1="0.5" x2="10" y2="0.5" stroke="currentColor" stroke-width="1.5"/></svg>
<button onclick={() => win.minimize()} title="Minimize" aria-label="Minimize">
<svg width="10" height="1" viewBox="0 0 10 1"><line x1="0" y1="0.5" x2="10" y2="0.5" stroke="currentColor" stroke-width="1.5" /></svg>
</button>
<button onclick={toggleMaximize} title="Maximize" aria-label="Maximize">
<svg width="9" height="9" viewBox="0 0 9 9"><rect x="0.75" y="0.75" width="7.5" height="7.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
<button onclick={() => win.toggleMaximize()} title="Maximize" aria-label="Maximize">
<svg width="9" height="9" viewBox="0 0 9 9"><rect x="0.75" y="0.75" width="7.5" height="7.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.5" /></svg>
</button>
<button class="close" onclick={onClose} title="Close" aria-label="Close">
<button class="close" onclick={handleCloseRequested} title="Close" aria-label="Close">
<svg width="10" height="10" viewBox="0 0 10 10">
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
</button>
</div>
@@ -64,7 +81,7 @@
</div>
{:else if isWindows}
<div class="fullscreen-controls">
<button onclick={exitFullscreen} title="Exit Fullscreen" aria-label="Exit Fullscreen">
<button onclick={() => win.setFullscreen(false)} title="Exit Fullscreen" aria-label="Exit Fullscreen">
<svg width="10" height="10" viewBox="0 0 10 10">
<polyline points="1,4 1,1 4,1" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="6,1 9,1 9,4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
@@ -72,63 +89,143 @@
<polyline points="4,9 1,9 1,6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<button class="close" onclick={onClose} title="Close" aria-label="Close">
<button class="close" onclick={handleCloseRequested} title="Close" aria-label="Close">
<svg width="10" height="10" viewBox="0 0 10 10">
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
</svg>
</button>
</div>
{/if}
{#if closeDialogOpen}
<div class="close-backdrop" role="presentation" onclick={() => { closeDialogOpen = false; closeRemember = false; }}>
<div class="close-dialog" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
<div class="close-header">
<p class="close-title">Close Moku?</p>
<p class="close-sub">Choose how the app should exit.</p>
</div>
<div class="close-actions">
<button class="close-btn" onclick={() => confirmClose('tray')}>
<span class="close-btn-label">Minimize to Tray</span>
<span class="close-btn-desc">Keep running in the background</span>
</button>
<button class="close-btn close-btn-danger" onclick={() => confirmClose('quit')}>
<span class="close-btn-label">Quit</span>
<span class="close-btn-desc">Stop Moku entirely</span>
</button>
</div>
<button class="close-remember" onclick={() => closeRemember = !closeRemember}>
<span class="close-remember-toggle" class:on={closeRemember}><span class="close-remember-thumb"></span></span>
<span class="close-remember-label">Remember my choice</span>
</button>
</div>
</div>
{/if}
<style>
.bar {
display: flex;
align-items: center;
justify-content: space-between;
height: var(--titlebar-height);
padding: 0 6px 0 var(--sp-4);
background: transparent;
flex-shrink: 0;
user-select: none;
}
.bar { display: flex; align-items: center; justify-content: space-between; height: var(--titlebar-height); padding: 0 6px 0 var(--sp-4); background: transparent; flex-shrink: 0; user-select: none; -webkit-app-region: drag; }
.mac-spacer { width: 70px; flex-shrink: 0; -webkit-app-region: drag; }
.title { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; opacity: 0.5; -webkit-app-region: drag; }
.controls { display: flex; align-items: center; gap: 2px; -webkit-app-region: no-drag; }
.mac-spacer { width: 70px; flex-shrink: 0; }
.title {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
opacity: 0.5;
}
.controls { display: flex; align-items: center; gap: 2px; }
button {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: var(--radius-sm);
.controls button,
.fullscreen-controls button {
display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px; border-radius: var(--radius-sm);
color: var(--text-faint);
transition: color var(--t-base), background var(--t-base);
-webkit-app-region: no-drag;
}
button:hover { color: var(--text-muted); background: rgba(255,255,255,0.06); }
.close:hover { color: #fff; background: #c0392b; }
.controls button:hover,
.fullscreen-controls button:hover { color: var(--text-muted); background: rgba(255,255,255,0.06); }
.controls .close:hover,
.fullscreen-controls .close:hover { color: #fff; background: #c0392b; }
.fullscreen-controls {
position: fixed;
top: 0;
right: 0;
z-index: 9999;
display: flex;
align-items: center;
gap: 2px;
padding: 4px;
opacity: 0;
transition: opacity var(--t-base);
}
.fullscreen-controls { position: fixed; top: 0; right: 0; z-index: 9999; display: flex; align-items: center; gap: 2px; padding: 4px; opacity: 0; transition: opacity 0.2s ease; -webkit-app-region: no-drag; }
.fullscreen-controls:hover { opacity: 1; }
.close-backdrop {
position: fixed; inset: 0;
background: rgba(0,0,0,0.5);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
display: flex; align-items: center; justify-content: center;
z-index: var(--z-modal);
animation: cdFade 0.18s ease both;
}
@keyframes cdFade { from { opacity: 0 } to { opacity: 1 } }
.close-dialog {
font-family: var(--font-ui);
background: var(--bg-surface);
border: 1px solid var(--border-base);
border-radius: var(--radius-2xl);
padding: var(--sp-5);
display: flex; flex-direction: column; gap: var(--sp-3);
width: 300px;
box-shadow:
0 0 0 1px rgba(255,255,255,0.04) inset,
0 24px 64px rgba(0,0,0,0.7),
0 8px 24px rgba(0,0,0,0.4);
animation: cdPop 0.22s cubic-bezier(0.16,1,0.3,1) both;
}
@keyframes cdPop { from { opacity: 0; transform: scale(0.96) translateY(6px) } to { opacity: 1; transform: none } }
.close-header { display: flex; flex-direction: column; gap: 3px; }
.close-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); margin: 0; }
.close-sub { font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin: 0; }
.close-actions { display: flex; flex-direction: column; gap: var(--sp-1); }
.close-btn {
display: flex; flex-direction: column; align-items: flex-start; gap: 3px;
width: 100%; padding: 10px var(--sp-3);
border-radius: var(--radius-lg);
border: 1px solid var(--border-dim);
background: var(--bg-raised);
cursor: pointer; text-align: left;
font-family: var(--font-ui);
transition: background var(--t-base), border-color var(--t-base), transform 80ms ease;
}
.close-btn:hover { background: var(--bg-overlay); border-color: var(--border-strong); }
.close-btn:active { transform: scale(0.985); }
.close-btn-danger { border-color: color-mix(in srgb, var(--color-error) 28%, transparent); }
.close-btn-danger:hover { background: var(--color-error-bg); border-color: color-mix(in srgb, var(--color-error) 55%, transparent); }
.close-btn-danger .close-btn-label { color: var(--color-error); }
.close-btn-danger .close-btn-desc { color: color-mix(in srgb, var(--color-error) 50%, var(--text-faint)); }
.close-btn-label { font-size: var(--text-sm); color: var(--text-secondary); font-weight: var(--weight-medium); line-height: 1.2; }
.close-btn-desc { font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: 1.2; }
.close-remember {
display: flex; align-items: center; gap: var(--sp-2);
width: 100%; padding: var(--sp-3) 0 0;
border-top: 1px solid var(--border-dim);
background: none; border-left: none; border-right: none; border-bottom: none;
cursor: pointer; user-select: none;
font-family: var(--font-ui);
}
.close-remember:hover .close-remember-label { color: var(--text-muted); }
.close-remember-toggle {
position: relative; flex-shrink: 0;
width: 28px; height: 16px;
border-radius: var(--radius-full);
border: 1px solid var(--border-strong);
background: var(--bg-overlay);
transition: background var(--t-base), border-color var(--t-base);
}
.close-remember-toggle.on { background: var(--accent); border-color: var(--accent); }
.close-remember-thumb {
position: absolute; top: 1px; left: 1px;
width: 12px; height: 12px; border-radius: 50%;
background: var(--text-faint);
transition: transform var(--t-base), background var(--t-base);
}
.close-remember-toggle.on .close-remember-thumb { transform: translateX(12px); background: #fff; }
.close-remember-label { font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); transition: color var(--t-base); }
</style>
+16 -16
View File
@@ -1,26 +1,26 @@
<script lang="ts">
import { Play, ArrowRight, BookOpen, Clock } from 'phosphor-svelte'
import { timeAgo } from '$lib/components/home/homeHelpers'
import type { Manga } from '$lib/types'
import type { HistoryEntry } from '$lib/state/home.svelte'
import { timeAgo } from '$lib/components/home/lib/homeHelpers'
import Thumbnail from '$lib/components/shared/manga/Thumbnail.svelte'
import { historyState } from '$lib/state/history.svelte'
import type { ReadSession } from '$lib/types/history'
let {
entries,
libraryManga,
onresume,
onviewhistory,
onopenlibrary,
}: {
entries: HistoryEntry[]
libraryManga: Manga[]
onresume: (entry: HistoryEntry) => void
onresume: (session: ReadSession) => void
onviewhistory: () => void
onopenlibrary: () => void
} = $props()
function thumbFor(entry: HistoryEntry): string {
return libraryManga.find(m => m.id === entry.mangaId)?.thumbnailUrl ?? entry.thumbnailUrl ?? ''
}
// Deduplicate by manga — keep the most recent session per manga (sessions are newest-first)
const entries = $derived(
historyState.sessions
.filter((s, i, arr) => arr.findIndex(x => x.mangaId === s.mangaId) === i)
.slice(0, 10)
)
</script>
<div class="section">
@@ -35,16 +35,16 @@
<div class="list">
{#if entries.length > 0}
{#each entries as entry (entry.chapterId)}
{#each entries as entry (entry.id)}
<button class="row" onclick={() => onresume(entry)}>
<img src={thumbFor(entry)} alt={entry.mangaTitle} class="row-thumb" />
<Thumbnail src={entry.thumbnailUrl} alt={entry.mangaTitle} class="row-thumb" />
<div class="row-info">
<span class="row-title">{entry.mangaTitle}</span>
<span class="row-sub">
{entry.chapterName}{entry.pageNumber > 1 ? ` · p.${entry.pageNumber}` : ''}
{entry.endChapterName}{entry.endPage > 1 ? ` · p.${entry.endPage}` : ''}
</span>
</div>
<span class="row-time">{timeAgo(entry.readAt)}</span>
<span class="row-time" title={new Date(entry.endedAt).toLocaleString()}>{timeAgo(entry.endedAt)}</span>
<span class="row-play"><Play size={10} weight="fill" /></span>
</button>
{/each}
@@ -103,7 +103,7 @@
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.row:hover .row-play { opacity: 1; }
.row-thumb {
:global(.row-thumb) {
width: 33px; height: 48px; border-radius: var(--radius-sm);
object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim);
}
+18 -7
View File
@@ -13,17 +13,30 @@
return 4
}
let tip: { text: string; x: number; y: number } | null = $state(null)
let tipEl: HTMLDivElement | null = null
function showTip(e: MouseEvent, cell: { dateStr: string; count: number }) {
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
const label = cell.count === 0
? `No chapters — ${fmtDate(cell.dateStr)}`
: `${cell.count} chapter${cell.count !== 1 ? 's' : ''}${fmtDate(cell.dateStr)}`
tip = { text: label, x: rect.left + rect.width / 2, y: rect.top - 6 }
if (!tipEl) {
tipEl = document.createElement('div')
tipEl.className = 'moku-heatmap-tip'
document.body.appendChild(tipEl)
}
tipEl.textContent = label
const zoom = parseFloat(document.documentElement.style.zoom) || 1
tipEl.style.left = `${(rect.left + rect.width / 2) / zoom}px`
tipEl.style.top = `${(rect.top - 6) / zoom}px`
tipEl.style.display = 'block'
}
function hideTip() { tip = null }
function hideTip() {
if (tipEl) tipEl.style.display = 'none'
}
$effect(() => () => { tipEl?.remove(); tipEl = null })
function fmtDate(d: string): string {
return new Date(d + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
@@ -141,9 +154,6 @@
</div>
</div>
{#if tip}
<div class="heatmap-tip" style="left:{tip.x}px; top:{tip.y}px;">{tip.text}</div>
{/if}
<style>
.heatmap-wrap {
@@ -196,12 +206,13 @@
.legend-cell { width: 10px; height: 10px; border-radius: 3px; flex-shrink: 0; }
.legend-label { font-family: var(--font-ui); font-size: 9px; color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.heatmap-tip {
:global(.moku-heatmap-tip) {
position: fixed; transform: translate(-50%, -100%);
background: var(--bg-overlay); border: 1px solid var(--border-base);
border-radius: var(--radius-sm); padding: 4px 8px;
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-secondary);
letter-spacing: var(--tracking-wide); white-space: nowrap; pointer-events: none;
z-index: 9999; box-shadow: 0 4px 16px rgba(0,0,0,0.5);
display: none;
}
</style>
@@ -1,5 +1,6 @@
<script lang="ts">
import { MagnifyingGlass, X as XIcon } from 'phosphor-svelte'
import Thumbnail from '$lib/components/shared/manga/Thumbnail.svelte'
import type { Manga } from '$lib/types'
let {
@@ -50,7 +51,7 @@
{:else}
{#each results as m (m.id)}
<button class="list-row" onclick={() => onpin(m)}>
<img src={m.thumbnailUrl} alt={m.title} class="row-thumb" />
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="row-thumb" />
<div class="row-info">
<span class="row-title">{m.title}</span>
{#if m.source?.displayName}<span class="row-source">{m.source.displayName}</span>{/if}
@@ -115,7 +116,7 @@
transition: background var(--t-fast);
}
.list-row:hover { background: var(--bg-raised); }
.row-thumb {
:global(.row-thumb) {
height: 50px; width: 35px; aspect-ratio: 1/1.42;
border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0;
border: 1px solid var(--border-dim); background: var(--bg-raised); display: block;
+11 -10
View File
@@ -1,14 +1,15 @@
<script lang="ts">
import { Play, ArrowRight, ArrowLeft, BookOpen, Clock, ListBullets, PushPin, X as XIcon } from 'phosphor-svelte'
import { goto } from '$app/navigation'
import { timeAgo } from '$lib/components/home/homeHelpers'
import { timeAgo } from '$lib/components/home/lib/homeHelpers'
import Thumbnail from '$lib/components/shared/manga/Thumbnail.svelte'
import type { Manga } from '$lib/types'
import type { Chapter } from '$lib/types'
import type { HistoryEntry } from '$lib/state/home.svelte'
import type { ReadSession } from '$lib/types/history'
interface HeroSlot {
kind: 'continue' | 'pinned' | 'empty'
entry?: HistoryEntry
entry?: ReadSession
manga?: Manga
slotIndex: number
}
@@ -39,7 +40,7 @@
heroThumb: string
heroTitle: string
heroManga: Manga | null | undefined
heroEntry: HistoryEntry | null
heroEntry: ReadSession | null
heroMangaId: number | null
heroChapters: Chapter[]
heroNewChapter: Chapter | null
@@ -76,7 +77,7 @@
aria-label={heroTitle ? `Resume ${heroTitle}` : 'No manga selected'}
>
{#if heroThumb}
<img src={heroThumb} alt={heroTitle} class="hero-cover" loading="eager" decoding="async" />
<Thumbnail src={heroThumb} alt={heroTitle} class="hero-cover" />
{#if activeSlot?.kind === 'continue'}
<div class="cover-resume-hint"><Play size={20} weight="fill" /></div>
{/if}
@@ -122,9 +123,9 @@
{#if heroEntry}
<p class="hero-progress">
<Clock size={10} weight="light" />
{heroEntry.chapterName}
{#if heroEntry.pageNumber > 1}<span class="hero-prog-page"> · p.{heroEntry.pageNumber}</span>{/if}
<span class="hero-prog-time">{timeAgo(heroEntry.readAt)}</span>
{heroEntry.endChapterName}
{#if heroEntry.endPage > 1}<span class="hero-prog-page"> · p.{heroEntry.endPage}</span>{/if}
<span class="hero-prog-time">{timeAgo(heroEntry.endedAt)}</span>
</p>
{/if}
@@ -277,9 +278,9 @@
border: none;
border-right: 1px solid rgba(255,255,255,0.07);
}
.hero-cover-col:hover .hero-cover { filter: brightness(1.1) saturate(1.05); }
.hero-cover-col:hover :global(.hero-cover) { filter: brightness(1.1) saturate(1.05); }
.hero-cover-col:hover .cover-resume-hint { opacity: 1; }
.hero-cover { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.22s ease; }
:global(.hero-cover) { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.22s ease; }
.hero-cover-empty {
width: 100%; height: 100%;
display: flex; align-items: center; justify-content: center;
+257
View File
@@ -0,0 +1,257 @@
<script lang="ts">
import { onMount, untrack } from 'svelte'
import { goto } from '$app/navigation'
import { loadLibrary } from '$lib/request-manager/manga'
import { getAdapter } from '$lib/request-manager'
import { libraryState } from '$lib/state/library.svelte'
import { homeState, setHeroSlot } from '$lib/state/home.svelte'
import { historyState } from '$lib/state/history.svelte'
import type { ReadSession } from '$lib/types/history'
import HeroStage from '$lib/components/home/HeroStage.svelte'
import HeroSlotPicker from '$lib/components/home/HeroSlotPicker.svelte'
import ActivityFeed from '$lib/components/home/ActivityFeed.svelte'
import ActivityHeatmap from '$lib/components/home/ActivityHeatmap.svelte'
import RecsRow from '$lib/components/home/RecsRow.svelte'
import StatsGrid from '$lib/components/home/StatsGrid.svelte'
import type { Manga, Chapter } from '$lib/types'
const TOTAL_SLOTS = 4
interface HeroSlot {
kind: 'continue' | 'pinned' | 'empty'
entry?: ReadSession
manga?: Manga
slotIndex: number
}
onMount(() => { loadLibrary() })
const manga = $derived(libraryState.items)
const continueReading = $derived((() => {
const seen = new Set<number>()
const out: ReadSession[] = []
for (const e of historyState.sessions) {
if (seen.has(e.mangaId)) continue
seen.add(e.mangaId)
out.push(e)
if (out.length >= 10) break
}
return out
})())
const resolvedSlots = $derived((() => {
const pins = homeState.heroSlots
const slots: HeroSlot[] = []
const first = continueReading[0]
slots.push(first ? { kind: 'continue', entry: first, slotIndex: 0 } : { kind: 'empty', slotIndex: 0 })
let hi = 1
for (let i = 1; i < TOTAL_SLOTS; i++) {
const pinId = pins[i]
if (pinId != null) {
const m = manga.find(m => m.id === pinId)
if (m) { slots.push({ kind: 'pinned', manga: m, slotIndex: i }); continue }
}
const entry = continueReading[hi++]
slots.push(entry ? { kind: 'continue', entry, slotIndex: i } : { kind: 'empty', slotIndex: i })
}
return slots
})())
let activeIdx = $state(0)
const activeSlot = $derived(resolvedSlots[activeIdx])
const heroManga = $derived(
activeSlot?.kind === 'pinned' ? activeSlot.manga :
activeSlot?.kind === 'continue' ? manga.find(m => m.id === activeSlot.entry?.mangaId) : null
)
const heroEntry = $derived(activeSlot?.kind === 'continue' ? activeSlot.entry ?? null : null)
const heroMangaId = $derived(heroManga?.id ?? heroEntry?.mangaId ?? null)
const heroTitle = $derived(heroManga?.title ?? heroEntry?.mangaTitle ?? '')
const heroThumbSrc = $derived(
heroManga?.thumbnailUrl ??
(activeSlot?.kind === 'continue' ? activeSlot.entry?.thumbnailUrl : undefined) ??
''
)
let heroThumb = $state('')
$effect(() => {
const path = heroThumbSrc
if (!path) { heroThumb = ''; return }
heroThumb = path
})
const heroNewChapter = $derived(
heroManga ? (manga.find(m => m.id === heroManga!.id) as any)?.latestUploadedChapter ?? null : null
)
let heroChapters: Chapter[] = $state([])
let heroAllChapters: Chapter[] = $state([])
let loadingHeroChapters = $state(false)
let heroChaptersFor: number | null = null
$effect(() => {
const id = heroMangaId
if (id) untrack(() => loadHeroChapters(id))
})
async function loadHeroChapters(mangaId: number) {
heroChaptersFor = mangaId
loadingHeroChapters = true
heroChapters = []
heroAllChapters = []
try {
const chapters = await getAdapter().getChapters(String(mangaId))
if (heroChaptersFor !== mangaId) return
const all = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder)
heroAllChapters = all
const lastReadIdx = heroEntry
? all.findLastIndex(c => c.id === heroEntry!.endChapterId)
: all.findLastIndex(c => c.isRead)
const startIdx = Math.max(0, lastReadIdx)
heroChapters = all.slice(startIdx, startIdx + 5)
} catch {
heroChapters = []
heroAllChapters = []
} finally {
loadingHeroChapters = false
}
}
let resuming = $state(false)
async function openChapter(chapter: Chapter) {
if (!heroMangaId) return
goto(`/reader/${heroMangaId}/${chapter.id}`)
}
async function resumeActive() {
if (!heroEntry && heroManga) { goto(`/series/${heroManga.id}`); return }
if (!heroEntry) return
const target = heroAllChapters.find(c => c.id === heroEntry!.endChapterId) ?? heroAllChapters[0]
if (target) openChapter(target)
}
function cycleNext() { activeIdx = (activeIdx + 1) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = [] }
function cyclePrev() { activeIdx = (activeIdx - 1 + TOTAL_SLOTS) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = [] }
function goToSlot(i: number) { if (i !== activeIdx) { activeIdx = i; heroChapters = []; heroAllChapters = [] } }
let pickerOpen = $state(false)
let pickerSlotIndex: 1 | 2 | 3 | null = $state(null)
function openPicker(i: 1 | 2 | 3) { pickerSlotIndex = i; pickerOpen = true }
function closePicker() { pickerOpen = false; pickerSlotIndex = null }
function pinManga(m: Manga) { if (pickerSlotIndex !== null) { setHeroSlot(pickerSlotIndex, m.id); closePicker() } }
function unpinSlot(i: 1 | 2 | 3) { setHeroSlot(i, null) }
function resumeEntry(entry: ReadSession) {
goto(`/reader/${entry.mangaId}/${entry.endChapterId}`)
}
</script>
<div class="root">
<div class="hero-shrink-guard">
<HeroStage
{resolvedSlots}
bind:activeIdx
{heroThumb}
{heroTitle}
{heroManga}
{heroEntry}
{heroMangaId}
{heroChapters}
{heroNewChapter}
{loadingHeroChapters}
{resuming}
onresume={resumeActive}
onopenchapter={openChapter}
oncyclenext={cycleNext}
oncycleprev={cyclePrev}
ongotoslot={goToSlot}
onopenpicker={openPicker}
onunpin={unpinSlot}
onviewall={() => heroManga && goto(`/series/${heroManga.id}`)}
/>
</div>
<div class="scroll-body">
<div class="mid-row">
<div class="mid-left">
<ActivityFeed
onresume={resumeEntry}
onviewhistory={() => goto('/recent')}
onopenlibrary={() => goto('/library')}
/>
</div>
<div class="mid-divider"></div>
<div class="mid-right">
<RecsRow
libraryManga={manga}
history={historyState.sessions}
onopenrecommended={(m) => goto(`/series/${m.id}`)}
/>
</div>
</div>
<div class="bottom-row">
<div class="bottom-heatmap">
<span class="bottom-label">Activity</span>
<ActivityHeatmap dailyReadCounts={historyState.dailyReadCounts} />
</div>
<div class="bottom-divider"></div>
<div class="bottom-stats">
<StatsGrid stats={historyState.stats} updateCount={0} />
</div>
</div>
</div>
</div>
{#if pickerOpen && pickerSlotIndex !== null}
<HeroSlotPicker
slotIndex={pickerSlotIndex}
libraryManga={manga}
loading={libraryState.loading}
onpin={pinManga}
onclose={closePicker}
/>
{/if}
<style>
.root {
display: flex; flex-direction: column;
height: 100%; overflow: hidden;
animation: fadeIn 0.4s ease both;
}
.hero-shrink-guard { flex-shrink: 0; }
.scroll-body {
flex: 1; overflow-y: auto; overflow-x: hidden;
min-height: 0; scrollbar-width: none;
}
.scroll-body::-webkit-scrollbar { display: none; }
.mid-row {
display: grid; grid-template-columns: 1fr 1px 1.4fr;
border-top: 1px solid var(--border-dim); flex-shrink: 0; min-height: 0;
}
.mid-left { min-width: 0; overflow: hidden; }
.mid-left :global(.section) { border-top: none; }
.mid-divider { background: var(--border-dim); align-self: stretch; }
.mid-right { min-width: 0; overflow: hidden; padding: var(--sp-3) var(--sp-4) var(--sp-4); }
.bottom-row {
display: grid; grid-template-columns: 1fr 1px 1fr;
border-top: 1px solid var(--border-dim); flex-shrink: 0;
}
.bottom-divider { background: var(--border-dim); align-self: stretch; }
.bottom-heatmap {
display: flex; flex-direction: column; gap: var(--sp-2);
padding: var(--sp-4) var(--sp-4) var(--sp-5); min-width: 0;
}
.bottom-stats { padding: var(--sp-4) var(--sp-4) var(--sp-5); min-width: 0; overflow: hidden; }
.bottom-label {
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
}
@keyframes fadeIn { from { opacity: 0; transform: translateY(6px) } to { opacity: 1; transform: translateY(0) } }
</style>
+222 -11
View File
@@ -1,7 +1,10 @@
<script lang="ts">
import { Sparkle } from 'phosphor-svelte'
import { ArrowLeft, ArrowRight, Sparkle } from 'phosphor-svelte'
import Thumbnail from '$lib/components/shared/manga/Thumbnail.svelte'
import type { Manga } from '$lib/types'
import type { HistoryEntry } from '$lib/state/home.svelte'
import type { ReadSession } from '$lib/types/history'
import { fetchRecommendations, topGenres } from './lib/recommendations'
import type { RecommendedManga } from './lib/recommendations'
let {
libraryManga,
@@ -9,25 +12,233 @@
onopenrecommended,
}: {
libraryManga: Manga[]
history: HistoryEntry[]
history: ReadSession[]
onopenrecommended: (m: Manga) => void
} = $props()
const CARD_MIN_WIDTH = 100
const GAP = 12
const ROWS = 2
let containerEl: HTMLDivElement | undefined = $state()
let containerWidth = $state(0)
$effect(() => {
if (!containerEl) return
const ro = new ResizeObserver(([entry]) => {
containerWidth = entry.contentRect.width
})
ro.observe(containerEl)
return () => ro.disconnect()
})
const cols = $derived(containerWidth > 0 ? Math.max(1, Math.floor((containerWidth + GAP) / (CARD_MIN_WIDTH + GAP))) : 6)
const visibleCount = $derived(cols * ROWS)
const gridStyle = $derived(`grid-template-columns: repeat(${cols}, 1fr);`)
let allRecs: RecommendedManga[] = $state([])
let loading = $state(false)
let _ctrl: AbortController | null = null
$effect(() => {
const _history = history
const _library = libraryManga
if (!_history.length || !_library.length) { allRecs = []; return }
_ctrl?.abort()
const ctrl = new AbortController()
_ctrl = ctrl
loading = true
fetchRecommendations(_history, _library, ctrl.signal)
.then(r => { if (!ctrl.signal.aborted) { allRecs = r; loading = false } })
.catch(() => { if (!ctrl.signal.aborted) loading = false })
})
const genres = $derived(topGenres(history, libraryManga))
let genreIdx = $state(0)
const activeGenre = $derived(genres[genreIdx] ?? null)
const visibleRecs = $derived(
(activeGenre
? allRecs.filter(r => r.matchedGenres.some(g => g.toLowerCase() === activeGenre.toLowerCase()))
: allRecs
).slice(0, visibleCount)
)
function prev() { genreIdx = (genreIdx - 1 + genres.length) % genres.length }
function next() { genreIdx = (genreIdx + 1) % genres.length }
</script>
<div class="col">
<div class="col-header">
<span class="col-title"><Sparkle size={10} weight="bold" /> Recommended</span>
<span class="col-title">
<Sparkle size={10} weight="bold" /> Recommended
</span>
{#if genres.length > 1}
<div class="genre-switcher">
<button class="nav-btn" onclick={prev}><ArrowLeft size={9} weight="bold" /></button>
<span class="genre-label">{activeGenre}</span>
<button class="nav-btn" onclick={next}><ArrowRight size={9} weight="bold" /></button>
</div>
{/if}
</div>
<div class="grid-container" bind:this={containerEl}>
{#if loading}
<p class="empty-msg">Loading…</p>
{:else if visibleRecs.length > 0}
<div class="card-grid" style={gridStyle}>
{#each visibleRecs as r (r.manga.id)}
<button class="card" onclick={() => onopenrecommended(r.manga)}>
<div class="card-cover-wrap">
<Thumbnail src={r.manga.thumbnailUrl} alt={r.manga.title} class="card-cover" />
<div class="card-gradient"></div>
<div class="card-footer">
<p class="card-title">{r.manga.title}</p>
<p class="card-badge">{r.matchedGenres.slice(0, 2).join(' · ')}</p>
</div>
</div>
</button>
{/each}
</div>
{:else}
<p class="empty-msg">No recommendations found</p>
{/if}
</div>
<p class="stub">Recommendations coming soon</p>
</div>
<style>
.col { display: flex; flex-direction: column; min-width: 0; height: 100%; }
.col-header { display: flex; align-items: center; justify-content: space-between; padding-bottom: var(--sp-2); flex-shrink: 0; }
.col-title {
display: inline-flex; align-items: center; gap: var(--sp-2);
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
.col-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: var(--sp-2);
flex-shrink: 0;
}
.col-title {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
.genre-switcher {
display: flex;
align-items: center;
gap: var(--sp-2);
}
.genre-label {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
min-width: 48px;
text-align: center;
}
.nav-btn {
display: flex;
align-items: center;
justify-content: center;
background: none;
border: none;
cursor: pointer;
padding: 2px;
color: var(--text-faint);
transition: color var(--t-base);
}
.nav-btn:hover { color: var(--accent-fg); }
.grid-container {
flex: 1;
min-width: 0;
min-height: 0;
overflow: hidden;
}
.card-grid {
display: grid;
grid-template-rows: repeat(2, auto);
grid-auto-rows: 0;
overflow: hidden;
gap: var(--sp-3);
align-content: start;
}
.card {
width: 100%;
background: none;
border: none;
padding: 0;
cursor: pointer;
text-align: left;
}
.card:hover :global(.card-cover) { filter: brightness(1.1) saturate(1.05); transform: scale(1.02); }
.card-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);
box-shadow: 0 2px 14px rgba(0, 0, 0, 0.38);
}
:global(.card-cover) {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: filter 0.15s ease, transform 0.15s ease;
}
.card-gradient {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.08) 55%, transparent 75%);
pointer-events: none;
}
.card-footer {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: var(--sp-2);
pointer-events: none;
}
.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);
}
.card-badge {
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;
}
.empty-msg {
font-family: var(--font-ui);
font-size: var(--text-sm);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
padding: var(--sp-1) 0;
}
.stub { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
</style>
+2 -2
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { Fire, BookOpen, Clock, TrendUp, Bell, CalendarBlank } from 'phosphor-svelte'
import { formatReadTime } from '$lib/components/home/homeHelpers'
import type { ReadingStats } from '$lib/state/home.svelte'
import { formatReadTime } from '$lib/components/home/lib/homeHelpers'
import type { ReadingStats } from '$lib/types/history'
let {
stats,
@@ -0,0 +1,66 @@
import { getAdapter } from '$lib/request-manager'
import type { Manga } from '$lib/types'
import type { ReadSession } from '$lib/types/history'
export interface RecommendedManga {
manga: Manga
matchedGenres: string[]
}
const TOP_GENRES = 6
const TARGET_PER_GENRE = 20
export function topGenres(history: ReadSession[], libraryManga: Manga[]): string[] {
const byId = new Map(libraryManga.map(m => [m.id, m]))
const tally = new Map<string, { count: number; original: string }>()
for (const session of history) {
const manga = byId.get(session.mangaId)
if (!manga?.genre?.length) continue
for (const g of manga.genre) {
const key = g.toLowerCase()
const existing = tally.get(key)
if (existing) existing.count++
else tally.set(key, { count: 1, original: g })
}
}
return [...tally.values()]
.sort((a, b) => b.count - a.count)
.slice(0, TOP_GENRES)
.map(e => e.original)
}
export async function fetchRecommendations(
history: ReadSession[],
libraryManga: Manga[],
signal?: AbortSignal,
): Promise<RecommendedManga[]> {
if (!history.length || !libraryManga.length) return []
const genres = topGenres(history, libraryManga)
if (!genres.length) return []
const adapter = getAdapter()
const globalSeen = new Set<number>(libraryManga.map(m => m.id))
const merged: Manga[] = []
for (const genre of genres) {
if (signal?.aborted) break
try {
const results = await adapter.getMangaByGenre(genre, { excludeInLibrary: true }, signal)
for (const m of results) {
if (globalSeen.has(m.id)) continue
globalSeen.add(m.id)
merged.push(m)
if (merged.length >= genres.length * TARGET_PER_GENRE) break
}
} catch {
continue
}
}
return merged.map(m => ({
manga: m,
matchedGenres: (m.genre ?? []).filter(g =>
genres.some(tg => tg.toLowerCase() === g.toLowerCase())
),
}))
}
+15 -2
View File
@@ -144,7 +144,6 @@
};
}
// Reset virtual load window when chapter changes
let lastChapterId = 0;
$effect(() => {
const chapterId = readerState.activeChapter?.id ?? 0;
@@ -362,6 +361,8 @@
readerState.inspectPanY = clampedY;
}
let tapTimer: ReturnType<typeof setTimeout> | null = null;
function handleTap(e: MouseEvent) {
if (style === "longstrip") {
if (stripDragMoved) { stripDragMoved = false; return; }
@@ -369,8 +370,20 @@
}
if (inspectDragMoved) { inspectDragMoved = false; return; }
if (stripDragMoved) { stripDragMoved = false; return; }
if (tapToToggleBar) {
if (tapTimer) { clearTimeout(tapTimer); tapTimer = null; return; }
tapTimer = setTimeout(() => { tapTimer = null; onTap(e); }, 220);
} else {
onTap(e);
}
}
function handleDblClick() {
if (tapToToggleBar) {
if (tapTimer) { clearTimeout(tapTimer); tapTimer = null; }
onToggleUi();
}
}
function setContainer(el: HTMLDivElement) {
containerEl = el;
@@ -399,7 +412,7 @@
tabindex="-1"
onclick={handleTap}
onauxclick={(e) => { if (e.button === 1 && style === "longstrip") e.preventDefault(); }}
ondblclick={() => { if (tapToToggleBar) onToggleUi(); }}
ondblclick={handleDblClick}
onmousedown={onInspectMouseDown}
onpointerdown={pinchZoomEnabled ? onPointerDown : undefined}
onwheel={(e) => { if (e.ctrlKey || style !== "longstrip") e.preventDefault(); }}
+49 -8
View File
@@ -2,6 +2,7 @@
import { onMount, untrack, tick } from "svelte";
import { readerState, PAGE_STYLES } from "$lib/state/reader.svelte";
import { settingsState, updateSettings } from "$lib/state/settings.svelte";
import { app } from "$lib/state/app.svelte";
import { DEFAULT_KEYBINDS } from "$lib/core/keybinds/defaultBinds";
import { fetchPages, resolveUrl, preloadImage, measureAspect, buildPageGroups } from "$lib/components/reader/lib/pageLoader";
import { setupScrollTracking, appendNextChapter } from "$lib/components/reader/lib/scrollHandler";
@@ -10,6 +11,7 @@
import { goForward, goBack, jumpToPage } from "$lib/components/reader/lib/navigation";
import { clampZoom, captureZoomAnchor, restoreZoomAnchor } from "$lib/components/reader/lib/zoomHelpers";
import { loadChapter, scheduleResumeDismiss } from "$lib/components/reader/lib/chapterLoader";
import { historyState } from "$lib/state/history.svelte";
import type { ReaderSettings } from "$lib/state/reader.svelte";
import ReaderControls from "$lib/components/reader/ReaderControls.svelte";
import PageView from "$lib/components/reader/PageView.svelte";
@@ -54,7 +56,9 @@
const isBookmarked = $derived(
!!currentBookmark &&
currentBookmark.chapterId === displayChapter?.id &&
currentBookmark.pageNumber === readerState.pageNumber
(style === "double"
? currentGroup.includes(currentBookmark.pageNumber)
: currentBookmark.pageNumber === readerState.pageNumber)
);
const currentPageMarkers = $derived(displayChapter ? readerState.getMarkersForPage(displayChapter.id, readerState.pageNumber) : []);
@@ -139,6 +143,7 @@
let startAtLastPageRef = { current: false };
let cleanupScroll: () => void = () => {};
let stripChaptersRef = readerState.stripChapters;
let tickTimer: ReturnType<typeof setTimeout> | null = null;
$effect(() => { stripChaptersRef = readerState.stripChapters; });
@@ -219,7 +224,7 @@
resetZoom: () => { captureZoomAnchor(containerEl, style, zoomAnchor); applySettings({ readerZoom: 1.0 }); restoreZoomAnchor(containerEl, zoomAnchor); },
cycleStyle: () => { const idx = PAGE_STYLES.indexOf(style); applySettings({ pageStyle: PAGE_STYLES[(idx + 1) % PAGE_STYLES.length] }); },
toggleDirection: () => applySettings({ readingDirection: rtl ? "ltr" : "rtl" }),
openSettings: () => { settingsState.settingsOpen = true; },
openSettings: () => { app.setSettingsOpen(true); },
toggleBookmark: () => toggleBookmark(displayChapter, readerState.pageNumber),
toggleAutoScroll: () => { if (style === "longstrip") updateSettings({ autoScroll: !(settingsState.settings.autoScroll ?? false) }); },
toggleMarker: () => {
@@ -294,7 +299,38 @@
$effect(() => {
const ch = readerState.activeChapter;
if (ch) untrack(() => loadChapter(ch.id, useBlob, abortCtrl, startAtLastPageRef, markedRead, adjacent));
const manga = readerState.activeManga;
if (ch && manga) {
untrack(() => {
historyState.openSession(
manga.id, manga.title, manga.thumbnailUrl,
ch.id, ch.name, readerState.pageNumber,
);
loadChapter(ch.id, useBlob, abortCtrl, startAtLastPageRef, markedRead, adjacent);
});
}
});
$effect(() => {
const page = readerState.pageNumber;
const chId = style === "longstrip"
? (readerState.visibleChapterId ?? readerState.activeChapter?.id)
: readerState.activeChapter?.id;
const chName = style === "longstrip"
? (readerState.activeChapterList.find(c => c.id === chId)?.name ?? readerState.activeChapter?.name ?? "")
: (readerState.activeChapter?.name ?? "");
if (!chId || !readerState.activeManga) return;
if (tickTimer) clearTimeout(tickTimer);
tickTimer = setTimeout(() => {
historyState.tickSession(chId, chName, page);
tickTimer = null;
}, 2_000);
return () => {
if (tickTimer) { clearTimeout(tickTimer); tickTimer = null; }
};
});
$effect(() => {
@@ -518,6 +554,7 @@
if (containerEl) ro.observe(containerEl);
return () => {
historyState.closeSession();
abortCtrl.current?.abort();
if (hideTimer) clearTimeout(hideTimer);
if (roTimer) clearTimeout(roTimer);
@@ -542,9 +579,14 @@
role="presentation"
onmousemove={(e) => {
if (!tapToToggleBar) {
if (barPosition === "top" && (e.clientY < 60 || window.innerHeight - e.clientY < 60)) showUi();
if (barPosition === "left" && e.clientX < 60) showUi();
if (barPosition === "right" && window.innerWidth - e.clientX < 60) showUi();
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const w = rect.width;
const h = rect.height;
if (barPosition === "top" && (y < 60 || h - y < 60)) showUi();
if (barPosition === "left" && x < 60) showUi();
if (barPosition === "right" && w - x < 60) showUi();
}
}}
>
@@ -564,8 +606,7 @@
onDeleteMarker={deleteCurrentMarker}
onClampZoom={clampZoom}
onApplySettings={applySettings}
onDlOpen={() => readerState.dlOpen = true}
onSettingsOpen={() => { settingsState.settingsOpen = true; }}
onSettingsOpen={() => { app.setSettingsOpen(true); }}
{perMangaEnabled}
/>
+318 -174
View File
@@ -3,6 +3,7 @@
X, CaretLeft, CaretRight, CaretUp, CaretDown,
MagnifyingGlassMinus, MagnifyingGlassPlus,
Bookmark, MapPin, Download, Check, GearSix, Sliders,
ArrowsOut, ArrowsIn,
} from "phosphor-svelte";
import { readerState, MARKER_COLORS, MARKER_COLOR_HEX, ZOOM_STEP, ZOOM_MIN, ZOOM_MAX } from "$lib/state/reader.svelte";
import { settingsState } from "$lib/state/settings.svelte";
@@ -14,7 +15,7 @@
interface Props {
displayChapter: Chapter | null;
adjacent: { prev: Chapter | null; next: Chapter | null };
adjacent: { prev: Chapter | null; next: Chapter | null; remaining: Chapter[] };
visibleChunkLastPage: number;
zoom: number;
zoomPct: number;
@@ -33,7 +34,6 @@
onDeleteMarker: () => void;
onClampZoom: (z: number) => number;
onApplySettings: (patch: Partial<ReaderSettings>) => void;
onDlOpen: () => void;
onSettingsOpen: () => void;
perMangaEnabled: boolean;
}
@@ -46,10 +46,37 @@
barPosition, progressBar,
onCaptureZoomAnchor, onRestoreZoomAnchor,
onMaybeMarkRead, onToggleBookmark, onCommitMarker, onDeleteMarker,
onClampZoom, onApplySettings, onDlOpen, onSettingsOpen,
onClampZoom, onApplySettings, onSettingsOpen,
perMangaEnabled,
}: Props = $props();
const queueable = $derived(adjacent.remaining.filter(c => !c.downloaded));
async function gqlMutation(query: string, variables: Record<string, unknown>): Promise<void> {
const base = settingsState.settings.serverUrl ?? "http://localhost:4567";
const headers: Record<string, string> = { "Content-Type": "application/json" };
const mode = settingsState.settings.serverAuthMode ?? "NONE";
if (mode === "BASIC_AUTH") {
const u = settingsState.settings.serverAuthUser?.trim() ?? "";
const p = settingsState.settings.serverAuthPass?.trim() ?? "";
if (u && p) headers["Authorization"] = `Basic ${btoa(`${u}:${p}`)}`;
}
const res = await fetch(`${base}/api/graphql`, { method: "POST", headers, body: JSON.stringify({ query, variables }) });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
if (json.errors?.length) throw new Error(json.errors[0].message);
}
const ENQUEUE_ONE = `mutation EnqueueOne($id: Int!) { fetchChapterPages(input: { chapterId: $id }) { chapter { id } } }`;
const ENQUEUE_MANY = `mutation EnqueueMany($ids: [Int!]!) { enqueueChaptersDownloads(input: { ids: $ids }) { downloadStatus { queue { chapter { id } } } } }`;
async function runDl(fn: () => Promise<void>) {
readerState.dlBusy = true;
try { await fn(); } catch (e) { console.error(e); }
readerState.dlBusy = false;
readerState.dlOpen = false;
}
const isVertical = $derived(barPosition === "left" || barPosition === "right");
const popoverSide = $derived(
barPosition === "left" ? "right" :
@@ -74,20 +101,15 @@
else await document.exitFullscreen();
}
let wcTimer: ReturnType<typeof setTimeout> | null = null;
function wcResetTimer() {
if (wcTimer) clearTimeout(wcTimer);
wcTimer = setTimeout(() => { readerState.winOpen = false; }, 1500);
function closeAllPopovers() {
readerState.actionsOpen = false;
readerState.markerOpen = false;
readerState.zoomOpen = false;
readerState.dlOpen = false;
}
$effect(() => {
if (readerState.winOpen) wcResetTimer();
else if (wcTimer) { clearTimeout(wcTimer); wcTimer = null; }
return () => { if (wcTimer) clearTimeout(wcTimer); };
});
function openMarkerPopover() {
closeAllPopovers();
if (currentPageMarkers.length > 0) {
const first = currentPageMarkers[0];
readerState.openMarker(first.id, first.note, first.color);
@@ -117,18 +139,17 @@
class:hidden={!uiVisible}
>
<div class="bar-start">
<button class="icon-btn" onclick={() => readerState.closeReader()} title="Close reader">
<X size={15} weight="light" />
<button class="icon-btn close-btn" onclick={() => readerState.closeReader()} title="Close reader">
<X size={14} weight="regular" />
</button>
<div class="bar-divider"></div>
<button class="icon-btn"
onclick={() => { if (adjacent.prev) { onMaybeMarkRead(); readerState.openReader(adjacent.prev, readerState.activeChapterList); } }}
disabled={!adjacent.prev}>
{#if isVertical}
<CaretUp size={14} weight="light" />
{:else}
<CaretLeft size={14} weight="light" />
{/if}
disabled={!adjacent.prev}
title="Previous chapter">
{#if isVertical}<CaretUp size={13} weight="regular" />{:else}<CaretLeft size={13} weight="regular" />{/if}
</button>
<div
@@ -137,7 +158,7 @@
onmouseleave={hideChapterPopover}
role="presentation"
>
<button class="ch-pill" title="{readerState.activeManga?.title} / {displayChapter?.name}">
<div class="ch-pill">
{#if isVertical}
<span class="ch-info">&#xE2CE;</span>
{:else}
@@ -148,33 +169,28 @@
<span class="ch-name">{displayChapter?.name}</span>
</span>
</span>
<span class="ch-page">p.{readerState.pageNumber} / {visibleChunkLastPage}</span>
{/if}
</button>
</div>
{#if !isVertical}
<span class="ch-page">{readerState.pageNumber}<span class="ch-page-sep">/</span>{visibleChunkLastPage}</span>
{/if}
{#if chapterHover && !isVertical}
{#if chapterHover && isVertical}
<div class="ch-popover ch-popover-{popoverSide}">
<span class="ch-pop-title">{readerState.activeManga?.title}</span>
<span class="ch-pop-sep">/</span>
<span class="ch-pop-name">{displayChapter?.name}</span>
<span class="ch-pop-page">p.{readerState.pageNumber} / {visibleChunkLastPage}</span>
<span class="ch-pop-page">{readerState.pageNumber} / {visibleChunkLastPage}</span>
</div>
{/if}
</div>
<button class="icon-btn"
onclick={() => { if (adjacent.next) { onMaybeMarkRead(); readerState.openReader(adjacent.next, readerState.activeChapterList); } }}
disabled={!adjacent.next}>
{#if isVertical}
<CaretDown size={14} weight="light" />
{:else}
<CaretRight size={14} weight="light" />
{/if}
disabled={!adjacent.next}
title="Next chapter">
{#if isVertical}<CaretDown size={13} weight="regular" />{:else}<CaretRight size={13} weight="regular" />{/if}
</button>
{#if !isVertical}
<span class="bar-sep" data-tauri-drag-region></span>
{/if}
</div>
{#if isVertical && progressBar}
@@ -188,32 +204,36 @@
{/if}
<div class="bar-end">
<div class="zoom-wrap">
<div class="zoom-inline">
<button class="icon-btn zoom-icon-btn" onclick={() => adjustZoom(-ZOOM_STEP)} title="Zoom out" disabled={zoom <= ZOOM_MIN}>
<MagnifyingGlassMinus size={13} weight="light" />
<div class="zoom-cluster">
<button class="icon-btn zoom-step-btn" onclick={() => adjustZoom(-ZOOM_STEP)} title="Zoom out" disabled={zoom <= ZOOM_MIN}>
<MagnifyingGlassMinus size={13} weight="regular" />
</button>
<div class="zoom-divider"></div>
<button class="zoom-pct-btn" onclick={() => readerState.zoomOpen = !readerState.zoomOpen} title="Click to adjust zoom">
<button class="zoom-pct-btn" onclick={() => { readerState.zoomOpen = !readerState.zoomOpen; readerState.actionsOpen = false; readerState.markerOpen = false; }} title="Adjust zoom">
{zoomPct}%
</button>
<div class="zoom-divider"></div>
<button class="icon-btn zoom-icon-btn" onclick={() => adjustZoom(ZOOM_STEP)} title="Zoom in" disabled={zoom >= ZOOM_MAX}>
<MagnifyingGlassPlus size={13} weight="light" />
<button class="icon-btn zoom-step-btn" onclick={() => adjustZoom(ZOOM_STEP)} title="Zoom in" disabled={zoom >= ZOOM_MAX}>
<MagnifyingGlassPlus size={13} weight="regular" />
</button>
</div>
{#if readerState.zoomOpen}
<div class="popover zoom-popover popover-{popoverSide}">
<div class="zoom-slider-row">
<input type="range" class="zoom-slider" min={10} max={100} step={5} value={zoomPct}
<div class="popover zoom-popover popover-{popoverSide}" role="presentation" onclick={(e) => e.stopPropagation()}>
<div class="zoom-row">
<button class="zoom-step-sm" onclick={() => adjustZoom(-ZOOM_STEP)} disabled={zoom <= ZOOM_MIN}></button>
<input type="range" class="zoom-slider" min={10} max={200} step={5} value={zoomPct}
oninput={(e) => { onCaptureZoomAnchor(); onApplySettings({ readerZoom: onClampZoom(Number(e.currentTarget.value) / 100) }); onRestoreZoomAnchor(); }} />
<button class="zoom-step-sm" onclick={() => adjustZoom(ZOOM_STEP)} disabled={zoom >= ZOOM_MAX}>+</button>
</div>
<div class="zoom-footer">
<span class="zoom-readout">{zoomPct}%</span>
<button class="zoom-reset" onclick={resetZoom} disabled={zoom === 1.0}>Reset</button>
</div>
</div>
{/if}
</div>
<div class="bar-divider"></div>
<div class="marker-wrap">
<button
class="icon-btn"
@@ -227,44 +247,28 @@
</button>
{#if readerState.markerOpen}
<div class="popover marker-popover popover-{popoverSide}" role="presentation"
onclick={(e) => e.stopPropagation()}
>
<div class="popover marker-popover popover-{popoverSide}" role="presentation" onclick={(e) => e.stopPropagation()}>
<div class="marker-pop-header">
<span class="marker-pop-title">
{readerState.markerEditId ? "Edit marker" : "New marker"} · p.{readerState.pageNumber}
</span>
<span class="marker-pop-title">{readerState.markerEditId ? "Edit marker" : "New marker"} · p.{readerState.pageNumber}</span>
{#if readerState.markerEditId}
<button class="marker-delete-btn" onclick={onDeleteMarker} title="Delete marker">
<X size={12} weight="light" />
</button>
<button class="marker-delete-btn" onclick={onDeleteMarker} title="Delete marker"><X size={12} weight="light" /></button>
{/if}
</div>
<div class="marker-color-row">
{#each MARKER_COLORS as c}
<button
class="marker-swatch"
class:marker-swatch-active={readerState.markerColor === c}
style="--swatch:{MARKER_COLOR_HEX[c]}"
onclick={() => readerState.markerColor = c}
title={c}
>
<button class="marker-swatch" class:marker-swatch-active={readerState.markerColor === c}
style="--swatch:{MARKER_COLOR_HEX[c]}" onclick={() => readerState.markerColor = c} title={c}>
<span class="swatch-dot"></span>
<span class="swatch-label">{c}</span>
</button>
{/each}
</div>
<textarea
class="marker-textarea"
style="--accent-marker:{MARKER_COLOR_HEX[readerState.markerColor]}"
rows={3}
placeholder="Note (optional)…"
bind:value={readerState.markerNote}
<textarea class="marker-textarea" style="--accent-marker:{MARKER_COLOR_HEX[readerState.markerColor]}"
rows={3} placeholder="Note (optional)…" bind:value={readerState.markerNote}
onkeydown={(e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); onCommitMarker(); }
if (e.key === "Escape") readerState.markerOpen = false;
}}
></textarea>
}}></textarea>
<div class="marker-pop-actions">
<button class="marker-save-btn" style="--accent-marker:{MARKER_COLOR_HEX[readerState.markerColor]}" onclick={onCommitMarker}>
<Check size={12} weight="bold" />
@@ -278,67 +282,90 @@
<button class="icon-btn" class:active={isBookmarked} onclick={onToggleBookmark}
title={isBookmarked ? "Remove bookmark" : "Bookmark this page"}>
<Bookmark size={15} weight={isBookmarked ? "fill" : "regular"} />
<Bookmark size={14} weight={isBookmarked ? "fill" : "regular"} />
</button>
<button class="icon-btn" onclick={onDlOpen}>
<Download size={14} weight="light" />
</button>
<div class="bar-divider"></div>
<button class="icon-btn" class:active={perMangaEnabled}
onclick={() => { readerState.presetOpen = true; readerState.markerOpen = false; readerState.zoomOpen = false; readerState.dlOpen = false; }}
onclick={() => { readerState.presetOpen = true; closeAllPopovers(); }}
title="Reader settings">
<Sliders size={13} weight="regular" />
</button>
<button class="icon-btn" onclick={onSettingsOpen} title="Settings">
<GearSix size={13} weight="regular" />
</button>
<div class="wc-wrap">
<div class="actions-wrap">
<button
class="icon-btn"
class:active={readerState.winOpen}
onclick={() => { readerState.winOpen = !readerState.winOpen; readerState.markerOpen = false; readerState.zoomOpen = false; readerState.dlOpen = false; }}
title="Window controls"
class:active={readerState.actionsOpen}
onclick={() => { readerState.actionsOpen = !readerState.actionsOpen; readerState.markerOpen = false; readerState.zoomOpen = false; }}
title="More actions"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<circle cx="6" cy="1.5" r="1.2" fill="currentColor"/>
<circle cx="6" cy="6" r="1.2" fill="currentColor"/>
<circle cx="6" cy="10.5" r="1.2" fill="currentColor"/>
<svg width="13" height="13" viewBox="0 0 13 13" fill="none">
<circle cx="2" cy="6.5" r="1.3" fill="currentColor"/>
<circle cx="6.5" cy="6.5" r="1.3" fill="currentColor"/>
<circle cx="11" cy="6.5" r="1.3" fill="currentColor"/>
</svg>
</button>
{#if readerState.winOpen}
{#if readerState.actionsOpen}
<div
class="wc-clip wc-clip-{popoverSide}"
role="presentation"
onmouseenter={wcResetTimer}
onmousemove={wcResetTimer}
>
<div
class="wc-bar"
class="popover actions-popover popover-{popoverSide}"
role="presentation"
onclick={(e) => e.stopPropagation()}
in:fly={isVertical
? (barPosition === "left" ? { x: '-100%', duration: 200, easing: cubicOut } : { x: '100%', duration: 200, easing: cubicOut })
: { y: '-100%', duration: 200, easing: cubicOut }}
? (barPosition === "left" ? { x: -8, duration: 160, easing: cubicOut } : { x: 8, duration: 160, easing: cubicOut })
: { y: -6, duration: 160, easing: cubicOut }}
out:fly={isVertical
? (barPosition === "left" ? { x: '-100%', duration: 150, easing: cubicIn } : { x: '100%', duration: 150, easing: cubicIn })
: { y: '-100%', duration: 150, easing: cubicIn }}
? (barPosition === "left" ? { x: -8, duration: 120, easing: cubicIn } : { x: 8, duration: 120, easing: cubicIn })
: { y: -6, duration: 120, easing: cubicIn }}
>
<button class="wc-icon-btn" onclick={async () => { readerState.winOpen = false; await toggleFullscreen(); }} title={isFullscreen ? "Exit fullscreen" : "Fullscreen"}>
<button class="action-row" onclick={() => { readerState.dlOpen = !readerState.dlOpen; readerState.actionsOpen = false; }}>
<Download size={13} weight="regular" />
<span>Download</span>
</button>
<button class="action-row" onclick={() => { onSettingsOpen(); readerState.actionsOpen = false; }}>
<GearSix size={13} weight="regular" />
<span>Settings</span>
</button>
<div class="action-divider"></div>
<button class="action-row" onclick={async () => { readerState.actionsOpen = false; await toggleFullscreen(); }}>
{#if isFullscreen}
<svg width="11" height="11" viewBox="0 0 11 11">
<polyline points="1,4 1,1 4,1" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="7,1 10,1 10,4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="10,7 10,10 7,10" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="4,10 1,10 1,7" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<ArrowsIn size={13} weight="regular" />
<span>Exit fullscreen</span>
{:else}
<svg width="10" height="10" viewBox="0 0 10 10"><rect x="0.75" y="0.75" width="8.5" height="8.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
<ArrowsOut size={13} weight="regular" />
<span>Fullscreen</span>
{/if}
</button>
</div>
{/if}
{#if readerState.dlOpen && readerState.activeChapter}
{@const chapter = readerState.activeChapter}
<div class="popover dl-popover popover-{popoverSide}" role="presentation" onclick={(e) => e.stopPropagation()}>
<p class="dl-title">Download</p>
<button class="dl-option" disabled={readerState.dlBusy || !!chapter.downloaded}
onclick={() => runDl(() => gqlMutation(ENQUEUE_ONE, { id: chapter.id }))}>
This chapter
<span class="dl-sub">{chapter.downloaded ? "Already downloaded" : chapter.name}</span>
</button>
<div class="dl-row">
<button class="dl-option" disabled={readerState.dlBusy || queueable.length === 0}
onclick={() => runDl(() => gqlMutation(ENQUEUE_MANY, { ids: queueable.slice(0, readerState.nextN).map(c => c.id) }))}>
Next chapters
<span class="dl-sub">{Math.min(readerState.nextN, queueable.length)} not yet downloaded</span>
</button>
<div class="dl-stepper" role="presentation" onclick={(e) => e.stopPropagation()}>
<button class="dl-step-btn" onclick={() => readerState.nextN = Math.max(1, readerState.nextN - 1)} disabled={readerState.nextN <= 1}></button>
<span class="dl-step-val">{readerState.nextN}</span>
<button class="dl-step-btn" onclick={() => readerState.nextN = Math.min(queueable.length || 1, readerState.nextN + 1)} disabled={readerState.nextN >= queueable.length}>+</button>
</div>
</div>
<button class="dl-option" disabled={readerState.dlBusy || queueable.length === 0}
onclick={() => runDl(() => gqlMutation(ENQUEUE_MANY, { ids: queueable.map(c => c.id) }))}>
All remaining
<span class="dl-sub">{queueable.length} not yet downloaded</span>
</button>
</div>
{/if}
</div>
@@ -349,12 +376,14 @@
.bar {
display: flex;
align-items: center;
gap: var(--sp-1);
background: var(--bg-void);
gap: 2px;
background: color-mix(in srgb, var(--bg-void) 92%, transparent);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
flex-shrink: 0;
position: relative;
z-index: 2;
transition: opacity 0.25s ease;
transition: opacity 0.2s ease;
overflow: visible;
user-select: none;
}
@@ -362,9 +391,8 @@
.bar-top {
flex-direction: row;
justify-content: space-between;
padding: 0 var(--sp-3);
height: 40px;
padding: 0 var(--sp-2);
height: 44px;
border-bottom: 1px solid var(--border-dim);
}
@@ -372,12 +400,13 @@
flex-direction: column;
justify-content: space-between;
padding: var(--sp-3) 0;
width: 40px;
width: 44px;
position: fixed;
top: 0;
bottom: 0;
z-index: 2;
border-bottom: none;
gap: 0;
}
.bar-left { left: 0; border-right: 1px solid var(--border-dim); }
.bar-right { right: 0; border-left: 1px solid var(--border-dim); }
@@ -385,20 +414,58 @@
.bar-drag-gap { flex: 1; height: 100%; cursor: grab; }
.bar-drag-gap:active { cursor: grabbing; }
.bar-start, .bar-end { display: flex; align-items: center; gap: var(--sp-1); }
.bar-top .bar-start { overflow: hidden; }
.bar-left .bar-start,
.bar-left .bar-end,
.bar-right .bar-start,
.bar-right .bar-end { flex-direction: column; }
.bar-start, .bar-end {
display: flex;
align-items: center;
gap: 2px;
flex-shrink: 0;
}
.bar-top .bar-start { overflow: hidden; min-width: 0; }
.bar-left .bar-start, .bar-left .bar-end,
.bar-right .bar-start, .bar-right .bar-end { flex-direction: column; }
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-muted); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
.bar-middle {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
min-height: 0;
padding: var(--sp-1) 0;
overflow: visible;
}
.bar-divider {
flex-shrink: 0;
background: var(--border-dim);
border-radius: 1px;
}
.bar-top .bar-divider { width: 1px; height: 18px; margin: 0 var(--sp-1); }
.bar-left .bar-divider,
.bar-right .bar-divider { height: 1px; width: 20px; margin: var(--sp-1) 0; }
.icon-btn {
display: flex; align-items: center; justify-content: center;
width: 30px; height: 30px;
border-radius: var(--radius-md);
color: var(--text-muted);
flex-shrink: 0;
transition: color var(--t-fast), background var(--t-fast);
}
.icon-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
.icon-btn:disabled { opacity: 0.2; cursor: default; }
.icon-btn.active { color: var(--accent-fg); }
.marker-btn-has { color: var(--marker-color, var(--accent-fg)) !important; }
.ch-hover-wrap { position: relative; min-width: 0; display: flex; align-items: center; gap: var(--sp-2); }
.close-btn:hover { color: var(--text-primary); background: color-mix(in srgb, #c0392b 15%, transparent); }
.ch-hover-wrap {
position: relative;
min-width: 0;
display: flex;
align-items: center;
gap: var(--sp-2);
}
.ch-pill {
display: flex;
@@ -408,23 +475,39 @@
overflow: hidden;
white-space: nowrap;
min-width: 0;
padding: 2px 4px;
border-radius: var(--radius-sm);
background: none;
cursor: default;
transition: background var(--t-fast);
padding: 3px 6px;
border-radius: var(--radius-md);
border: 1px solid transparent;
transition: border-color var(--t-fast), background var(--t-fast);
}
.ch-hover-wrap:hover .ch-pill {
border-color: var(--border-dim);
background: var(--bg-raised);
}
.bar-left .ch-pill, .bar-right .ch-pill {
width: 30px; height: 30px; justify-content: center; padding: 0; border: none;
}
.bar-left .ch-pill, .bar-right .ch-pill { width: 28px; height: 28px; justify-content: center; padding: 0; }
.ch-info { font-size: 15px; line-height: 1; color: var(--text-faint); flex-shrink: 0; }
.ch-marquee-track { overflow-x: auto; min-width: 0; flex: 1; scrollbar-width: none; }
.ch-marquee-track::-webkit-scrollbar { display: none; }
.ch-marquee-content { display: inline-flex; align-items: center; gap: var(--sp-2); white-space: nowrap; }
.ch-title { color: var(--text-secondary); font-weight: var(--weight-medium); }
.ch-sep { color: var(--text-faint); flex-shrink: 0; }
.ch-name { color: var(--text-muted); }
.ch-page { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
.ch-page {
font-family: var(--font-ui);
font-size: var(--text-xs);
font-variant-numeric: tabular-nums;
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
flex-shrink: 0;
display: flex;
align-items: center;
gap: 3px;
}
.ch-page-sep { color: var(--border-strong); }
.ch-popover {
position: absolute;
@@ -432,9 +515,7 @@
border: 1px solid var(--border-base);
border-radius: var(--radius-lg);
padding: var(--sp-2) var(--sp-3);
display: flex;
align-items: center;
gap: var(--sp-2);
display: flex; align-items: center; gap: var(--sp-2);
white-space: nowrap;
z-index: 100;
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
@@ -447,34 +528,52 @@
.ch-pop-title { color: var(--text-secondary); font-weight: var(--weight-medium); }
.ch-pop-sep { color: var(--text-faint); }
.ch-pop-name { color: var(--text-muted); }
.ch-pop-page { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.ch-pop-page { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); font-variant-numeric: tabular-nums; }
.bar-sep { width: 1px; height: 16px; background: var(--border-dim); flex-shrink: 0; margin: 0 var(--sp-1); }
.zoom-cluster {
position: relative;
display: flex;
align-items: center;
background: var(--bg-overlay);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
overflow: visible;
flex-shrink: 0;
}
.bar-left .zoom-cluster, .bar-right .zoom-cluster { flex-direction: column; }
.zoom-wrap { position: relative; flex-shrink: 0; }
.zoom-inline { display: flex; align-items: center; }
.bar-left .zoom-inline, .bar-right .zoom-inline { flex-direction: column; }
.zoom-icon-btn { width: 28px; height: 28px; }
.zoom-divider { background: var(--border-dim); flex-shrink: 0; }
.bar-top .zoom-divider { width: 1px; height: 16px; }
.bar-left .zoom-divider,
.bar-right .zoom-divider { height: 1px; width: 16px; }
.zoom-step-btn {
width: 26px; height: 26px;
border-radius: calc(var(--radius-md) - 1px);
color: var(--text-faint);
flex-shrink: 0;
}
.zoom-step-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
.zoom-step-btn:disabled { opacity: 0.2; cursor: default; }
.zoom-pct-btn {
font-family: var(--font-ui);
font-size: var(--text-2xs);
font-variant-numeric: tabular-nums;
letter-spacing: var(--tracking-wide);
color: var(--text-secondary);
height: 28px;
min-width: 38px;
height: 26px;
min-width: 36px;
padding: 0 2px;
text-align: center;
transition: color var(--t-base), background var(--t-base);
padding: 0 var(--sp-1);
transition: color var(--t-fast), background var(--t-fast);
border-radius: 0;
border-left: 1px solid var(--border-dim);
border-right: 1px solid var(--border-dim);
}
.bar-left .zoom-pct-btn, .bar-right .zoom-pct-btn {
height: 22px; min-width: unset; width: 26px;
writing-mode: vertical-rl; font-size: 9px;
rotate: 270deg;
border-left: none; border-right: none;
border-top: 1px solid var(--border-dim);
border-bottom: 1px solid var(--border-dim);
}
.bar-left .zoom-pct-btn,
.bar-right .zoom-pct-btn { height: 24px; min-width: unset; width: 28px; writing-mode: vertical-rl; font-size: 9px; padding: var(--sp-1) 0; }
.zoom-pct-btn:hover { color: var(--text-primary); background: var(--bg-raised); }
.popover {
@@ -482,19 +581,36 @@
background: var(--bg-raised);
border: 1px solid var(--border-base);
border-radius: var(--radius-lg);
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
box-shadow: 0 8px 32px rgba(0,0,0,0.55);
z-index: 100;
animation: scaleIn 0.1s ease both;
animation: scaleIn 0.12s ease both;
}
.popover-bottom { top: calc(100% + 6px); left: 50%; translate: -50% 0; transform-origin: top center; }
.popover-bottom { top: calc(100% + 8px); left: 50%; translate: -50% 0; transform-origin: top center; }
.popover-right { left: calc(100% + 8px); top: 50%; translate: 0 -50%; transform-origin: left center; }
.popover-left { right: calc(100% + 8px); top: 50%; translate: 0 -50%; transform-origin: right center; }
.actions-wrap .popover-bottom { left: auto; right: 0; translate: none; transform-origin: top right; }
.actions-wrap .popover-right { top: auto; bottom: 0; translate: none; transform-origin: bottom left; }
.actions-wrap .popover-left { top: auto; bottom: 0; translate: none; transform-origin: bottom right; }
.zoom-popover { padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-2); min-width: 180px; }
.zoom-slider-row { display: flex; align-items: center; gap: var(--sp-2); }
.zoom-popover { padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-2); min-width: 200px; }
.zoom-row { display: flex; align-items: center; gap: var(--sp-2); }
.zoom-step-sm {
display: flex; align-items: center; justify-content: center;
width: 24px; height: 24px; flex-shrink: 0;
border-radius: var(--radius-sm);
border: 1px solid var(--border-dim);
background: var(--bg-overlay);
color: var(--text-muted);
font-size: var(--text-base); line-height: 1;
transition: color var(--t-fast), background var(--t-fast);
}
.zoom-step-sm:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
.zoom-step-sm:disabled { opacity: 0.25; cursor: default; }
.zoom-slider { flex: 1; height: 3px; appearance: none; -webkit-appearance: none; background: var(--border-strong); border-radius: 2px; outline: none; cursor: pointer; }
.zoom-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; border-radius: 50%; background: var(--accent-fg); cursor: pointer; }
.zoom-reset { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); padding: 3px var(--sp-2); border-radius: var(--radius-sm); border: 1px solid var(--border-dim); transition: color var(--t-base), background var(--t-base), border-color var(--t-base); }
.zoom-footer { display: flex; align-items: center; justify-content: space-between; }
.zoom-readout { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); font-variant-numeric: tabular-nums; }
.zoom-reset { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); padding: 3px var(--sp-2); border-radius: var(--radius-sm); border: 1px solid var(--border-dim); transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast); }
.zoom-reset:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-overlay); border-color: var(--border-strong); }
.zoom-reset:disabled { opacity: 0.3; cursor: default; }
@@ -507,7 +623,7 @@
.marker-color-row { display: flex; align-items: center; gap: var(--sp-2); }
.marker-swatch { display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 4px; border-radius: var(--radius-sm); background: none; border: none; cursor: pointer; flex: 1; transition: background var(--t-fast); }
.marker-swatch:hover { background: var(--bg-overlay); }
.swatch-dot { width: 14px; height: 14px; border-radius: 50%; background: var(--swatch); box-shadow: 0 0 0 0 var(--swatch); transition: box-shadow var(--t-fast), transform var(--t-fast); flex-shrink: 0; }
.swatch-dot { width: 14px; height: 14px; border-radius: 50%; background: var(--swatch); transition: box-shadow var(--t-fast), transform var(--t-fast); flex-shrink: 0; }
.marker-swatch:hover .swatch-dot { transform: scale(1.15); }
.marker-swatch-active .swatch-dot { box-shadow: 0 0 0 3px color-mix(in srgb, var(--swatch) 30%, transparent); transform: scale(1.1); }
.swatch-label { font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); color: var(--text-faint); text-transform: capitalize; line-height: 1; }
@@ -520,20 +636,48 @@
.marker-cancel-btn { flex: 1; padding: 6px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); text-align: center; }
.marker-cancel-btn:hover { color: var(--text-muted); border-color: var(--border-strong); }
.wc-wrap { position: static; flex-shrink: 0; }
.wc-clip { position: absolute; z-index: 100; }
.wc-clip-bottom { top: 100%; right: var(--sp-3); clip-path: inset(0 -20px -20px -20px); }
.wc-clip-right { left: calc(100% + 1px); top: auto; bottom: var(--sp-3); clip-path: inset(-20px -20px -20px 0); }
.wc-clip-left { right: calc(100% + 1px); top: auto; bottom: var(--sp-3); clip-path: inset(-20px 0 -20px -20px); }
.wc-bar { display: flex; align-items: center; gap: 1px; padding: 3px 10px 4px; background: var(--bg-raised); border: 1px solid var(--border-base); box-shadow: 0 6px 16px rgba(0,0,0,0.45); }
.wc-clip-bottom .wc-bar { border-top: none; border-radius: 0 0 8px 8px; flex-direction: row; }
.wc-clip-right .wc-bar { border-left: none; border-radius: 0 8px 8px 0; flex-direction: column; padding: 10px 4px; }
.wc-clip-left .wc-bar { border-right: none; border-radius: 8px 0 0 8px; flex-direction: column; padding: 10px 4px; }
.actions-wrap { position: relative; flex-shrink: 0; }
.actions-popover {
min-width: 160px;
padding: var(--sp-1);
display: flex;
flex-direction: column;
gap: 1px;
}
.wc-icon-btn { display: flex; align-items: center; justify-content: center; width: 26px; height: 24px; border-radius: var(--radius-sm); background: none; border: none; color: var(--text-muted); cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
.wc-icon-btn:hover { color: var(--text-primary); background: var(--bg-overlay); }
.action-row {
display: flex;
align-items: center;
gap: var(--sp-2);
width: 100%;
padding: 7px var(--sp-2);
border-radius: var(--radius-md);
background: none;
border: none;
color: var(--text-secondary);
font-size: var(--text-sm);
cursor: pointer;
text-align: left;
transition: background var(--t-fast), color var(--t-fast);
}
.action-row:hover { background: var(--bg-overlay); color: var(--text-primary); }
.action-row svg, .action-row :global(svg) { flex-shrink: 0; color: var(--text-faint); }
.action-row:hover svg, .action-row:hover :global(svg) { color: var(--text-muted); }
.bar-middle { flex: 1; display: flex; flex-direction: column; align-items: center; width: 100%; min-height: 0; padding: var(--sp-1) 0; overflow: hidden; }
.action-divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) 0; }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
.dl-popover { min-width: 220px; padding: var(--sp-2); display: flex; flex-direction: column; gap: 1px; }
.dl-title { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: 2px var(--sp-2) var(--sp-2); border-bottom: 1px solid var(--border-dim); margin-bottom: var(--sp-1); }
.dl-option { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; width: 100%; padding: 7px var(--sp-2); border-radius: var(--radius-md); font-size: var(--text-sm); color: var(--text-secondary); background: none; border: none; cursor: pointer; text-align: left; transition: background var(--t-fast), color var(--t-fast); }
.dl-option:hover:not(:disabled) { background: var(--bg-overlay); color: var(--text-primary); }
.dl-option:disabled { opacity: 0.3; cursor: default; }
.dl-sub { font-size: var(--text-xs); color: var(--text-faint); }
.dl-row { display: flex; align-items: center; gap: var(--sp-2); }
.dl-stepper { display: flex; align-items: center; gap: 2px; background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); overflow: hidden; flex-shrink: 0; }
.dl-step-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 28px; font-size: var(--text-base); color: var(--text-muted); background: none; border: none; cursor: pointer; line-height: 1; transition: color var(--t-fast), background var(--t-fast); }
.dl-step-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
.dl-step-btn:disabled { opacity: 0.25; cursor: default; }
.dl-step-val { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); min-width: 24px; text-align: center; letter-spacing: var(--tracking-wide); }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.96) } to { opacity: 1; transform: scale(1) } }
</style>
+13 -5
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { readerState } from "$lib/state/reader.svelte";
import { settingsState } from "$lib/state/settings.svelte";
import { getAdapter } from "$lib/request-manager";
import type { Chapter } from "$lib/types";
@@ -7,11 +8,12 @@
showResumeBanner: boolean;
resumePage: number;
resumeFading: boolean;
adjacent: { remaining: Chapter[] };
adjacent: { prev: Chapter | null; next: Chapter | null; remaining: Chapter[] };
onDismissResume: () => void;
barPosition: "top" | "left" | "right";
}
const { showResumeBanner, resumePage, resumeFading, adjacent, onDismissResume }: Props = $props();
const { showResumeBanner, resumePage, resumeFading, adjacent, onDismissResume, barPosition }: Props = $props();
async function gqlMutation(query: string, variables: Record<string, unknown>): Promise<void> {
const base = settingsState.settings.serverUrl ?? "http://localhost:4567";
@@ -53,7 +55,7 @@
{#if readerState.dlOpen && readerState.activeChapter}
{@const chapter = readerState.activeChapter}
<div class="dl-backdrop" role="presentation" onclick={() => readerState.dlOpen = false}>
<div class="dl-backdrop dl-backdrop-{barPosition}" role="presentation" onclick={() => readerState.dlOpen = false}>
<div class="dl-modal" role="presentation" onclick={(e) => e.stopPropagation()}>
<p class="dl-title">Download</p>
@@ -91,8 +93,14 @@
@keyframes bannerIn { from { opacity: 0; translate: -50% -6px; scale: 0.97; } to { opacity: 1; translate: -50% 0; scale: 1; } }
@keyframes bannerOut { from { opacity: 1; translate: -50% 0; scale: 1; } to { opacity: 0; translate: -50% -4px; scale: 0.97; } }
.dl-backdrop { position: fixed; inset: 0; z-index: calc(var(--z-reader) + 10); display: flex; align-items: flex-start; justify-content: flex-end; padding: 48px var(--sp-4) 0; }
.dl-modal { background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-xl); padding: var(--sp-3); min-width: 210px; display: flex; flex-direction: column; gap: var(--sp-1); box-shadow: 0 8px 32px rgba(0,0,0,0.6); animation: scaleIn 0.12s ease both; transform-origin: top right; }
.dl-backdrop { position: fixed; inset: 0; z-index: calc(var(--z-reader) + 10); display: flex; padding: var(--sp-4); }
.dl-backdrop-top { align-items: flex-start; justify-content: flex-end; padding-top: 52px; padding-right: var(--sp-4); }
.dl-backdrop-left { align-items: flex-end; justify-content: flex-start; padding-bottom: var(--sp-4); padding-left: 52px; }
.dl-backdrop-right { align-items: flex-end; justify-content: flex-end; padding-bottom: var(--sp-4); padding-right: 52px; }
.dl-modal { background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-xl); padding: var(--sp-3); min-width: 210px; display: flex; flex-direction: column; gap: var(--sp-1); box-shadow: 0 8px 32px rgba(0,0,0,0.6); animation: scaleIn 0.12s ease both; }
.dl-backdrop-top .dl-modal { transform-origin: top right; }
.dl-backdrop-left .dl-modal { transform-origin: bottom left; }
.dl-backdrop-right .dl-modal { transform-origin: bottom right; }
.dl-title { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: 2px var(--sp-2) var(--sp-2); border-bottom: 1px solid var(--border-dim); margin-bottom: var(--sp-1); }
.dl-option { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); font-size: var(--text-sm); color: var(--text-secondary); background: none; border: none; cursor: pointer; text-align: left; transition: background var(--t-fast), color var(--t-fast); }
.dl-option:hover:not(:disabled) { background: var(--bg-overlay); color: var(--text-primary); }
@@ -402,7 +402,7 @@
position: fixed;
inset: 0;
z-index: calc(var(--z-reader) + 20);
background: rgba(0, 0, 0, 0.35);
background: rgba(0, 0, 0, 0.18);
}
.panel {
@@ -412,11 +412,11 @@
bottom: 0;
width: 320px;
z-index: calc(var(--z-reader) + 21);
background: var(--bg-surface);
border-left: 1px solid var(--border-base);
background: var(--bg-void);
border-left: 1px solid var(--border-dim);
display: flex;
flex-direction: column;
box-shadow: -12px 0 40px rgba(0, 0, 0, 0.5);
box-shadow: -8px 0 32px rgba(0, 0, 0, 0.35);
}
.panel-header {
@@ -432,8 +432,8 @@
.panel-title {
font-family: var(--font-ui);
font-size: var(--text-sm);
font-weight: var(--weight-medium);
color: var(--text-primary);
font-weight: var(--weight-regular);
color: var(--text-secondary);
letter-spacing: var(--tracking-tight);
}
@@ -505,12 +505,12 @@
padding: var(--sp-2) var(--sp-1);
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-overlay);
color: var(--text-muted);
background: transparent;
color: var(--text-faint);
cursor: pointer;
transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast);
}
.option-tile:hover { color: var(--text-primary); background: var(--bg-raised); border-color: var(--border-strong); }
.option-tile:hover { color: var(--text-secondary); background: var(--bg-overlay); border-color: var(--border-base); }
.option-tile.active { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
.tile-icon { display: flex; align-items: center; justify-content: center; }
@@ -530,12 +530,12 @@
padding: var(--sp-2) var(--sp-1);
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-overlay);
color: var(--text-muted);
background: transparent;
color: var(--text-faint);
cursor: pointer;
transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast);
}
.bar-tile:hover { color: var(--text-primary); background: var(--bg-raised); border-color: var(--border-strong); }
.bar-tile:hover { color: var(--text-secondary); background: var(--bg-overlay); border-color: var(--border-base); }
.bar-tile.active { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
.bar-tile-preview {
@@ -577,7 +577,7 @@
.toggle-label {
font-size: var(--text-xs);
color: var(--text-secondary);
color: var(--text-muted);
}
.toggle-badge {
@@ -629,19 +629,19 @@
padding: var(--sp-2) var(--sp-3);
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
background: var(--bg-overlay);
color: var(--text-muted);
background: transparent;
color: var(--text-faint);
font-size: var(--text-xs);
cursor: pointer;
transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast);
}
.dir-btn:hover { color: var(--text-primary); background: var(--bg-raised); border-color: var(--border-strong); }
.dir-btn:hover { color: var(--text-secondary); background: var(--bg-overlay); border-color: var(--border-base); }
.dir-btn.active { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
.zoom-readout {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-secondary);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
}
@@ -659,14 +659,14 @@
height: 28px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-dim);
background: var(--bg-overlay);
color: var(--text-muted);
background: transparent;
color: var(--text-faint);
font-size: var(--text-base);
line-height: 1;
flex-shrink: 0;
transition: color var(--t-fast), background var(--t-fast);
}
.zoom-step:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
.zoom-step:hover:not(:disabled) { color: var(--text-secondary); background: var(--bg-overlay); }
.zoom-step:disabled { opacity: 0.25; cursor: default; }
.zoom-slider {
@@ -742,8 +742,8 @@
.preset-name {
font-size: var(--text-xs);
color: var(--text-secondary);
font-weight: var(--weight-medium);
color: var(--text-muted);
font-weight: var(--weight-regular);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -34,22 +34,53 @@
const hValue = $derived(rtl ? sliderMax - sliderPage + 1 : sliderPage);
const hPct = $derived(`--pct:${sliderPct}%`);
const vPct = $derived(`--pct:${sliderPct}%`);
function handleH(e: Event) {
const raw = Number((e.target as HTMLInputElement).value);
onJumpToPage(rtl ? sliderMax - raw + 1 : raw);
}
function handleV(e: Event) {
onJumpToPage(Number((e.target as HTMLInputElement).value));
}
function markerPct(pageNumber: number, forRtl = false): number {
if (sliderMax <= 1) return 0;
const ord = forRtl ? sliderMax - pageNumber + 1 : pageNumber;
return ((ord - 1) / (sliderMax - 1)) * 100;
}
// Custom vertical slider
let trackEl = $state<HTMLDivElement | null>(null);
let dragging = $state(false);
function pctFromPointer(clientY: number): number {
if (!trackEl) return 0;
const rect = trackEl.getBoundingClientRect();
return Math.max(0, Math.min(1, (clientY - rect.top) / rect.height));
}
function pageFromPct(pct: number): number {
return Math.round(1 + pct * (sliderMax - 1));
}
function handleTrackPointerDown(e: PointerEvent) {
if (e.button !== 0) return;
e.preventDefault();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
dragging = true;
readerState.sliderDragging = true;
const pct = pctFromPointer(e.clientY);
onJumpToPage(pageFromPct(pct));
}
function handleTrackPointerMove(e: PointerEvent) {
if (!dragging) return;
const pct = pctFromPointer(e.clientY);
onJumpToPage(pageFromPct(pct));
}
function handleTrackPointerUp(e: PointerEvent) {
if (!dragging) return;
dragging = false;
readerState.sliderDragging = false;
}
</script>
{#if !isVertical}
@@ -103,43 +134,52 @@
<ArrowRight size={13} weight="light" />
</button>
</div>
{:else}
<div class="vbar-progress" class:hidden={!uiVisible} class:vbar-right={barPosition === "right"}>
<div class="vbar-progress" class:hidden={!uiVisible}>
{#if sliderMax > 1}
<div
class="vslider-wrap"
bind:this={trackEl}
role="slider"
aria-valuenow={sliderPage}
aria-valuemin={1}
aria-valuemax={sliderMax}
tabindex="0"
onmouseenter={() => readerState.sliderHover = true}
onmouseleave={() => readerState.sliderHover = false}
onmouseleave={() => { if (!dragging) readerState.sliderHover = false; }}
onpointerdown={handleTrackPointerDown}
onpointermove={handleTrackPointerMove}
onpointerup={handleTrackPointerUp}
onpointercancel={handleTrackPointerUp}
>
<input
type="range"
class="v-range"
style={vPct}
min={1}
max={sliderMax}
value={sliderPage}
oninput={handleV}
onmousedown={() => readerState.sliderDragging = true}
onmouseup={() => readerState.sliderDragging = false}
/>
<div class="vtrack">
<div class="vtrack-fill" style="height:{sliderPct}%"></div>
</div>
<div class="vslider-markers" aria-hidden="true">
{#if currentBookmark && currentBookmark.chapterId === displayChapter?.id}
{@const bPct = sliderMax > 1 ? ((currentBookmark.pageNumber - 1) / (sliderMax - 1)) * 100 : 0}
<div class="vslider-checkpoint bookmark-checkpoint"
style="top:{bPct}%"
style="top:{markerPct(currentBookmark.pageNumber)}%"
title="Bookmark: Page {currentBookmark.pageNumber}">
</div>
{/if}
{#each activeChapterMarkers as m (m.id)}
{@const mPct = sliderMax > 1 ? ((m.pageNumber - 1) / (sliderMax - 1)) * 100 : 0}
<div class="vslider-checkpoint marker-checkpoint"
style="top:{mPct}%;background:{MARKER_COLOR_HEX[m.color]}"
style="top:{markerPct(m.pageNumber)}%;background:{MARKER_COLOR_HEX[m.color]}"
title="{m.note ? m.note : 'Marker'} · Page {m.pageNumber}">
</div>
{/each}
</div>
<div class="vthumb" style="top:{sliderPct}%" class:dragging></div>
{#if readerState.sliderHover || readerState.sliderDragging}
<div class="vslider-tooltip" style="top:{sliderPct}%" class:tooltip-right={barPosition === "right"}>
<div
class="vslider-tooltip"
class:tooltip-right={barPosition === "right"}
style="top:{sliderPct}%"
>
{sliderPage} / {sliderMax}
</div>
{/if}
@@ -179,21 +219,95 @@
.marker-checkpoint { opacity: 0.85; }
.slider-tooltip { position: absolute; bottom: calc(100% + 2px); transform: translateX(-50%); background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-sm); padding: 2px 6px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-secondary); white-space: nowrap; pointer-events: none; z-index: 10; letter-spacing: var(--tracking-wide); }
.vbar-progress { display: flex; flex-direction: column; align-items: center; justify-content: center; flex: 1; width: 100%; padding: var(--sp-2) 0; transition: opacity 0.25s ease; pointer-events: none; }
.vbar-progress {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
width: 100%;
min-height: 0;
padding: var(--sp-2) 0;
transition: opacity 0.25s ease;
pointer-events: none;
}
.vbar-progress.hidden { opacity: 0; }
.vslider-wrap { flex: 1; position: relative; display: flex; flex-direction: column; align-items: center; width: 36px; pointer-events: all; margin: var(--sp-1) 0; }
.vslider-wrap {
flex: 1;
width: 100%;
min-height: 0;
position: relative;
display: flex;
justify-content: center;
pointer-events: all;
cursor: pointer;
touch-action: none;
}
.vslider-wrap:focus { outline: none; }
.v-range { -webkit-appearance: slider-vertical; appearance: slider-vertical; writing-mode: vertical-lr; direction: rtl; width: 34px; height: 100%; background: transparent; cursor: pointer; position: relative; z-index: 2; margin: 0; padding: 0; }
.v-range::-webkit-slider-runnable-track { width: 5px; background: linear-gradient(to bottom, var(--accent-fg) var(--pct, 0%), var(--border-strong) var(--pct, 0%)); border-radius: 3px; transition: width 0.15s ease, background 0.05s linear; }
.v-range:hover::-webkit-slider-runnable-track,
.v-range:active::-webkit-slider-runnable-track { width: 7px; }
.v-range::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 14px; border-radius: 50%; background: var(--accent-fg); box-shadow: 0 0 0 2px rgba(0,0,0,0.5); margin-left: -4.5px; transition: transform var(--t-fast); }
.v-range:hover::-webkit-slider-thumb,
.v-range:active::-webkit-slider-thumb { transform: scale(1.3); }
.vtrack {
width: 4px;
height: 100%;
border-radius: 2px;
background: var(--border-strong);
position: relative;
overflow: hidden;
flex-shrink: 0;
transition: width 0.15s ease;
}
.vslider-wrap:hover .vtrack { width: 6px; }
.vtrack-fill {
position: absolute;
top: 0;
left: 0;
right: 0;
border-radius: 2px;
background: var(--accent-fg);
transition: height 0.05s linear;
}
.vthumb {
position: absolute;
left: 50%;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--accent-fg);
box-shadow: 0 0 0 2px rgba(0,0,0,0.5);
transform: translate(-50%, -50%);
pointer-events: none;
transition: transform var(--t-fast);
}
.vslider-wrap:hover .vthumb,
.vthumb.dragging { transform: translate(-50%, -50%) scale(1.3); }
.vslider-markers { position: absolute; inset: 0; pointer-events: none; }
.vslider-checkpoint { position: absolute; left: 50%; transform: translate(-50%, -50%); width: 12px; height: 5px; border-radius: 2px; }
.vslider-tooltip { position: absolute; left: calc(100% + 6px); transform: translateY(-50%); background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-sm); padding: 2px 6px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-secondary); white-space: nowrap; pointer-events: none; z-index: 10; letter-spacing: var(--tracking-wide); }
.vslider-tooltip.tooltip-right { left: auto; right: calc(100% + 6px); }
.vslider-checkpoint {
position: absolute;
left: 50%;
transform: translate(-50%, -50%);
width: 10px;
height: 4px;
border-radius: 2px;
}
.vslider-tooltip {
position: absolute;
left: calc(100% + 8px);
transform: translateY(-50%);
background: var(--bg-raised);
border: 1px solid var(--border-base);
border-radius: var(--radius-sm);
padding: 2px 6px;
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-secondary);
white-space: nowrap;
pointer-events: none;
z-index: 10;
letter-spacing: var(--tracking-wide);
}
.vslider-tooltip.tooltip-right { left: auto; right: calc(100% + 8px); }
</style>
+24 -11
View File
@@ -2,7 +2,7 @@
import { Books, ClockCounterClockwise, Clock, BookOpen, Fire, TrendUp } from 'phosphor-svelte'
import Thumbnail from '$lib/components/shared/manga/Thumbnail.svelte'
import { timeAgo, formatReadTime } from '$lib/core/util'
import type { HistorySession, HistoryGroup } from './lib/recentHistory'
import type { HistoryGroup, ReadSession } from './lib/recentHistory'
interface Stats {
currentStreakDays: number
@@ -17,10 +17,19 @@
historySearch: string
stats: Stats
thumbFor: (mangaId: number, fallback: string) => string
onOpenSeries: (session: HistorySession) => void
onOpenSeries: (session: ReadSession) => void
}
let { groups, hasHistory, historySearch, stats, thumbFor, onOpenSeries }: Props = $props()
function formatDuration(ms: number): string {
const totalMin = Math.round(ms / 60_000)
if (totalMin < 1) return '< 1 min'
if (totalMin < 60) return `${totalMin} min`
const h = Math.floor(totalMin / 60)
const m = totalMin % 60
return m > 0 ? `${h}h ${m}m` : `${h}h`
}
</script>
<div class="root">
@@ -79,7 +88,7 @@
<div class="day-rule"></div>
</div>
<div class="session-list">
{#each items as session (session.latestChapterId)}
{#each items as session (session.id)}
<button class="session-row" onclick={() => onOpenSeries(session)}>
<div class="thumb-wrap">
<Thumbnail
@@ -87,24 +96,27 @@
alt={session.mangaTitle}
class="thumb"
/>
{#if session.chapterCount > 1}
<span class="session-count">{session.chapterCount}</span>
{#if session.chaptersSpanned > 1}
<span class="session-count">{session.chaptersSpanned}</span>
{/if}
</div>
<div class="session-info">
<span class="session-title">{session.mangaTitle}</span>
<span class="session-chapter">
{#if session.chapterCount > 1}
{session.firstChapterName}<span class="ch-arrow"></span>{session.latestChapterName}
{#if session.chaptersSpanned > 1}
{session.startChapterName}<span class="ch-arrow"></span>{session.endChapterName}
{:else}
{session.latestChapterName}
{#if session.latestPageNumber > 1}
<span class="ch-page">· p.{session.latestPageNumber}</span>
{session.endChapterName}
{#if session.endPage > 1}
<span class="ch-page">· p.{session.endPage}</span>
{/if}
{/if}
{#if session.durationMs >= 60_000}
<span class="ch-duration">· {formatDuration(session.durationMs)}</span>
{/if}
</span>
</div>
<span class="session-time">{timeAgo(session.readAt)}</span>
<span class="session-time">{timeAgo(session.endedAt)}</span>
</button>
{/each}
</div>
@@ -178,6 +190,7 @@
}
.ch-arrow { color: var(--text-faint); opacity: 0.35; flex-shrink: 0; }
.ch-page { color: var(--text-faint); opacity: 0.5; flex-shrink: 0; }
.ch-duration { color: var(--text-faint); opacity: 0.5; flex-shrink: 0; }
.session-time {
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint);
letter-spacing: var(--tracking-wide); flex-shrink: 0; white-space: nowrap; opacity: 0.45;
+10 -9
View File
@@ -3,10 +3,11 @@
import { getAdapter } from '$lib/request-manager'
import { cache, CACHE_KEYS, CACHE_GROUPS } from '$lib/core/cache/queryCache'
import { homeState, clearHistory } from '$lib/state/home.svelte'
import { historyState } from '$lib/state/history.svelte'
import { setActiveManga, openReader, setPreviewManga } from '$lib/state/series.svelte'
import { addToast } from '$lib/state/notifications.svelte'
import { buildChapterList } from '$lib/components/series/lib/chapterList'
import { buildSessions, groupByDay } from './lib/recentHistory'
import { groupByDay } from './lib/recentHistory'
import { fetchedAtMs, parseServerTimestamp, groupUpdatesByDay } from './lib/recentUpdates'
import RecentToolbar from './RecentToolbar.svelte'
import UpdatesTab from './UpdatesTab.svelte'
@@ -69,13 +70,13 @@
)
const filteredHistory = $derived(historySearch.trim()
? homeState.history.filter(e =>
e.mangaTitle.toLowerCase().includes(historySearch.toLowerCase()) ||
e.chapterName.toLowerCase().includes(historySearch.toLowerCase())
? historyState.sessions.filter(s =>
s.mangaTitle.toLowerCase().includes(historySearch.toLowerCase()) ||
s.endChapterName.toLowerCase().includes(historySearch.toLowerCase())
)
: homeState.history)
: historyState.sessions)
const historyGroups = $derived(groupByDay(buildSessions(filteredHistory)))
const historyGroups = $derived(groupByDay(filteredHistory))
function applyUpdateStatus(statusRes: { isRunning?: boolean; finishedJobs?: number; totalJobs?: number; lastUpdated?: unknown } | null) {
if (!statusRes) return
@@ -201,7 +202,7 @@
{historySearch}
{updatesSearch}
{historyConfirmClear}
hasHistory={homeState.history.length > 0}
hasHistory={historyState.sessions.length > 0}
{updatesLoading}
onTabChange={(t) => tab = t}
onHistorySearchChange={(v) => historySearch = v}
@@ -228,9 +229,9 @@
{:else}
<HistoryTab
groups={historyGroups}
hasHistory={homeState.history.length > 0}
hasHistory={historyState.sessions.length > 0}
{historySearch}
stats={homeState.stats}
stats={historyState.stats}
{thumbFor}
onOpenSeries={(session) => setPreviewManga({
id: session.mangaId,
+8 -58
View File
@@ -1,69 +1,19 @@
import { dayLabel } from '$lib/core/util'
import type { ReadSession } from '$lib/types/history'
export interface HistorySession {
mangaId: number
mangaTitle: string
thumbnailUrl: string
latestChapterId: number
latestChapterName: string
latestPageNumber: number
firstChapterName: string
chapterCount: number
readAt: number
}
export type { ReadSession }
export interface HistoryGroup {
label: string
items: HistorySession[]
items: ReadSession[]
}
const SESSION_GAP_MS = 30 * 60 * 1_000
export function buildSessions(entries: {
mangaId: number
mangaTitle: string
thumbnailUrl: string
chapterId: number
chapterName: string
pageNumber: number
readAt: number
}[]): HistorySession[] {
if (!entries.length) return []
const sessions: HistorySession[] = []
let i = 0
while (i < entries.length) {
const anchor = entries[i]
const group = [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
}
export function groupByDay(sessions: HistorySession[]): HistoryGroup[] {
const map = new Map<string, HistorySession[]>()
export function groupByDay(sessions: ReadSession[]): HistoryGroup[] {
const map = new Map<string, ReadSession[]>()
for (const s of sessions) {
const l = dayLabel(s.readAt)
if (!map.has(l)) map.set(l, [])
map.get(l)!.push(s)
const label = dayLabel(s.endedAt)
if (!map.has(label)) map.set(label, [])
map.get(label)!.push(s)
}
return Array.from(map.entries()).map(([label, items]) => ({ label, items }))
}
@@ -5,7 +5,7 @@
import { toast } from '$lib/state/notifications.svelte'
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
import { exportAppData, importAppData } from '$lib/core/backup'
import { loadBackups, persistBackups, persistSettings, persistLibrary } from '$lib/core/persistence/persist'
import { loadBackups, saveBackups, saveSettings, saveLibrary } from '$lib/core/persistence/persist'
import type { BackupEntry } from '$lib/core/persistence/persist'
import { DEFAULT_SETTINGS } from '$lib/types/settings'
import { DEFAULT_READING_STATS } from '$lib/types/history'
@@ -92,11 +92,11 @@
await clearAllCaches()
break
case 'reading-history':
await persistLibrary({ history: [], bookmarks: [], markers: [], readLog: [], readingStats: DEFAULT_READING_STATS, dailyReadCounts: {} })
await saveLibrary({ sessions: [], bookmarks: [], markers: [], dailyReadCounts: {} })
break
case 'moku-settings':
localStorage.clear()
await persistSettings({ settings: DEFAULT_SETTINGS, storeVersion: 1 })
await saveSettings({ settings: DEFAULT_SETTINGS, storeVersion: 2 })
patchReset(key, { state: 'done' })
await showExitCountdown()
platformService.exitApp()
@@ -295,7 +295,7 @@
}
async function saveBackupList() {
await persistBackups(backupList.map(({ url, name }) => ({ url, name })))
await saveBackups(backupList.map(({ url, name }) => ({ url, name })))
}
async function createBackup() {
+25 -27
View File
@@ -1,8 +1,8 @@
import { invoke } from "@tauri-apps/api/core";
import {
persistSettings,
persistLibrary,
persistUpdates,
saveSettings,
saveLibrary,
saveUpdates,
} from "$lib/core/persistence/persist";
const STORE_FILES = ["settings.json", "library.json", "updates.json"] as const;
@@ -37,19 +37,17 @@ export async function importAppData(): Promise<void> {
const u = decode("updates.json");
await Promise.all([
persistSettings({
saveSettings({
storeVersion: s.storeVersion ?? 2,
settings: s.settings ?? null,
storeVersion: s.storeVersion ?? 1,
}),
persistLibrary({
history: l.history ?? [],
saveLibrary({
sessions: l.sessions ?? [],
bookmarks: l.bookmarks ?? [],
markers: l.markers ?? [],
readLog: l.readLog ?? [],
readingStats: l.readingStats ?? null,
dailyReadCounts: l.dailyReadCounts ?? {},
}),
persistUpdates({
saveUpdates({
libraryUpdates: u.libraryUpdates ?? [],
lastLibraryRefresh: u.lastLibraryRefresh ?? 0,
acknowledgedUpdateIds: u.acknowledgedUpdateIds ?? [],
@@ -60,6 +58,23 @@ export async function importAppData(): Promise<void> {
invoke("exit_app");
}
export async function autoBackupAppData(): Promise<void> {
try {
const entries: [string, string][] = await invoke("read_store_files", {
names: [...STORE_FILES],
});
const zip = buildZip(
entries.map(([name, content]) => ({
name,
bytes: new TextEncoder().encode(content),
}))
);
await invoke("auto_backup_app_data", { bytes: Array.from(zip) });
} catch (e) {
console.warn("[moku] auto-backup failed:", e);
}
}
function showExitModal(): Promise<void> {
return new Promise(resolve => {
const backdrop = document.createElement("div");
@@ -123,23 +138,6 @@ function showExitModal(): Promise<void> {
});
}
export async function autoBackupAppData(): Promise<void> {
try {
const entries: [string, string][] = await invoke("read_store_files", {
names: [...STORE_FILES],
});
const zip = buildZip(
entries.map(([name, content]) => ({
name,
bytes: new TextEncoder().encode(content),
}))
);
await invoke("auto_backup_app_data", { bytes: Array.from(zip) });
} catch (e) {
console.warn("[moku] auto-backup failed:", e);
}
}
function crc32(data: Uint8Array): number {
let crc = 0xffffffff;
for (const byte of data) {
+66
View File
@@ -0,0 +1,66 @@
import { platformService } from '$lib/platform-service'
import type { Manga } from '$lib/types/manga'
import type { Chapter } from '$lib/types/chapter'
const APP_BUTTONS = [
{ label: 'GitHub', url: 'https://github.com/moku-project/Moku' },
{ label: 'Discord', url: 'https://discord.gg/Jq3pwuNqPp' },
]
const FALLBACK_IMAGE = 'moku_logo'
let sessionStart: number | null = null
function isPublicUrl(url: string | null | undefined): boolean {
return typeof url === 'string' && url.startsWith('https://')
}
function trunc(s: string, max = 128): string {
return s.length <= max ? s : `${s.slice(0, max - 1)}`
}
function formatChapter(chapter: Chapter): string {
const n = chapter.chapterNumber
return `Chapter ${Number.isInteger(n) ? n : n.toFixed(1)}`
}
export async function initRpc(): Promise<void> {
if (!platformService.isSupported('discord-rpc')) return
sessionStart = Date.now()
}
export async function destroyRpc(): Promise<void> {
if (!platformService.isSupported('discord-rpc')) return
sessionStart = null
}
export async function setReading(manga: Manga, chapter: Chapter): Promise<void> {
if (!platformService.isSupported('discord-rpc')) return
await platformService.setDiscordPresence({
details: trunc(manga.title),
state: `${formatChapter(chapter)} · Reading`,
timestamps: { start: sessionStart ?? Date.now() },
assets: {
largeImage: isPublicUrl(manga.thumbnailUrl) ? manga.thumbnailUrl : FALLBACK_IMAGE,
largeText: trunc(manga.title),
smallImage: FALLBACK_IMAGE,
smallText: 'Moku',
},
buttons: APP_BUTTONS,
})
}
export async function setIdle(): Promise<void> {
if (!platformService.isSupported('discord-rpc')) return
await platformService.setDiscordPresence({
details: 'Browsing',
timestamps: { start: sessionStart ?? Date.now() },
assets: { largeImage: FALLBACK_IMAGE, largeText: 'Moku' },
buttons: APP_BUTTONS,
})
}
export async function clearReading(): Promise<void> {
if (!platformService.isSupported('discord-rpc')) return
await platformService.clearDiscordPresence()
}
+136 -146
View File
@@ -1,166 +1,156 @@
import { LazyStore } from "@tauri-apps/plugin-store";
import { platformService } from '$lib/platform-service'
import type { ReadSession } from '$lib/types/history'
import type { BookmarkEntry, MarkerEntry } from '$lib/types/history'
const settingsStore = new LazyStore("settings.json", { autoSave: false });
const libraryStore = new LazyStore("library.json", { autoSave: false });
const updatesStore = new LazyStore("updates.json", { autoSave: false });
const backupsStore = new LazyStore("backups.json", { autoSave: false });
const STORE_VERSION = 2
export interface PersistedData {
settings: any;
storeVersion: number | null;
history: any[];
bookmarks: any[];
markers: any[];
readLog: any[];
readingStats: any | null;
dailyReadCounts: Record<string, number>;
libraryUpdates: any[];
lastLibraryRefresh: number;
acknowledgedUpdateIds: number[];
export interface PersistedSettings {
storeVersion: number
settings: unknown
}
export async function loadAllStores(): Promise<PersistedData> {
const migrated = await migrateFromLocalStorage();
if (migrated) return migrated;
const [sv, s, hist, bk, mk, rl, rs, dc, lu, llr, au] = await Promise.all([
settingsStore.get<number>("storeVersion"),
settingsStore.get<any>("settings"),
libraryStore.get<any[]>("history"),
libraryStore.get<any[]>("bookmarks"),
libraryStore.get<any[]>("markers"),
libraryStore.get<any[]>("readLog"),
libraryStore.get<any>("readingStats"),
libraryStore.get<Record<string, number>>("dailyReadCounts"),
updatesStore.get<any[]>("libraryUpdates"),
updatesStore.get<number>("lastLibraryRefresh"),
updatesStore.get<number[]>("acknowledgedUpdateIds"),
]);
return {
storeVersion: sv ?? null,
settings: s ?? null,
history: hist ?? [],
bookmarks: bk ?? [],
markers: mk ?? [],
readLog: rl ?? [],
readingStats: rs ?? null,
dailyReadCounts: dc ?? {},
libraryUpdates: lu ?? [],
lastLibraryRefresh: llr ?? 0,
acknowledgedUpdateIds: au ?? [],
};
export interface PersistedLibrary {
sessions: ReadSession[]
bookmarks: BookmarkEntry[]
markers: MarkerEntry[]
dailyReadCounts: Record<string, number>
}
async function migrateFromLocalStorage(): Promise<PersistedData | null> {
try {
const raw = localStorage.getItem("moku-store");
if (!raw) return null;
const data = JSON.parse(raw);
export interface PersistedUpdates {
libraryUpdates: unknown[]
lastLibraryRefresh: number
acknowledgedUpdateIds: number[]
}
await Promise.all([
persistSettings({ settings: data.settings ?? null, storeVersion: data.storeVersion ?? 1 }),
persistLibrary({
history: data.history ?? [],
bookmarks: data.bookmarks ?? [],
markers: data.markers ?? [],
readLog: data.readLog ?? [],
readingStats: data.readingStats ?? null,
dailyReadCounts: data.dailyReadCounts ?? {},
}),
persistUpdates({
libraryUpdates: data.libraryUpdates ?? [],
lastLibraryRefresh: data.lastLibraryRefresh ?? 0,
acknowledgedUpdateIds: data.acknowledgedUpdateIds ?? [],
}),
]);
export interface PersistedBackups {
backupList: { url: string; name: string }[]
}
localStorage.removeItem("moku-store");
function migrateLibrary(raw: unknown, fromVersion: number): PersistedLibrary {
const data = (raw ?? {}) as Record<string, unknown>
if (fromVersion < 2) {
const oldHistory = (data.history ?? []) as Array<{
mangaId: number; mangaTitle: string; thumbnailUrl: string
chapterId: number; chapterName: string; chapterNumber?: number
pageNumber?: number; readAt: number
}>
const sessions: ReadSession[] = oldHistory.map(e => ({
id: crypto.randomUUID(),
mangaId: e.mangaId,
mangaTitle: e.mangaTitle,
thumbnailUrl: e.thumbnailUrl,
startChapterId: e.chapterId,
startChapterName: e.chapterName,
endChapterId: e.chapterId,
endChapterName: e.chapterName,
startPage: 1,
endPage: e.pageNumber ?? 1,
startedAt: e.readAt,
endedAt: e.readAt,
durationMs: 0,
chaptersSpanned: 1,
}))
return {
storeVersion: data.storeVersion ?? null,
settings: data.settings ?? null,
history: data.history ?? [],
bookmarks: data.bookmarks ?? [],
markers: data.markers ?? [],
readLog: data.readLog ?? [],
readingStats: data.readingStats ?? null,
dailyReadCounts: data.dailyReadCounts ?? {},
libraryUpdates: data.libraryUpdates ?? [],
lastLibraryRefresh: data.lastLibraryRefresh ?? 0,
acknowledgedUpdateIds: data.acknowledgedUpdateIds ?? [],
};
} catch {
return null;
sessions,
bookmarks: (data.bookmarks ?? []) as BookmarkEntry[],
markers: (data.markers ?? []) as MarkerEntry[],
dailyReadCounts: (data.dailyReadCounts ?? {}) as Record<string, number>,
}
}
return {
sessions: (data.sessions ?? []) as ReadSession[],
bookmarks: (data.bookmarks ?? []) as BookmarkEntry[],
markers: (data.markers ?? []) as MarkerEntry[],
dailyReadCounts: (data.dailyReadCounts ?? {}) as Record<string, number>,
}
}
export async function persistSettings(data: { settings: any; storeVersion: number }) {
await Promise.all([
settingsStore.set("settings", data.settings),
settingsStore.set("storeVersion", data.storeVersion),
]);
await settingsStore.save();
}
export async function loadSettings(): Promise<PersistedSettings> {
const raw = await platformService.loadStore('settings')
const data = (raw ?? {}) as Record<string, unknown>
export async function persistLibrary(data: {
history: any[];
bookmarks: any[];
markers: any[];
readLog: any[];
readingStats: any;
dailyReadCounts: Record<string, number>;
}) {
await Promise.all([
libraryStore.set("history", data.history),
libraryStore.set("bookmarks", data.bookmarks),
libraryStore.set("markers", data.markers),
libraryStore.set("readLog", data.readLog),
libraryStore.set("readingStats", data.readingStats),
libraryStore.set("dailyReadCounts", data.dailyReadCounts),
]);
await libraryStore.save();
}
export async function persistUpdates(data: {
libraryUpdates: any[];
lastLibraryRefresh: number;
acknowledgedUpdateIds: number[];
}) {
await Promise.all([
updatesStore.set("libraryUpdates", data.libraryUpdates),
updatesStore.set("lastLibraryRefresh", data.lastLibraryRefresh),
updatesStore.set("acknowledgedUpdateIds", data.acknowledgedUpdateIds),
]);
await updatesStore.save();
}
export interface BackupEntry { url: string; name: string; }
export async function loadBackups(): Promise<BackupEntry[]> {
const fromStore = await backupsStore.get<BackupEntry[]>("backupList");
if (fromStore) return fromStore;
const legacyRaw = typeof window !== 'undefined' ? localStorage.getItem('moku_settings') : null
if (legacyRaw && !data.settings) {
try {
const raw = localStorage.getItem("moku_backups");
if (!raw) return [];
const migrated: BackupEntry[] = JSON.parse(raw);
await persistBackups(migrated);
localStorage.removeItem("moku_backups");
return migrated;
} catch { return []; }
const legacySettings = JSON.parse(legacyRaw)
localStorage.removeItem('moku_settings')
const result: PersistedSettings = { storeVersion: STORE_VERSION, settings: legacySettings }
await saveSettings(result)
return result
} catch {}
}
return {
storeVersion: (data.storeVersion as number) ?? STORE_VERSION,
settings: data.settings ?? null,
}
}
export async function persistBackups(list: BackupEntry[]): Promise<void> {
await backupsStore.set("backupList", list);
await backupsStore.save();
export async function saveSettings(data: PersistedSettings): Promise<void> {
await platformService.saveStore('settings', data)
}
export async function resetAuthSettings(): Promise<void> {
const current = await settingsStore.get<any>("settings") ?? {};
current.serverAuthMode = "NONE";
current.serverAuthUser = "";
current.serverAuthPass = "";
await settingsStore.set("settings", current);
await settingsStore.save();
localStorage.removeItem("moku-credential-vault");
export async function loadLibrary(): Promise<PersistedLibrary> {
const raw = await platformService.loadStore('library')
const data = (raw ?? {}) as Record<string, unknown>
const version = (data.storeVersion as number) ?? 1
const legacyRaw = typeof window !== 'undefined' ? localStorage.getItem('moku-store') : null
if (legacyRaw && !(data.sessions || data.history)) {
try {
const legacy = JSON.parse(legacyRaw)
const migrated = migrateLibrary(legacy, 1)
localStorage.removeItem('moku-store')
await saveLibrary(migrated)
return migrated
} catch {}
}
return migrateLibrary(raw, version)
}
export async function saveLibrary(data: PersistedLibrary): Promise<void> {
await platformService.saveStore('library', { ...data, storeVersion: STORE_VERSION })
}
export async function loadUpdates(): Promise<PersistedUpdates> {
const raw = await platformService.loadStore('updates')
const data = (raw ?? {}) as Record<string, unknown>
return {
libraryUpdates: (data.libraryUpdates ?? []) as unknown[],
lastLibraryRefresh: (data.lastLibraryRefresh ?? 0) as number,
acknowledgedUpdateIds: (data.acknowledgedUpdateIds ?? []) as number[],
}
}
export async function saveUpdates(data: PersistedUpdates): Promise<void> {
await platformService.saveStore('updates', data)
}
export async function loadBackups(): Promise<{ url: string; name: string }[]> {
const raw = await platformService.loadStore('backups')
const data = (raw ?? {}) as Record<string, unknown>
if (!data.backupList) {
try {
const legacyRaw = typeof window !== 'undefined' ? localStorage.getItem('moku_backups') : null
if (legacyRaw) {
const list = JSON.parse(legacyRaw) as { url: string; name: string }[]
localStorage.removeItem('moku_backups')
await saveBackups(list)
return list
}
} catch {}
return []
}
return data.backupList as { url: string; name: string }[]
}
export async function saveBackups(list: { url: string; name: string }[]): Promise<void> {
await platformService.saveStore('backups', { backupList: list })
}
@@ -14,6 +14,23 @@ export class CapacitorAdapter implements PlatformAdapter {
return supported.includes(feature)
}
async loadStore(key: string): Promise<unknown> {
try {
const { Preferences } = await import('@capacitor/preferences')
const { value } = await Preferences.get({ key: `moku:${key}` })
return value ? JSON.parse(value) : null
} catch {
return null
}
}
async saveStore(key: string, value: unknown): Promise<void> {
try {
const { Preferences } = await import('@capacitor/preferences')
await Preferences.set({ key: `moku:${key}`, value: JSON.stringify(value) })
} catch {}
}
async launchServer(_config: ServerLaunchConfig) {}
async stopServer() {}
async getServerStatus(): Promise<'running' | 'stopped' | 'error'> { return 'stopped' }
@@ -84,4 +101,25 @@ export class CapacitorAdapter implements PlatformAdapter {
async checkForAppUpdate(): Promise<AppUpdateInfo | null> { return null }
async installAppUpdate(): Promise<void> {}
async restartApp(): Promise<void> {}
async getDefaultDownloadsPath(): Promise<string> { return '' }
async getStorageInfo(): Promise<{ manga_bytes: number; total_bytes: number; free_bytes: number; path: string }> {
return { manga_bytes: 0, total_bytes: 0, free_bytes: 0, path: '' }
}
async checkPathExists(_path: string): Promise<boolean> { return false }
async createDirectory(_path: string): Promise<void> {}
async openPath(_path: string): Promise<void> {}
async getAutoBackupDir(): Promise<string> { return '' }
async clearMokuCache(): Promise<void> {}
async clearSuwayomiCache(): Promise<void> {}
async resetSuwayomiData(): Promise<void> {}
async exitApp(): Promise<void> {}
async listReleases() { return [] }
async onUpdateProgress(_cb: (p: { downloaded: number; total: number | null }) => void): Promise<() => void> { return () => {} }
async onUpdateLaunching(_cb: () => void): Promise<() => void> { return () => {} }
async onMigrateProgress(_cb: (p: { done: number; total: number; current: string }) => void): Promise<() => void> { return () => {} }
async migrateDownloads(_src: string, _dst: string): Promise<void> {}
}
+83 -55
View File
@@ -5,6 +5,8 @@ import { open } from '@tauri-apps/plugin-dialog'
import { readFile, writeFile } from '@tauri-apps/plugin-fs'
import { open as openUrl } from '@tauri-apps/plugin-shell'
import { getVersion } from '@tauri-apps/api/app'
import { LazyStore } from '@tauri-apps/plugin-store'
import { connect, disconnect, setActivity, clearActivity } from 'tauri-plugin-discord-rpc-api'
import type {
PlatformAdapter,
PlatformFeature,
@@ -17,9 +19,24 @@ import type {
MigrateProgress,
} from '$lib/platform-adapters/types'
const APP_ID = '1487894643613106298'
const storeCache = new Map<string, LazyStore>()
function getStore(key: string): LazyStore {
if (!storeCache.has(key)) {
storeCache.set(key, new LazyStore(`${key}.json`, { autoSave: false }))
}
return storeCache.get(key)!
}
export class TauriAdapter implements PlatformAdapter {
async init() {
await invoke('init_app')
await connect(APP_ID).catch(() => {})
}
async destroy() {
await disconnect().catch(() => {})
}
isSupported(feature: PlatformFeature): boolean {
@@ -34,16 +51,30 @@ export class TauriAdapter implements PlatformAdapter {
return supported.includes(feature)
}
async loadStore(key: string): Promise<unknown> {
return getStore(key).get<unknown>(key) ?? null
}
async saveStore(key: string, value: unknown): Promise<void> {
const store = getStore(key)
await store.set(key, value)
await store.save()
}
async launchServer(config: ServerLaunchConfig) {
await invoke('launch_server', { config })
await invoke('spawn_server', {
binary: config.binary ?? '',
binaryArgs: config.binaryArgs ?? null,
webUiEnabled: config.webUiEnabled ?? false,
})
}
async stopServer() {
await invoke('stop_server')
await invoke('kill_server')
}
async getServerStatus(): Promise<'running' | 'stopped' | 'error'> {
return invoke('get_server_status')
return 'stopped'
}
async readFile(path: string): Promise<Uint8Array> {
@@ -59,16 +90,37 @@ export class TauriAdapter implements PlatformAdapter {
return typeof result === 'string' ? result : null
}
async checkPathExists(path: string): Promise<boolean> {
return invoke('check_path_exists', { path })
}
async createDirectory(path: string) {
await invoke('create_directory', { path })
}
async openPath(path: string) {
await invoke('open_path', { path })
}
async getDefaultDownloadsPath(): Promise<string> {
return invoke('get_default_downloads_path')
}
async getStorageInfo(downloadsPath: string): Promise<StorageInfo> {
return invoke('get_storage_info', { downloadsPath })
}
async migrateDownloads(src: string, dst: string) {
await invoke('migrate_downloads', { src, dst })
}
async authenticateBiometric(): Promise<boolean> {
return invoke('authenticate_biometric')
try {
await invoke('windows_hello_authenticate', { reason: 'Authenticate to access Moku' })
return true
} catch {
return false
}
async storeCredential(key: string, value: string) {
await invoke('store_credential', { key, value })
}
async getCredential(key: string): Promise<string | null> {
return invoke('get_credential', { key })
}
async setTitle(title: string) {
@@ -94,11 +146,11 @@ export class TauriAdapter implements PlatformAdapter {
}
async setDiscordPresence(presence: DiscordPresence) {
await invoke('set_discord_presence', { presence })
await setActivity(presence).catch(() => {})
}
async clearDiscordPresence() {
await invoke('clear_discord_presence')
await clearActivity().catch(() => {})
}
async getVersion(): Promise<string> {
@@ -109,6 +161,14 @@ export class TauriAdapter implements PlatformAdapter {
await openUrl(url)
}
async restartApp() {
await invoke('restart_app')
}
async exitApp() {
await invoke('exit_app')
}
async checkForAppUpdate(): Promise<AppUpdateInfo | null> {
const releases = await invoke<Array<{ tag_name: string; html_url: string; body: string }>>('list_releases')
const current = await getVersion()
@@ -126,32 +186,21 @@ export class TauriAdapter implements PlatformAdapter {
return { version: latest.replace(/^v/, ''), url: rel.html_url, notes: rel.body }
}
async listReleases(): Promise<ReleaseInfo[]> {
const all = await invoke<ReleaseInfo[]>('list_releases')
return all.filter(r => typeof r.tag_name === 'string' && r.tag_name.trim())
}
async installAppUpdate(tag: string) {
await invoke('download_and_install_update', { tag })
}
async restartApp() {
await invoke('restart_app')
async onUpdateProgress(cb: (p: UpdateProgress) => void): Promise<() => void> {
return listen<UpdateProgress>('update-progress', e => cb(e.payload))
}
async getDefaultDownloadsPath(): Promise<string> {
return invoke('get_default_downloads_path')
}
async getStorageInfo(downloadsPath: string): Promise<StorageInfo> {
return invoke('get_storage_info', { downloadsPath })
}
async checkPathExists(path: string): Promise<boolean> {
return invoke('check_path_exists', { path })
}
async createDirectory(path: string) {
await invoke('create_directory', { path })
}
async openPath(path: string) {
await invoke('open_path', { path })
async onUpdateLaunching(cb: () => void): Promise<() => void> {
return listen('update-launching', cb)
}
async getAutoBackupDir(): Promise<string> {
@@ -170,28 +219,7 @@ export class TauriAdapter implements PlatformAdapter {
await invoke('reset_suwayomi_data')
}
async exitApp() {
await invoke('exit_app')
}
async listReleases(): Promise<ReleaseInfo[]> {
const all = await invoke<ReleaseInfo[]>('list_releases')
return all.filter(r => typeof r.tag_name === 'string' && r.tag_name.trim())
}
async onUpdateProgress(cb: (p: UpdateProgress) => void): Promise<() => void> {
return listen<UpdateProgress>('update-progress', e => cb(e.payload))
}
async onUpdateLaunching(cb: () => void): Promise<() => void> {
return listen('update-launching', cb)
}
async onMigrateProgress(cb: (p: MigrateProgress) => void): Promise<() => void> {
return listen<MigrateProgress>('migrate_progress', e => cb(e.payload))
}
async migrateDownloads(src: string, dst: string) {
await invoke('migrate_downloads', { src, dst })
}
}
+39 -9
View File
@@ -11,10 +11,36 @@ export interface ServerLaunchConfig {
[key: string]: unknown
}
export interface DiscordAssets {
largeImage?: string
largeText?: string
smallImage?: string
smallText?: string
}
export interface DiscordButton {
label: string
url: string
}
export interface DiscordParty {
id?: string
currentSize?: number
maxSize?: number
}
export interface DiscordTimestamps {
start?: number
end?: number
}
export interface DiscordPresence {
state?: string
details?: string
[key: string]: unknown
assets?: DiscordAssets
buttons?: DiscordButton[]
party?: DiscordParty
timestamps?: DiscordTimestamps
}
export interface AppUpdateInfo {
@@ -41,8 +67,17 @@ export interface UpdateProgress {
total: number | null
}
export interface ReleaseInfo {
tag_name: string
name: string
body: string
published_at: string
html_url: string
}
export interface PlatformAdapter {
init(): Promise<void>
destroy(): Promise<void>
isSupported(feature: PlatformFeature): boolean
launchServer(config: ServerLaunchConfig): Promise<void>
@@ -57,6 +92,9 @@ export interface PlatformAdapter {
storeCredential(key: string, value: string): Promise<void>
getCredential(key: string): Promise<string | null>
loadStore(key: string): Promise<unknown>
saveStore(key: string, value: unknown): Promise<void>
setTitle(title: string): Promise<void>
minimize(): Promise<void>
maximize(): Promise<void>
@@ -90,11 +128,3 @@ export interface PlatformAdapter {
onMigrateProgress(cb: (p: MigrateProgress) => void): Promise<() => void>
migrateDownloads(src: string, dst: string): Promise<void>
}
export interface ReleaseInfo {
tag_name: string
name: string
body: string
published_at: string
html_url: string
}
+15
View File
@@ -17,6 +17,21 @@ export class WebAdapter implements PlatformAdapter {
return false
}
async loadStore(key: string): Promise<unknown> {
try {
const raw = localStorage.getItem(`moku:${key}`)
return raw ? JSON.parse(raw) : null
} catch {
return null
}
}
async saveStore(key: string, value: unknown): Promise<void> {
try {
localStorage.setItem(`moku:${key}`, JSON.stringify(value))
} catch {}
}
async launchServer(_config: ServerLaunchConfig) {}
async stopServer() {}
async getServerStatus(): Promise<'running' | 'stopped' | 'error'> { return 'stopped' }
+4
View File
@@ -24,6 +24,7 @@ function get(): PlatformAdapter {
export const platformService = {
isSupported: (f: PlatformFeature) => get().isSupported(f),
init: () => get().init(),
destroy: () => get().destroy(),
launchServer: (c: ServerLaunchConfig) => get().launchServer(c),
stopServer: () => get().stopServer(),
@@ -37,6 +38,9 @@ export const platformService = {
storeCredential: (k: string, v: string) => get().storeCredential(k, v),
getCredential: (k: string) => get().getCredential(k),
loadStore: (key: string) => get().loadStore(key),
saveStore: (key: string, value: unknown) => get().saveStore(key, value),
setTitle: (title: string) => get().setTitle(title),
minimize: () => get().minimize(),
maximize: () => get().maximize(),
+4
View File
@@ -14,6 +14,7 @@ export const boot = $state({
loginPass: '',
sessionExpired: false,
skipped: false,
serverProbeOk: false,
})
let probeGeneration = 0
@@ -22,6 +23,7 @@ function handleProbeSuccess(gen: number) {
if (gen !== probeGeneration) return
boot.failed = false
boot.skipped = false
boot.serverProbeOk = true
appState.authenticated = true
appState.status = 'ready'
}
@@ -56,6 +58,7 @@ export function startProbe(
boot.failed = false
boot.loginRequired = false
boot.skipped = false
boot.serverProbeOk = false
appState.status = 'booting'
let tries = 0
@@ -121,6 +124,7 @@ export async function submitLogin(): Promise<void> {
boot.skipped = false
boot.loginPass = ''
boot.loginError = null
boot.serverProbeOk = true
appState.authenticated = true
appState.status = 'ready'
} catch (e: unknown) {
+202
View File
@@ -0,0 +1,202 @@
import { saveLibrary } from '$lib/core/persistence/persist'
import type { ReadSession, ReadingStats } from '$lib/types/history'
import { DEFAULT_READING_STATS } from '$lib/types/history'
const MAX_SESSIONS = 1000
const SESSION_GAP_MS = 60 * 60 * 1_000
export interface ActiveSession {
id: string
mangaId: number
mangaTitle: string
thumbnailUrl: string
startChapterId: number
startChapterName: string
endChapterId: number
endChapterName: string
startPage: number
endPage: number
startedAt: number
lastTickAt: number
seenChapterIds: Set<number>
}
function dateKey(ms: number): string {
return new Date(ms).toISOString().slice(0, 10)
}
function computeStats(sessions: ReadSession[]): ReadingStats {
if (!sessions.length) return { ...DEFAULT_READING_STATS }
const chapterIds = new Set<number>()
const mangaIds = new Set<number>()
const days = new Set<string>()
let totalMs = 0
let firstReadAt = Infinity
let lastReadAt = 0
for (const s of sessions) {
chapterIds.add(s.endChapterId)
if (s.chaptersSpanned > 1) chapterIds.add(s.startChapterId)
mangaIds.add(s.mangaId)
totalMs += Math.min(s.durationMs, SESSION_GAP_MS)
firstReadAt = Math.min(firstReadAt, s.startedAt)
lastReadAt = Math.max(lastReadAt, s.endedAt)
days.add(dateKey(s.endedAt))
}
const sortedDays = Array.from(days).sort()
let currentStreak = 0
let longestStreak = 0
let streak = 0
const todayKey = dateKey(Date.now())
const yestKey = dateKey(Date.now() - 86_400_000)
const lastDay = sortedDays[sortedDays.length - 1]
const streakActive = lastDay === todayKey || lastDay === yestKey
for (let i = 0; i < sortedDays.length; i++) {
if (i === 0) {
streak = 1
} else {
const prev = new Date(sortedDays[i - 1]).getTime()
const curr = new Date(sortedDays[i]).getTime()
streak = curr - prev <= 86_400_000 * 1.5 ? streak + 1 : 1
}
longestStreak = Math.max(longestStreak, streak)
}
currentStreak = streakActive ? streak : 0
return {
totalChaptersRead: chapterIds.size,
totalMangaRead: mangaIds.size,
totalMinutesRead: Math.round(totalMs / 60_000),
firstReadAt: firstReadAt === Infinity ? 0 : firstReadAt,
lastReadAt,
currentStreakDays: currentStreak,
longestStreakDays: longestStreak,
lastStreakDate: lastDay ?? '',
}
}
class HistoryStore {
sessions = $state<ReadSession[]>([])
dailyReadCounts = $state<Record<string, number>>({})
stats = $state<ReadingStats>({ ...DEFAULT_READING_STATS })
active = $state<ActiveSession | null>(null)
load(sessions: ReadSession[], dailyReadCounts: Record<string, number>) {
this.sessions = sessions
this.dailyReadCounts = dailyReadCounts
this.stats = computeStats(sessions)
}
openSession(
mangaId: number,
mangaTitle: string,
thumbnailUrl: string,
chapterId: number,
chapterName: string,
page: number,
) {
if (this.active) this._commit(Date.now())
this.active = {
id: crypto.randomUUID(),
mangaId,
mangaTitle,
thumbnailUrl,
startChapterId: chapterId,
startChapterName: chapterName,
endChapterId: chapterId,
endChapterName: chapterName,
startPage: page,
endPage: page,
startedAt: Date.now(),
lastTickAt: Date.now(),
seenChapterIds: new Set([chapterId]),
}
}
tickSession(chapterId: number, chapterName: string, page: number) {
if (!this.active) return
const now = Date.now()
if (now - this.active.lastTickAt > SESSION_GAP_MS) {
this._commit(this.active.lastTickAt)
this.openSession(
this.active.mangaId,
this.active.mangaTitle,
this.active.thumbnailUrl,
chapterId,
chapterName,
page,
)
return
}
this.active.lastTickAt = now
this.active.endPage = page
this.active.endChapterId = chapterId
this.active.endChapterName = chapterName
this.active.seenChapterIds.add(chapterId)
}
closeSession() {
if (!this.active) return
this._commit(Date.now())
this.active = null
}
clearHistory() {
this.sessions = []
this.dailyReadCounts = {}
this.stats = { ...DEFAULT_READING_STATS }
void this._persist()
}
private _commit(endedAt: number) {
const a = this.active
if (!a) return
const durationMs = Math.min(endedAt - a.startedAt, SESSION_GAP_MS)
if (durationMs < 1_000) return
const session: ReadSession = {
id: a.id,
mangaId: a.mangaId,
mangaTitle: a.mangaTitle,
thumbnailUrl: a.thumbnailUrl,
startChapterId: a.startChapterId,
startChapterName: a.startChapterName,
endChapterId: a.endChapterId,
endChapterName: a.endChapterName,
startPage: a.startPage,
endPage: a.endPage,
startedAt: a.startedAt,
endedAt,
durationMs,
chaptersSpanned: a.seenChapterIds.size,
}
const day = dateKey(endedAt)
this.dailyReadCounts[day] = (this.dailyReadCounts[day] ?? 0) + 1
this.sessions = [session, ...this.sessions].slice(0, MAX_SESSIONS)
this.stats = computeStats(this.sessions)
void this._persist()
}
private async _persist() {
const bookmarks = (await import('$lib/state/reader.svelte')).readerState.bookmarks
const markers = (await import('$lib/state/reader.svelte')).readerState.markers
await saveLibrary({
sessions: this.sessions,
bookmarks,
markers,
dailyReadCounts: this.dailyReadCounts,
})
}
}
export const historyState = new HistoryStore()
+8 -37
View File
@@ -1,46 +1,17 @@
export interface HistoryEntry {
mangaId: number;
mangaTitle: string;
thumbnailUrl: string;
chapterId: number;
chapterName: string;
chapterNumber: number;
pageNumber: number;
readAt: number;
}
export interface ReadingStats {
currentStreakDays: number;
totalChaptersRead: number;
totalMinutesRead: number;
totalMangaRead: number;
longestStreakDays: number;
}
import { historyState } from '$lib/state/history.svelte'
export const homeState = $state({
history: [] as HistoryEntry[],
dailyReadCounts: {} as Record<string, number>,
stats: {
currentStreakDays: 0,
totalChaptersRead: 0,
totalMinutesRead: 0,
totalMangaRead: 0,
longestStreakDays: 0,
} as ReadingStats,
heroSlots: [null, null, null, null] as [number | null, number | null, number | null, number | null],
});
})
export function getHistoryStats() { return historyState.stats }
export function getHistorySessions() { return historyState.sessions }
export function getHistoryDailyCounts() { return historyState.dailyReadCounts }
export function setHeroSlot(i: 1 | 2 | 3, mangaId: number | null) {
homeState.heroSlots[i] = mangaId;
}
export function recordRead(entry: HistoryEntry) {
homeState.history = [entry, ...homeState.history.filter(e => e.chapterId !== entry.chapterId)];
const dateStr = new Date(entry.readAt).toISOString().slice(0, 10);
homeState.dailyReadCounts[dateStr] = (homeState.dailyReadCounts[dateStr] ?? 0) + 1;
homeState.stats.totalChaptersRead++;
homeState.heroSlots[i] = mangaId
}
export function clearHistory() {
homeState.history = [];
historyState.clearHistory()
}
+2
View File
@@ -52,6 +52,7 @@ class ReaderState {
zoomOpen = $state(false);
winOpen = $state(false);
presetOpen = $state(false);
actionsOpen = $state(false);
nextN = $state(5);
dlBusy = $state(false);
@@ -121,6 +122,7 @@ class ReaderState {
if (this.dlOpen) { this.dlOpen = false; return true; }
if (this.winOpen) { this.winOpen = false; return true; }
if (this.presetOpen) { this.presetOpen = false; return true; }
if (this.actionsOpen) { this.actionsOpen = false; return true; }
return false;
}
+17 -27
View File
@@ -1,38 +1,28 @@
import type { Settings } from "$lib/types/settings";
import { DEFAULT_SETTINGS } from "$lib/types/settings";
import type { Settings } from '$lib/types/settings'
import { DEFAULT_SETTINGS } from '$lib/types/settings'
import { saveSettings } from '$lib/core/persistence/persist'
const KEY = "moku_settings";
export const settingsState = $state({ settings: { ...DEFAULT_SETTINGS } as Settings })
function load(): Settings {
try {
const raw = localStorage.getItem(KEY);
if (raw) return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) };
} catch {}
return { ...DEFAULT_SETTINGS };
}
function save(s: Settings) {
try { localStorage.setItem(KEY, JSON.stringify(s)); } catch {}
}
export const settingsState = $state({ settings: load() });
if (typeof document !== "undefined") {
document.documentElement.style.zoom = String(settingsState.settings.uiZoom ?? 1.0);
export async function loadSettingsIntoState(raw: unknown) {
if (raw && typeof raw === 'object') {
Object.assign(settingsState.settings, raw)
}
if (typeof document !== 'undefined') {
document.documentElement.style.zoom = String(settingsState.settings.uiZoom ?? 1.0)
}
}
export function updateSettings(patch: Partial<Settings>) {
Object.assign(settingsState.settings, patch);
save(settingsState.settings);
Object.assign(settingsState.settings, patch)
void saveSettings({ storeVersion: 2, settings: settingsState.settings })
if (typeof document !== "undefined") {
if (patch.uiZoom !== undefined) {
document.documentElement.style.zoom = String(patch.uiZoom);
}
if (typeof document !== 'undefined' && patch.uiZoom !== undefined) {
document.documentElement.style.zoom = String(patch.uiZoom)
}
}
export function resetSettings() {
settingsState.settings = { ...DEFAULT_SETTINGS };
save(settingsState.settings);
settingsState.settings = { ...DEFAULT_SETTINGS }
void saveSettings({ storeVersion: 2, settings: settingsState.settings })
}
+16 -15
View File
@@ -1,12 +1,3 @@
export interface HistoryEntry {
mangaId: number
mangaTitle: string
thumbnailUrl: string
chapterId: number
chapterName: string
readAt: number
}
export interface BookmarkEntry {
mangaId: number
mangaTitle: string
@@ -18,7 +9,7 @@ export interface BookmarkEntry {
label?: string
}
export type MarkerColor = "yellow" | "red" | "blue" | "green" | "purple"
export type MarkerColor = 'yellow' | 'red' | 'blue' | 'green' | 'purple'
export interface MarkerEntry {
id: string
@@ -34,11 +25,21 @@ export interface MarkerEntry {
updatedAt?: number
}
export interface ReadLogEntry {
export interface ReadSession {
id: string
mangaId: number
chapterId: number
readAt: number
minutes: number
mangaTitle: string
thumbnailUrl: string
startChapterId: number
startChapterName: string
endChapterId: number
endChapterName: string
startPage: number
endPage: number
startedAt: number
endedAt: number
durationMs: number
chaptersSpanned: number
}
export interface ReadingStats {
@@ -60,7 +61,7 @@ export const DEFAULT_READING_STATS: ReadingStats = {
lastReadAt: 0,
currentStreakDays: 0,
longestStreakDays: 0,
lastStreakDate: "",
lastStreakDate: '',
}
export interface LibraryUpdateEntry {
+66 -11
View File
@@ -3,8 +3,12 @@
import { page } from '$app/stores'
import { appState, app } from '$lib/state/app.svelte'
import { notifications } from '$lib/state/notifications.svelte'
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
import { settingsState, loadSettingsIntoState, updateSettings } from '$lib/state/settings.svelte'
import { loadSettings } from '$lib/core/persistence/persist'
import { applyTheme, mountSystemThemeSync } from '$lib/core/theme'
import { initPlatformService, platformService } from '$lib/platform-service'
import { startProbe } from '$lib/state/boot.svelte'
import * as discord from '$lib/core/discord'
import SplashScreen from '$lib/components/chrome/SplashScreen.svelte'
import AuthGate from '$lib/components/chrome/AuthGate.svelte'
import Sidebar from '$lib/components/chrome/Sidebar.svelte'
@@ -30,7 +34,7 @@
}
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
const ringFull = $derived(appState.status !== 'booting')
const ringFull = $derived(appState.status === 'ready' || appState.status === 'auth')
let splashVisible = $state(true)
let bypassed = $state(false)
@@ -47,13 +51,56 @@
const readerContainerized = $derived(settingsState.settings.readerContainerized ?? false)
const strippedLayout = $derived(isReaderRoute && !readerContainerized)
onMount(() => {
onMount(async () => {
if (isTauri) {
const { TauriAdapter } = await import('$lib/platform-adapters/tauri')
initPlatformService(new TauriAdapter())
} else {
const { WebAdapter } = await import('$lib/platform-adapters/web')
initPlatformService(new WebAdapter())
}
await platformService.init()
const persisted = await loadSettings()
await loadSettingsIntoState(persisted.settings)
appState.platform = isTauri ? 'tauri' : 'web'
appState.version = await platformService.getVersion().catch(() => '')
if (isTauri && settingsState.settings.autoStartServer) {
platformService.launchServer({
binary: settingsState.settings.serverBinary,
binary_args: settingsState.settings.serverBinaryArgs,
web_ui_enabled: settingsState.settings.suwayomiWebUI,
}).catch(() => {})
}
if (settingsState.settings.discordRpc) {
await discord.initRpc()
await discord.setIdle()
}
startProbe(
settingsState.settings.serverAuthMode,
settingsState.settings.serverAuthUser,
settingsState.settings.serverAuthPass,
)
polling = true
pollLoop()
applyTheme(
settingsState.settings.theme ?? 'dark',
settingsState.settings.customThemes ?? []
)
return () => {
polling = false
if (pollTimer !== null) { clearTimeout(pollTimer); pollTimer = null }
discord.destroyRpc()
platformService.destroy()
}
})
$effect(() => {
@@ -73,11 +120,6 @@
mountSystemThemeSync(enabled, darkTheme, lightTheme, (id) => updateSettings({ theme: id }))
})
$effect(() => () => {
polling = false
if (pollTimer !== null) { clearTimeout(pollTimer); pollTimer = null }
})
function onSplashReady() { splashVisible = false }
function onSplashBypass() { bypassed = true; splashVisible = false }
@@ -103,10 +145,11 @@
{@render children()}
{:else}
<div class="frame">
<div class="shell">
{#if isTauri}
<TitleBar onClose={() => import('@tauri-apps/api/window').then(m => m.getCurrentWindow().close())} />
<TitleBar onClose={() => platformService.close()} />
{/if}
<div class="padding" class:padding-web={!isTauri}>
<div class="shell">
<div class="body">
<Sidebar />
<main class="main">
@@ -115,6 +158,7 @@
</div>
</div>
</div>
</div>
{/if}
{/if}
@@ -141,13 +185,24 @@
<style>
.frame {
display: flex;
padding: 6px 15px 15px;
flex-direction: column;
width: 100%;
height: 100%;
box-sizing: border-box;
overflow: hidden;
}
.padding {
display: flex;
flex: 1;
padding: 0 15px 15px;
min-height: 0;
}
.padding-web {
padding-top: 15px;
}
.shell {
display: flex;
flex-direction: column;
+2 -256
View File
@@ -1,259 +1,5 @@
<script lang="ts">
import { onMount, untrack } from 'svelte'
import { goto } from '$app/navigation'
import { loadLibrary } from '$lib/request-manager/manga'
import { getAdapter } from '$lib/request-manager'
import { libraryState } from '$lib/state/library.svelte'
import { homeState, setHeroSlot } from '$lib/state/home.svelte'
import type { HistoryEntry } from '$lib/state/home.svelte'
import HeroStage from '$lib/components/home/HeroStage.svelte'
import HeroSlotPicker from '$lib/components/home/HeroSlotPicker.svelte'
import ActivityFeed from '$lib/components/home/ActivityFeed.svelte'
import ActivityHeatmap from '$lib/components/home/ActivityHeatmap.svelte'
import RecsRow from '$lib/components/home/RecsRow.svelte'
import StatsGrid from '$lib/components/home/StatsGrid.svelte'
import type { Manga, Chapter } from '$lib/types'
const TOTAL_SLOTS = 4
interface HeroSlot {
kind: 'continue' | 'pinned' | 'empty'
entry?: HistoryEntry
manga?: Manga
slotIndex: number
}
onMount(() => { loadLibrary() })
const manga = $derived(libraryState.items)
const continueReading = $derived((() => {
const seen = new Set<number>()
const out: HistoryEntry[] = []
for (const e of homeState.history) {
if (seen.has(e.mangaId)) continue
seen.add(e.mangaId)
out.push(e)
if (out.length >= 10) break
}
return out
})())
const resolvedSlots = $derived((() => {
const pins = homeState.heroSlots
const slots: HeroSlot[] = []
const first = continueReading[0]
slots.push(first ? { kind: 'continue', entry: first, slotIndex: 0 } : { kind: 'empty', slotIndex: 0 })
let hi = 1
for (let i = 1; i < TOTAL_SLOTS; i++) {
const pinId = pins[i]
if (pinId != null) {
const m = manga.find(m => m.id === pinId)
if (m) { slots.push({ kind: 'pinned', manga: m, slotIndex: i }); continue }
}
const entry = continueReading[hi++]
slots.push(entry ? { kind: 'continue', entry, slotIndex: i } : { kind: 'empty', slotIndex: i })
}
return slots
})())
let activeIdx = $state(0)
const activeSlot = $derived(resolvedSlots[activeIdx])
const heroManga = $derived(
activeSlot?.kind === 'pinned' ? activeSlot.manga :
activeSlot?.kind === 'continue' ? manga.find(m => m.id === activeSlot.entry?.mangaId) : null
)
const heroEntry = $derived(activeSlot?.kind === 'continue' ? activeSlot.entry ?? null : null)
const heroMangaId = $derived(heroManga?.id ?? heroEntry?.mangaId ?? null)
const heroTitle = $derived(heroManga?.title ?? heroEntry?.mangaTitle ?? '')
const heroThumbSrc = $derived(
heroManga?.thumbnailUrl ??
(activeSlot?.kind === 'continue' ? activeSlot.entry?.thumbnailUrl : undefined) ??
''
)
let heroThumb = $state('')
$effect(() => {
const path = heroThumbSrc
if (!path) { heroThumb = ''; return }
heroThumb = path
})
const heroNewChapter = $derived(
heroManga ? (manga.find(m => m.id === heroManga!.id) as any)?.latestUploadedChapter ?? null : null
)
let heroChapters: Chapter[] = $state([])
let heroAllChapters: Chapter[] = $state([])
let loadingHeroChapters = $state(false)
let heroChaptersFor: number | null = null
$effect(() => {
const id = heroMangaId
if (id) untrack(() => loadHeroChapters(id))
})
async function loadHeroChapters(mangaId: number) {
heroChaptersFor = mangaId
loadingHeroChapters = true
heroChapters = []
heroAllChapters = []
try {
const chapters = await getAdapter().getChapters(String(mangaId))
if (heroChaptersFor !== mangaId) return
const all = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder)
heroAllChapters = all
const lastReadIdx = heroEntry
? all.findLastIndex(c => c.id === heroEntry!.chapterId)
: all.findLastIndex(c => c.isRead)
const startIdx = Math.max(0, lastReadIdx)
heroChapters = all.slice(startIdx, startIdx + 5)
} catch {
heroChapters = []
heroAllChapters = []
} finally {
loadingHeroChapters = false
}
}
let resuming = $state(false)
async function openChapter(chapter: Chapter) {
if (!heroMangaId) return
goto(`/reader/${heroMangaId}/${chapter.id}`)
}
async function resumeActive() {
if (!heroEntry && heroManga) { goto(`/series/${heroManga.id}`); return }
if (!heroEntry) return
const target = heroAllChapters.find(c => c.id === heroEntry!.chapterId) ?? heroAllChapters[0]
if (target) openChapter(target)
}
function cycleNext() { activeIdx = (activeIdx + 1) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = [] }
function cyclePrev() { activeIdx = (activeIdx - 1 + TOTAL_SLOTS) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = [] }
function goToSlot(i: number) { if (i !== activeIdx) { activeIdx = i; heroChapters = []; heroAllChapters = [] } }
let pickerOpen = $state(false)
let pickerSlotIndex: 1 | 2 | 3 | null = $state(null)
function openPicker(i: 1 | 2 | 3) { pickerSlotIndex = i; pickerOpen = true }
function closePicker() { pickerOpen = false; pickerSlotIndex = null }
function pinManga(m: Manga) { if (pickerSlotIndex !== null) { setHeroSlot(pickerSlotIndex, m.id); closePicker() } }
function unpinSlot(i: 1 | 2 | 3) { setHeroSlot(i, null) }
function resumeEntry(entry: HistoryEntry) {
const target = homeState.history.find(e => e.chapterId === entry.chapterId)
if (target) goto(`/reader/${entry.mangaId}/${entry.chapterId}`)
}
import Home from '$lib/components/home/Home.svelte'
</script>
<div class="root">
<div class="hero-shrink-guard">
<HeroStage
{resolvedSlots}
bind:activeIdx
{heroThumb}
{heroTitle}
{heroManga}
{heroEntry}
{heroMangaId}
{heroChapters}
{heroNewChapter}
{loadingHeroChapters}
{resuming}
onresume={resumeActive}
onopenchapter={openChapter}
oncyclenext={cycleNext}
oncycleprev={cyclePrev}
ongotoslot={goToSlot}
onopenpicker={openPicker}
onunpin={unpinSlot}
onviewall={() => heroManga && goto(`/series/${heroManga.id}`)}
/>
</div>
<div class="scroll-body">
<div class="mid-row">
<div class="mid-left">
<ActivityFeed
entries={homeState.history.slice(0, 6)}
libraryManga={manga}
onresume={resumeEntry}
onviewhistory={() => goto('/recent')}
onopenlibrary={() => goto('/library')}
/>
</div>
<div class="mid-divider"></div>
<div class="mid-right">
<RecsRow
libraryManga={manga}
history={homeState.history}
onopenrecommended={(m) => goto(`/series/${m.id}`)}
/>
</div>
</div>
<div class="bottom-row">
<div class="bottom-heatmap">
<span class="bottom-label">Activity</span>
<ActivityHeatmap dailyReadCounts={homeState.dailyReadCounts} />
</div>
<div class="bottom-divider"></div>
<div class="bottom-stats">
<StatsGrid stats={homeState.stats} updateCount={0} />
</div>
</div>
</div>
</div>
{#if pickerOpen && pickerSlotIndex !== null}
<HeroSlotPicker
slotIndex={pickerSlotIndex}
libraryManga={manga}
loading={libraryState.loading}
onpin={pinManga}
onclose={closePicker}
/>
{/if}
<style>
.root {
display: flex; flex-direction: column;
height: 100%; overflow: hidden;
animation: fadeIn 0.4s ease both;
}
.hero-shrink-guard { flex-shrink: 0; }
.scroll-body {
flex: 1; overflow-y: auto; overflow-x: hidden;
min-height: 0; scrollbar-width: none;
}
.scroll-body::-webkit-scrollbar { display: none; }
.mid-row {
display: grid; grid-template-columns: 1fr 1px 1.4fr;
border-top: 1px solid var(--border-dim); flex-shrink: 0; min-height: 0;
}
.mid-left { min-width: 0; overflow: hidden; }
.mid-left :global(.section) { border-top: none; }
.mid-divider { background: var(--border-dim); align-self: stretch; }
.mid-right { min-width: 0; overflow: hidden; padding: var(--sp-3) var(--sp-4) var(--sp-4); }
.bottom-row {
display: grid; grid-template-columns: 1fr 1px 1fr;
border-top: 1px solid var(--border-dim); flex-shrink: 0;
}
.bottom-divider { background: var(--border-dim); align-self: stretch; }
.bottom-heatmap {
display: flex; flex-direction: column; gap: var(--sp-2);
padding: var(--sp-4) var(--sp-4) var(--sp-5); min-width: 0;
}
.bottom-stats { padding: var(--sp-4) var(--sp-4) var(--sp-5); min-width: 0; overflow: hidden; }
.bottom-label {
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
}
@keyframes fadeIn { from { opacity: 0; transform: translateY(6px) } to { opacity: 1; transform: translateY(0) } }
</style>
<Home />