mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 01:09:56 -05:00
Chore: Completed Splash-Screen & Iniital Tauri Wire-Up
This commit is contained in:
@@ -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); }
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+11
@@ -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)
|
||||
|
||||
@@ -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' }
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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())
|
||||
),
|
||||
}))
|
||||
}
|
||||
@@ -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(); }}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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"></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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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> {}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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' }
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 />
|
||||
Reference in New Issue
Block a user