diff --git a/_old/core/persistence/index.ts b/_old/core/persistence/index.ts
index 1b1f9d9..bb72661 100644
--- a/_old/core/persistence/index.ts
+++ b/_old/core/persistence/index.ts
@@ -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";
\ No newline at end of file
diff --git a/_old/features/home/components/ActivityFeed.svelte b/_old/features/home/components/ActivityFeed.svelte
index cc1ecbc..918245c 100644
--- a/_old/features/home/components/ActivityFeed.svelte
+++ b/_old/features/home/components/ActivityFeed.svelte
@@ -1,9 +1,10 @@
@@ -36,16 +37,16 @@
{#if entries.length > 0}
- {#each entries as entry (entry.chapterId)}
+ {#each entries as entry (entry.id)}
onresume(entry)}>
-
+
{entry.mangaTitle}
- {entry.chapterName}{entry.pageNumber > 1 ? ` · p.${entry.pageNumber}` : ""}
+ {entry.endChapterName}{entry.endPage > 1 ? ` · p.${entry.endPage}` : ''}
- {timeAgo(entry.readAt)}
+ {timeAgo(entry.endedAt)}
{/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); }
diff --git a/_old/features/home/components/HeroStage.svelte b/_old/features/home/components/HeroStage.svelte
index 6b5efdd..b1da029 100644
--- a/_old/features/home/components/HeroStage.svelte
+++ b/_old/features/home/components/HeroStage.svelte
@@ -1,15 +1,18 @@
@@ -71,12 +74,12 @@
{#if heroThumb}
-
- {#if activeSlot?.kind === "continue"}
+
+ {#if activeSlot?.kind === 'continue'}
{/if}
{:else}
@@ -85,12 +88,12 @@
- {#if activeSlot?.kind === "empty"}
+ {#if activeSlot?.kind === 'empty'}
Nothing here yet
{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'}
{#if activeSlot.slotIndex !== 0}
onopenpicker(activeSlot.slotIndex as 1 | 2 | 3)}>
@@ -99,7 +102,7 @@
{/if}
{:else}
@@ -121,9 +124,9 @@
{#if heroEntry}
- {heroEntry.chapterName}
- {#if heroEntry.pageNumber > 1} · p.{heroEntry.pageNumber} {/if}
- {timeAgo(heroEntry.readAt)}
+ {heroEntry.endChapterName}
+ {#if heroEntry.endPage > 1} · p.{heroEntry.endPage} {/if}
+ {timeAgo(heroEntry.endedAt)}
{/if}
@@ -132,17 +135,17 @@
{/if}
- {#if activeSlot?.kind === "continue"}
+ {#if activeSlot?.kind === 'continue'}
- {resuming ? "Loading…" : "Resume"}
+ {resuming ? 'Loading…' : 'Resume'}
{:else if heroManga}
-
store.previewManga = heroManga!}>
+ goto(`/series/${heroManga!.id}`)}>
View manga
{/if}
{#if activeSlot?.slotIndex !== 0}
- {#if activeSlot?.kind === "pinned"}
+ {#if activeSlot?.kind === 'pinned'}
onunpin(activeSlot.slotIndex as 1 | 2 | 3)}>
Unpin
@@ -164,7 +167,7 @@
ongotoslot(i)}
aria-label="Slot {i + 1}"
>
@@ -182,7 +185,7 @@
Up Next
- {#if activeSlot?.kind === "empty"}
+ {#if activeSlot?.kind === 'empty'}
No chapters to show
{:else if loadingHeroChapters}
{#each Array(4) as _}
@@ -198,7 +201,7 @@
No chapters available
{:else}
{#each heroChapters as ch (ch.id)}
- {@const isCurrent = heroEntry?.chapterId === ch.id}
+ {@const isCurrent = heroEntry?.endChapterId === ch.id}
Ch.{ch.chapterNumber % 1 === 0 ? Math.floor(ch.chapterNumber) : ch.chapterNumber}
{ch.name}
- {#if isCurrent && heroEntry && heroEntry.pageNumber > 1}
- p.{heroEntry.pageNumber} · in progress
+ {#if isCurrent && heroEntry && heroEntry.endPage > 1}
+ p.{heroEntry.endPage} · in progress
{:else if ch.isRead}
Read
{:else if ch.uploadDate}
{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' })}
{/if}
@@ -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 } }
\ No newline at end of file
diff --git a/_old/features/home/components/RecsRow.svelte b/_old/features/home/components/RecsRow.svelte
index 9b4d971..2cce4a3 100644
--- a/_old/features/home/components/RecsRow.svelte
+++ b/_old/features/home/components/RecsRow.svelte
@@ -1,244 +1,33 @@
-
-
- {#if loading}
-
Loading…
- {:else if visibleRecs.length > 0}
-
- {#each visibleRecs as r (r.manga.id)}
-
onopenrecommended(r.manga)}>
-
-
- {/each}
-
- {:else}
-
No recommendations found
- {/if}
+
Recommended
+
Recommendations coming soon
\ No newline at end of file
diff --git a/_old/features/home/components/StatsGrid.svelte b/_old/features/home/components/StatsGrid.svelte
index ea58a11..d9b44c6 100644
--- a/_old/features/home/components/StatsGrid.svelte
+++ b/_old/features/home/components/StatsGrid.svelte
@@ -1,20 +1,15 @@
@@ -69,65 +64,39 @@
\ No newline at end of file
diff --git a/package.json b/package.json
index c384836..c67d0f4 100644
--- a/package.json
+++ b/package.json
@@ -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"
}
}
\ No newline at end of file
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 17d4539..e443563 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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)
diff --git a/src/hooks.client.ts b/src/hooks.client.ts
index 09896e4..82c4892 100644
--- a/src/hooks.client.ts
+++ b/src/hooks.client.ts
@@ -1,7 +1,11 @@
-import { initRequestManager } from '$lib/request-manager'
-import { initPlatformService } from '$lib/platform-service'
-import { appState } from '$lib/state/app.svelte'
+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' }
diff --git a/src/lib/components/chrome/SplashScreen.svelte b/src/lib/components/chrome/SplashScreen.svelte
index 8d511e4..6722090 100644
--- a/src/lib/components/chrome/SplashScreen.svelte
+++ b/src/lib/components/chrome/SplashScreen.svelte
@@ -1,136 +1,415 @@
-
+
{#if showCards}
-
+
+ {#if showFps}
+
+ {/if}
{/if}
- {#if mode === 'idle'}
-
-
+ {#if mode === "idle" && lockEnabled}
+
+
-
+
+
+
+
Enter PIN
+
+
+ {#each Array(settingsState.settings.appLockPin?.length ?? 4) as _, i}
+
+ {/each}
+
+
Unlock
+
+
+
+
+ {:else if mode === "idle"}
+
+
+
+
press any key to continue
@@ -138,83 +417,91 @@
{:else}
{#if !failed && !notConfigured}
-
-
-
+
+
+ style="transition: stroke-dasharray 0.4s cubic-bezier(0.4,0,0.2,1)" />
{/if}
-
+
-
-
-
+
+
{#if failed || notConfigured}
-
{failed ? 'Could not reach server' : 'Server not configured'}
+
{failed ? "Could not reach server" : "Server not configured"}
onRetry?.()}>Retry
onBypass?.()}>Enter app
{:else}
-
{ringFull ? '' : `Initializing server${dots}`}
+
{ringFull ? "" : `Initializing server${dots}`}
{/if}
+
+ {#if lockEnabled}
+
+
+
Enter PIN
+
+
+ {#each Array(settingsState.settings.appLockPin?.length ?? 4) as _, i}
+
+ {/each}
+
+
Unlock
+
+
+
+ {/if}
{/if}
\ No newline at end of file
diff --git a/src/lib/components/chrome/TitleBar.svelte b/src/lib/components/chrome/TitleBar.svelte
index a5534b7..b2a579f 100644
--- a/src/lib/components/chrome/TitleBar.svelte
+++ b/src/lib/components/chrome/TitleBar.svelte
@@ -1,62 +1,79 @@
{#if !isFullscreen}
- {#if isMac}
{/if}
+ {#if isMac}
{/if}
Moku
{#if !isMac}
-
-
+ win.minimize()} title="Minimize" aria-label="Minimize">
+
-
-
+ win.toggleMaximize()} title="Maximize" aria-label="Maximize">
+
-
+
-
-
+
+
@@ -64,7 +81,7 @@
{:else if isWindows}
-
+ win.setFullscreen(false)} title="Exit Fullscreen" aria-label="Exit Fullscreen">
@@ -72,63 +89,143 @@
-
+
-
-
+
+
{/if}
+{#if closeDialogOpen}
+
{ closeDialogOpen = false; closeRemember = false; }}>
+
e.stopPropagation()}>
+
+
+ confirmClose('tray')}>
+ Minimize to Tray
+ Keep running in the background
+
+ confirmClose('quit')}>
+ Quit
+ Stop Moku entirely
+
+
+
closeRemember = !closeRemember}>
+
+ Remember my choice
+
+
+
+{/if}
+
\ No newline at end of file
diff --git a/src/lib/components/home/ActivityFeed.svelte b/src/lib/components/home/ActivityFeed.svelte
index 743fbdf..bf7c0c9 100644
--- a/src/lib/components/home/ActivityFeed.svelte
+++ b/src/lib/components/home/ActivityFeed.svelte
@@ -1,26 +1,26 @@
@@ -35,16 +35,16 @@
{#if entries.length > 0}
- {#each entries as entry (entry.chapterId)}
+ {#each entries as entry (entry.id)}
onresume(entry)}>
-
+
{entry.mangaTitle}
- {entry.chapterName}{entry.pageNumber > 1 ? ` · p.${entry.pageNumber}` : ''}
+ {entry.endChapterName}{entry.endPage > 1 ? ` · p.${entry.endPage}` : ''}
- {timeAgo(entry.readAt)}
+ {timeAgo(entry.endedAt)}
{/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);
}
diff --git a/src/lib/components/home/ActivityHeatmap.svelte b/src/lib/components/home/ActivityHeatmap.svelte
index fa632ad..b511ca6 100644
--- a/src/lib/components/home/ActivityHeatmap.svelte
+++ b/src/lib/components/home/ActivityHeatmap.svelte
@@ -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 @@
-{#if tip}
-
{tip.text}
-{/if}
\ No newline at end of file
diff --git a/src/lib/components/home/HeroSlotPicker.svelte b/src/lib/components/home/HeroSlotPicker.svelte
index f643b35..b1ba044 100644
--- a/src/lib/components/home/HeroSlotPicker.svelte
+++ b/src/lib/components/home/HeroSlotPicker.svelte
@@ -1,5 +1,6 @@
+
+
+
+ heroManga && goto(`/series/${heroManga.id}`)}
+ />
+
+
+
+
+
+
goto('/recent')}
+ onopenlibrary={() => goto('/library')}
+ />
+
+
+
+ goto(`/series/${m.id}`)}
+ />
+
+
+
+
+
+
+
+{#if pickerOpen && pickerSlotIndex !== null}
+
+{/if}
+
+
\ No newline at end of file
diff --git a/src/lib/components/home/RecsRow.svelte b/src/lib/components/home/RecsRow.svelte
index 41853d4..18c65c9 100644
--- a/src/lib/components/home/RecsRow.svelte
+++ b/src/lib/components/home/RecsRow.svelte
@@ -1,7 +1,10 @@
+
+
+ {#if loading}
+
Loading…
+ {:else if visibleRecs.length > 0}
+
+ {#each visibleRecs as r (r.manga.id)}
+
onopenrecommended(r.manga)}>
+
+
+ {/each}
+
+ {:else}
+
No recommendations found
+ {/if}
-
Recommendations coming soon
\ No newline at end of file
diff --git a/src/lib/components/home/StatsGrid.svelte b/src/lib/components/home/StatsGrid.svelte
index 8e40ec8..c7a2ce3 100644
--- a/src/lib/components/home/StatsGrid.svelte
+++ b/src/lib/components/home/StatsGrid.svelte
@@ -1,7 +1,7 @@
{#if !isVertical}
@@ -103,43 +134,52 @@
+
{:else}
-
+
{#if sliderMax > 1}
readerState.sliderHover = true}
- onmouseleave={() => readerState.sliderHover = false}
+ onmouseleave={() => { if (!dragging) readerState.sliderHover = false; }}
+ onpointerdown={handleTrackPointerDown}
+ onpointermove={handleTrackPointerMove}
+ onpointerup={handleTrackPointerUp}
+ onpointercancel={handleTrackPointerUp}
>
-
readerState.sliderDragging = true}
- onmouseup={() => readerState.sliderDragging = false}
- />
+
+
{#if currentBookmark && currentBookmark.chapterId === displayChapter?.id}
- {@const bPct = sliderMax > 1 ? ((currentBookmark.pageNumber - 1) / (sliderMax - 1)) * 100 : 0}
{/if}
{#each activeChapterMarkers as m (m.id)}
- {@const mPct = sliderMax > 1 ? ((m.pageNumber - 1) / (sliderMax - 1)) * 100 : 0}
{/each}
+
+
+
{#if readerState.sliderHover || readerState.sliderDragging}
-
+
{sliderPage} / {sliderMax}
{/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); }
\ No newline at end of file
diff --git a/src/lib/components/recent/HistoryTab.svelte b/src/lib/components/recent/HistoryTab.svelte
index 97a23cb..40a147c 100644
--- a/src/lib/components/recent/HistoryTab.svelte
+++ b/src/lib/components/recent/HistoryTab.svelte
@@ -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`
+ }
- {#each items as session (session.latestChapterId)}
+ {#each items as session (session.id)}
onOpenSeries(session)}>
- {#if session.chapterCount > 1}
- {session.chapterCount}
+ {#if session.chaptersSpanned > 1}
+ {session.chaptersSpanned}
{/if}
{session.mangaTitle}
- {#if session.chapterCount > 1}
- {session.firstChapterName}→ {session.latestChapterName}
+ {#if session.chaptersSpanned > 1}
+ {session.startChapterName}→ {session.endChapterName}
{:else}
- {session.latestChapterName}
- {#if session.latestPageNumber > 1}
- · p.{session.latestPageNumber}
+ {session.endChapterName}
+ {#if session.endPage > 1}
+ · p.{session.endPage}
{/if}
{/if}
+ {#if session.durationMs >= 60_000}
+ · {formatDuration(session.durationMs)}
+ {/if}
- {timeAgo(session.readAt)}
+ {timeAgo(session.endedAt)}
{/each}
@@ -176,8 +188,9 @@
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted);
letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; min-width: 0;
}
- .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-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;
diff --git a/src/lib/components/recent/Recent.svelte b/src/lib/components/recent/Recent.svelte
index d3de44c..d76259d 100644
--- a/src/lib/components/recent/Recent.svelte
+++ b/src/lib/components/recent/Recent.svelte
@@ -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}
0}
+ hasHistory={historyState.sessions.length > 0}
{historySearch}
- stats={homeState.stats}
+ stats={historyState.stats}
{thumbFor}
onOpenSeries={(session) => setPreviewManga({
id: session.mangaId,
diff --git a/src/lib/components/recent/lib/recentHistory.ts b/src/lib/components/recent/lib/recentHistory.ts
index 4926820..2d42235 100644
--- a/src/lib/components/recent/lib/recentHistory.ts
+++ b/src/lib/components/recent/lib/recentHistory.ts
@@ -1,69 +1,19 @@
-import { dayLabel } from '$lib/core/util'
+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()
+export function groupByDay(sessions: ReadSession[]): HistoryGroup[] {
+ const map = new Map()
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 }))
}
\ No newline at end of file
diff --git a/src/lib/components/settings/sections/StorageSettings.svelte b/src/lib/components/settings/sections/StorageSettings.svelte
index 7c8c104..8ff9d58 100644
--- a/src/lib/components/settings/sections/StorageSettings.svelte
+++ b/src/lib/components/settings/sections/StorageSettings.svelte
@@ -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() {
diff --git a/src/lib/core/backup.ts b/src/lib/core/backup.ts
index 8446b7e..8009de7 100644
--- a/src/lib/core/backup.ts
+++ b/src/lib/core/backup.ts
@@ -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 {
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 {
invoke("exit_app");
}
+export async function autoBackupAppData(): Promise {
+ 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 {
return new Promise(resolve => {
const backdrop = document.createElement("div");
@@ -123,23 +138,6 @@ function showExitModal(): Promise {
});
}
-export async function autoBackupAppData(): Promise {
- 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) {
diff --git a/src/lib/core/discord.ts b/src/lib/core/discord.ts
new file mode 100644
index 0000000..8c98842
--- /dev/null
+++ b/src/lib/core/discord.ts
@@ -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 {
+ if (!platformService.isSupported('discord-rpc')) return
+ sessionStart = Date.now()
+}
+
+export async function destroyRpc(): Promise {
+ if (!platformService.isSupported('discord-rpc')) return
+ sessionStart = null
+}
+
+export async function setReading(manga: Manga, chapter: Chapter): Promise {
+ 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 {
+ 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 {
+ if (!platformService.isSupported('discord-rpc')) return
+ await platformService.clearDiscordPresence()
+}
\ No newline at end of file
diff --git a/src/lib/core/persistence/persist.ts b/src/lib/core/persistence/persist.ts
index 6591a4e..3bce1ea 100644
--- a/src/lib/core/persistence/persist.ts
+++ b/src/lib/core/persistence/persist.ts
@@ -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;
- libraryUpdates: any[];
- lastLibraryRefresh: number;
- acknowledgedUpdateIds: number[];
+export interface PersistedSettings {
+ storeVersion: number
+ settings: unknown
}
-export async function loadAllStores(): Promise {
- 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("storeVersion"),
- settingsStore.get("settings"),
- libraryStore.get("history"),
- libraryStore.get("bookmarks"),
- libraryStore.get("markers"),
- libraryStore.get("readLog"),
- libraryStore.get("readingStats"),
- libraryStore.get>("dailyReadCounts"),
- updatesStore.get("libraryUpdates"),
- updatesStore.get("lastLibraryRefresh"),
- updatesStore.get("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
}
-async function migrateFromLocalStorage(): Promise {
- 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
+
+ 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,
+ }
+ }
+
+ return {
+ sessions: (data.sessions ?? []) as ReadSession[],
+ bookmarks: (data.bookmarks ?? []) as BookmarkEntry[],
+ markers: (data.markers ?? []) as MarkerEntry[],
+ dailyReadCounts: (data.dailyReadCounts ?? {}) as Record,
}
}
-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 {
+ const raw = await platformService.loadStore('settings')
+ const data = (raw ?? {}) as Record
+
+ const legacyRaw = typeof window !== 'undefined' ? localStorage.getItem('moku_settings') : null
+ if (legacyRaw && !data.settings) {
+ try {
+ 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 persistLibrary(data: {
- history: any[];
- bookmarks: any[];
- markers: any[];
- readLog: any[];
- readingStats: any;
- dailyReadCounts: Record;
-}) {
- 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 saveSettings(data: PersistedSettings): Promise {
+ await platformService.saveStore('settings', data)
}
-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 async function loadLibrary(): Promise {
+ const raw = await platformService.loadStore('library')
+ const data = (raw ?? {}) as Record
+ 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 interface BackupEntry { url: string; name: string; }
-
-export async function loadBackups(): Promise {
- const fromStore = await backupsStore.get("backupList");
- if (fromStore) return fromStore;
- 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 []; }
+export async function saveLibrary(data: PersistedLibrary): Promise {
+ await platformService.saveStore('library', { ...data, storeVersion: STORE_VERSION })
}
-export async function persistBackups(list: BackupEntry[]): Promise {
- await backupsStore.set("backupList", list);
- await backupsStore.save();
+export async function loadUpdates(): Promise {
+ const raw = await platformService.loadStore('updates')
+ const data = (raw ?? {}) as Record
+ return {
+ libraryUpdates: (data.libraryUpdates ?? []) as unknown[],
+ lastLibraryRefresh: (data.lastLibraryRefresh ?? 0) as number,
+ acknowledgedUpdateIds: (data.acknowledgedUpdateIds ?? []) as number[],
+ }
}
-export async function resetAuthSettings(): Promise {
- const current = await settingsStore.get("settings") ?? {};
- current.serverAuthMode = "NONE";
- current.serverAuthUser = "";
- current.serverAuthPass = "";
- await settingsStore.set("settings", current);
- await settingsStore.save();
- localStorage.removeItem("moku-credential-vault");
+export async function saveUpdates(data: PersistedUpdates): Promise {
+ 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
+
+ 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 {
+ await platformService.saveStore('backups', { backupList: list })
}
\ No newline at end of file
diff --git a/src/lib/platform-adapters/capacitor/index.ts b/src/lib/platform-adapters/capacitor/index.ts
index 5f66fdf..af59608 100644
--- a/src/lib/platform-adapters/capacitor/index.ts
+++ b/src/lib/platform-adapters/capacitor/index.ts
@@ -14,6 +14,23 @@ export class CapacitorAdapter implements PlatformAdapter {
return supported.includes(feature)
}
+ async loadStore(key: string): Promise {
+ 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 {
+ 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 { return null }
async installAppUpdate(): Promise {}
+ async restartApp(): Promise {}
+
+ async getDefaultDownloadsPath(): Promise { 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 { return false }
+ async createDirectory(_path: string): Promise {}
+ async openPath(_path: string): Promise {}
+ async getAutoBackupDir(): Promise { return '' }
+
+ async clearMokuCache(): Promise {}
+ async clearSuwayomiCache(): Promise {}
+ async resetSuwayomiData(): Promise {}
+ async exitApp(): Promise {}
+
+ 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 {}
}
\ No newline at end of file
diff --git a/src/lib/platform-adapters/tauri/index.ts b/src/lib/platform-adapters/tauri/index.ts
index de49d6e..8c2c8fb 100644
--- a/src/lib/platform-adapters/tauri/index.ts
+++ b/src/lib/platform-adapters/tauri/index.ts
@@ -1,10 +1,12 @@
-import { invoke } from '@tauri-apps/api/core'
-import { getCurrentWindow } from '@tauri-apps/api/window'
-import { listen } from '@tauri-apps/api/event'
-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 { invoke } from '@tauri-apps/api/core'
+import { getCurrentWindow } from '@tauri-apps/api/window'
+import { listen } from '@tauri-apps/api/event'
+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()
+
+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 {
+ return getStore(key).get(key) ?? null
+ }
+
+ async saveStore(key: string, value: unknown): Promise {
+ 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 {
@@ -59,16 +90,37 @@ export class TauriAdapter implements PlatformAdapter {
return typeof result === 'string' ? result : null
}
+ async checkPathExists(path: string): Promise {
+ 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 {
+ return invoke('get_default_downloads_path')
+ }
+
+ async getStorageInfo(downloadsPath: string): Promise {
+ return invoke('get_storage_info', { downloadsPath })
+ }
+
+ async migrateDownloads(src: string, dst: string) {
+ await invoke('migrate_downloads', { src, dst })
+ }
+
async authenticateBiometric(): Promise {
- return invoke('authenticate_biometric')
- }
-
- async storeCredential(key: string, value: string) {
- await invoke('store_credential', { key, value })
- }
-
- async getCredential(key: string): Promise {
- return invoke('get_credential', { key })
+ try {
+ await invoke('windows_hello_authenticate', { reason: 'Authenticate to access Moku' })
+ return true
+ } catch {
+ return false
+ }
}
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 {
@@ -109,12 +161,20 @@ export class TauriAdapter implements PlatformAdapter {
await openUrl(url)
}
+ async restartApp() {
+ await invoke('restart_app')
+ }
+
+ async exitApp() {
+ await invoke('exit_app')
+ }
+
async checkForAppUpdate(): Promise {
const releases = await invoke>('list_releases')
const current = await getVersion()
const valid = releases.filter(r => r.tag_name?.trim())
if (!valid.length) return null
- const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number)
+ const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number)
const latest = valid.map(r => r.tag_name).sort((a, b) => {
const pa = parse(a), pb = parse(b)
for (let i = 0; i < 3; i++) if ((pb[i] ?? 0) !== (pa[i] ?? 0)) return (pb[i] ?? 0) - (pa[i] ?? 0)
@@ -126,32 +186,21 @@ export class TauriAdapter implements PlatformAdapter {
return { version: latest.replace(/^v/, ''), url: rel.html_url, notes: rel.body }
}
+ async listReleases(): Promise {
+ const all = await invoke('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('update-progress', e => cb(e.payload))
}
- async getDefaultDownloadsPath(): Promise {
- return invoke('get_default_downloads_path')
- }
-
- async getStorageInfo(downloadsPath: string): Promise {
- return invoke('get_storage_info', { downloadsPath })
- }
-
- async checkPathExists(path: string): Promise {
- 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 {
@@ -170,28 +219,7 @@ export class TauriAdapter implements PlatformAdapter {
await invoke('reset_suwayomi_data')
}
- async exitApp() {
- await invoke('exit_app')
- }
-
- async listReleases(): Promise {
- const all = await invoke('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('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('migrate_progress', e => cb(e.payload))
}
-
- async migrateDownloads(src: string, dst: string) {
- await invoke('migrate_downloads', { src, dst })
- }
}
\ No newline at end of file
diff --git a/src/lib/platform-adapters/types.ts b/src/lib/platform-adapters/types.ts
index 4e21414..4699199 100644
--- a/src/lib/platform-adapters/types.ts
+++ b/src/lib/platform-adapters/types.ts
@@ -11,16 +11,42 @@ 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
+ state?: string
+ details?: string
+ assets?: DiscordAssets
+ buttons?: DiscordButton[]
+ party?: DiscordParty
+ timestamps?: DiscordTimestamps
}
export interface AppUpdateInfo {
version: string
- url: string
- notes: string
+ url: string
+ notes: string
}
export interface StorageInfo {
@@ -41,60 +67,64 @@ export interface UpdateProgress {
total: number | null
}
-export interface PlatformAdapter {
- init(): Promise
- isSupported(feature: PlatformFeature): boolean
-
- launchServer(config: ServerLaunchConfig): Promise
- stopServer(): Promise
- getServerStatus(): Promise<'running' | 'stopped' | 'error'>
-
- readFile(path: string): Promise
- writeFile(path: string, data: Uint8Array): Promise
- pickFolder(): Promise
-
- authenticateBiometric(): Promise
- storeCredential(key: string, value: string): Promise
- getCredential(key: string): Promise
-
- setTitle(title: string): Promise
- minimize(): Promise
- maximize(): Promise
- close(): Promise
- toggleFullscreen(): Promise
-
- setDiscordPresence(presence: DiscordPresence): Promise
- clearDiscordPresence(): Promise
-
- getVersion(): Promise
- openExternal(url: string): Promise
- checkForAppUpdate(): Promise
- installAppUpdate(tag: string): Promise
- restartApp(): Promise
-
- getDefaultDownloadsPath(): Promise
- getStorageInfo(downloadsPath: string): Promise
- checkPathExists(path: string): Promise
- createDirectory(path: string): Promise
- openPath(path: string): Promise
- getAutoBackupDir(): Promise
-
- clearMokuCache(): Promise
- clearSuwayomiCache(): Promise
- resetSuwayomiData(): Promise
- exitApp(): Promise
-
- onUpdateProgress(cb: (p: UpdateProgress) => void): Promise<() => void>
- onUpdateLaunching(cb: () => void): Promise<() => void>
- listReleases(): Promise
- onMigrateProgress(cb: (p: MigrateProgress) => void): Promise<() => void>
- migrateDownloads(src: string, dst: string): Promise
-}
-
export interface ReleaseInfo {
tag_name: string
name: string
body: string
published_at: string
html_url: string
+}
+
+export interface PlatformAdapter {
+ init(): Promise
+ destroy(): Promise
+ isSupported(feature: PlatformFeature): boolean
+
+ launchServer(config: ServerLaunchConfig): Promise
+ stopServer(): Promise
+ getServerStatus(): Promise<'running' | 'stopped' | 'error'>
+
+ readFile(path: string): Promise
+ writeFile(path: string, data: Uint8Array): Promise
+ pickFolder(): Promise
+
+ authenticateBiometric(): Promise
+ storeCredential(key: string, value: string): Promise
+ getCredential(key: string): Promise
+
+ loadStore(key: string): Promise
+ saveStore(key: string, value: unknown): Promise
+
+ setTitle(title: string): Promise
+ minimize(): Promise
+ maximize(): Promise
+ close(): Promise
+ toggleFullscreen(): Promise
+
+ setDiscordPresence(presence: DiscordPresence): Promise
+ clearDiscordPresence(): Promise
+
+ getVersion(): Promise
+ openExternal(url: string): Promise
+ checkForAppUpdate(): Promise
+ installAppUpdate(tag: string): Promise
+ restartApp(): Promise
+
+ getDefaultDownloadsPath(): Promise
+ getStorageInfo(downloadsPath: string): Promise
+ checkPathExists(path: string): Promise
+ createDirectory(path: string): Promise
+ openPath(path: string): Promise
+ getAutoBackupDir(): Promise
+
+ clearMokuCache(): Promise
+ clearSuwayomiCache(): Promise
+ resetSuwayomiData(): Promise
+ exitApp(): Promise
+
+ onUpdateProgress(cb: (p: UpdateProgress) => void): Promise<() => void>
+ onUpdateLaunching(cb: () => void): Promise<() => void>
+ listReleases(): Promise
+ onMigrateProgress(cb: (p: MigrateProgress) => void): Promise<() => void>
+ migrateDownloads(src: string, dst: string): Promise
}
\ No newline at end of file
diff --git a/src/lib/platform-adapters/web/index.ts b/src/lib/platform-adapters/web/index.ts
index f8ba6ae..5c6aa21 100644
--- a/src/lib/platform-adapters/web/index.ts
+++ b/src/lib/platform-adapters/web/index.ts
@@ -17,6 +17,21 @@ export class WebAdapter implements PlatformAdapter {
return false
}
+ async loadStore(key: string): Promise {
+ try {
+ const raw = localStorage.getItem(`moku:${key}`)
+ return raw ? JSON.parse(raw) : null
+ } catch {
+ return null
+ }
+ }
+
+ async saveStore(key: string, value: unknown): Promise {
+ try {
+ localStorage.setItem(`moku:${key}`, JSON.stringify(value))
+ } catch {}
+ }
+
async launchServer(_config: ServerLaunchConfig) {}
async stopServer() {}
async getServerStatus(): Promise<'running' | 'stopped' | 'error'> { return 'stopped' }
diff --git a/src/lib/platform-service/index.ts b/src/lib/platform-service/index.ts
index bd7a4db..5923ff1 100644
--- a/src/lib/platform-service/index.ts
+++ b/src/lib/platform-service/index.ts
@@ -22,52 +22,56 @@ function get(): PlatformAdapter {
}
export const platformService = {
- isSupported: (f: PlatformFeature) => get().isSupported(f),
- init: () => get().init(),
+ isSupported: (f: PlatformFeature) => get().isSupported(f),
+ init: () => get().init(),
+ destroy: () => get().destroy(),
- launchServer: (c: ServerLaunchConfig) => get().launchServer(c),
- stopServer: () => get().stopServer(),
- getServerStatus: () => get().getServerStatus(),
+ launchServer: (c: ServerLaunchConfig) => get().launchServer(c),
+ stopServer: () => get().stopServer(),
+ getServerStatus: () => get().getServerStatus(),
- readFile: (path: string) => get().readFile(path),
- writeFile: (path: string, data: Uint8Array) => get().writeFile(path, data),
- pickFolder: () => get().pickFolder(),
+ readFile: (path: string) => get().readFile(path),
+ writeFile: (path: string, data: Uint8Array) => get().writeFile(path, data),
+ pickFolder: () => get().pickFolder(),
- authenticateBiometric: () => get().authenticateBiometric(),
- storeCredential: (k: string, v: string) => get().storeCredential(k, v),
- getCredential: (k: string) => get().getCredential(k),
+ authenticateBiometric: () => get().authenticateBiometric(),
+ storeCredential: (k: string, v: string) => get().storeCredential(k, v),
+ getCredential: (k: string) => get().getCredential(k),
- setTitle: (title: string) => get().setTitle(title),
- minimize: () => get().minimize(),
- maximize: () => get().maximize(),
- close: () => get().close(),
- toggleFullscreen: () => get().toggleFullscreen(),
+ loadStore: (key: string) => get().loadStore(key),
+ saveStore: (key: string, value: unknown) => get().saveStore(key, value),
- setDiscordPresence: (p: DiscordPresence) => get().setDiscordPresence(p),
- clearDiscordPresence: () => get().clearDiscordPresence(),
+ setTitle: (title: string) => get().setTitle(title),
+ minimize: () => get().minimize(),
+ maximize: () => get().maximize(),
+ close: () => get().close(),
+ toggleFullscreen: () => get().toggleFullscreen(),
- getVersion: () => get().getVersion(),
- openExternal: (url: string) => get().openExternal(url),
- checkForAppUpdate: () => get().checkForAppUpdate(),
- installAppUpdate: (tag: string) => get().installAppUpdate(tag),
- restartApp: () => get().restartApp(),
+ setDiscordPresence: (p: DiscordPresence) => get().setDiscordPresence(p),
+ clearDiscordPresence: () => get().clearDiscordPresence(),
- getDefaultDownloadsPath: () => get().getDefaultDownloadsPath(),
- getStorageInfo: (downloadsPath: string) => get().getStorageInfo(downloadsPath),
- checkPathExists: (path: string) => get().checkPathExists(path),
- createDirectory: (path: string) => get().createDirectory(path),
- openPath: (path: string) => get().openPath(path),
- getAutoBackupDir: () => get().getAutoBackupDir(),
+ getVersion: () => get().getVersion(),
+ openExternal: (url: string) => get().openExternal(url),
+ checkForAppUpdate: () => get().checkForAppUpdate(),
+ installAppUpdate: (tag: string) => get().installAppUpdate(tag),
+ restartApp: () => get().restartApp(),
- clearMokuCache: () => get().clearMokuCache(),
- clearSuwayomiCache: () => get().clearSuwayomiCache(),
- resetSuwayomiData: () => get().resetSuwayomiData(),
- exitApp: () => get().exitApp(),
+ getDefaultDownloadsPath: () => get().getDefaultDownloadsPath(),
+ getStorageInfo: (downloadsPath: string) => get().getStorageInfo(downloadsPath),
+ checkPathExists: (path: string) => get().checkPathExists(path),
+ createDirectory: (path: string) => get().createDirectory(path),
+ openPath: (path: string) => get().openPath(path),
+ getAutoBackupDir: () => get().getAutoBackupDir(),
- listReleases: () => get().listReleases(),
+ clearMokuCache: () => get().clearMokuCache(),
+ clearSuwayomiCache: () => get().clearSuwayomiCache(),
+ resetSuwayomiData: () => get().resetSuwayomiData(),
+ exitApp: () => get().exitApp(),
- onUpdateProgress: (cb: (p: UpdateProgress) => void) => get().onUpdateProgress(cb),
- onUpdateLaunching: (cb: () => void) => get().onUpdateLaunching(cb),
- onMigrateProgress: (cb: (p: MigrateProgress) => void) => get().onMigrateProgress(cb),
- migrateDownloads: (src: string, dst: string) => get().migrateDownloads(src, dst),
+ listReleases: () => get().listReleases(),
+
+ onUpdateProgress: (cb: (p: UpdateProgress) => void) => get().onUpdateProgress(cb),
+ onUpdateLaunching: (cb: () => void) => get().onUpdateLaunching(cb),
+ onMigrateProgress: (cb: (p: MigrateProgress) => void) => get().onMigrateProgress(cb),
+ migrateDownloads: (src: string, dst: string) => get().migrateDownloads(src, dst),
}
\ No newline at end of file
diff --git a/src/lib/state/boot.svelte.ts b/src/lib/state/boot.svelte.ts
index 499127c..e9aba9d 100644
--- a/src/lib/state/boot.svelte.ts
+++ b/src/lib/state/boot.svelte.ts
@@ -14,14 +14,16 @@ export const boot = $state({
loginPass: '',
sessionExpired: false,
skipped: false,
+ serverProbeOk: false,
})
let probeGeneration = 0
function handleProbeSuccess(gen: number) {
if (gen !== probeGeneration) return
- boot.failed = false
- boot.skipped = false
+ 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 {
boot.skipped = false
boot.loginPass = ''
boot.loginError = null
+ boot.serverProbeOk = true
appState.authenticated = true
appState.status = 'ready'
} catch (e: unknown) {
diff --git a/src/lib/state/history.svelte.ts b/src/lib/state/history.svelte.ts
new file mode 100644
index 0000000..d3aa0f7
--- /dev/null
+++ b/src/lib/state/history.svelte.ts
@@ -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
+}
+
+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()
+ const mangaIds = new Set()
+ const days = new Set()
+ 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([])
+ dailyReadCounts = $state>({})
+ stats = $state({ ...DEFAULT_READING_STATS })
+ active = $state(null)
+
+ load(sessions: ReadSession[], dailyReadCounts: Record) {
+ 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()
\ No newline at end of file
diff --git a/src/lib/state/home.svelte.ts b/src/lib/state/home.svelte.ts
index dbf5ce7..cd234cd 100644
--- a/src/lib/state/home.svelte.ts
+++ b/src/lib/state/home.svelte.ts
@@ -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,
- 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()
}
\ No newline at end of file
diff --git a/src/lib/state/reader.svelte.ts b/src/lib/state/reader.svelte.ts
index 83623df..090a5b1 100644
--- a/src/lib/state/reader.svelte.ts
+++ b/src/lib/state/reader.svelte.ts
@@ -52,6 +52,7 @@ class ReaderState {
zoomOpen = $state(false);
winOpen = $state(false);
presetOpen = $state(false);
+ actionsOpen = $state(false);
nextN = $state(5);
dlBusy = $state(false);
@@ -116,11 +117,12 @@ class ReaderState {
}
closeAllPopovers(): boolean {
- if (this.markerOpen) { this.markerOpen = false; return true; }
- if (this.zoomOpen) { this.zoomOpen = false; return true; }
- 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.markerOpen) { this.markerOpen = false; return true; }
+ if (this.zoomOpen) { this.zoomOpen = false; return true; }
+ 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;
}
diff --git a/src/lib/state/settings.svelte.ts b/src/lib/state/settings.svelte.ts
index f974270..b4fef17 100644
--- a/src/lib/state/settings.svelte.ts
+++ b/src/lib/state/settings.svelte.ts
@@ -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) {
- 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 })
}
\ No newline at end of file
diff --git a/src/lib/types/history.ts b/src/lib/types/history.ts
index 631000c..d03659c 100644
--- a/src/lib/types/history.ts
+++ b/src/lib/types/history.ts
@@ -1,72 +1,73 @@
-export interface HistoryEntry {
- mangaId: number
- mangaTitle: string
- thumbnailUrl: string
- chapterId: number
- chapterName: string
- readAt: number
-}
-
export interface BookmarkEntry {
- mangaId: number
- mangaTitle: string
+ mangaId: number
+ mangaTitle: string
thumbnailUrl: string
- chapterId: number
- chapterName: string
- pageNumber: number
- savedAt: number
- label?: string
+ chapterId: number
+ chapterName: string
+ pageNumber: number
+ savedAt: number
+ label?: string
}
-export type MarkerColor = "yellow" | "red" | "blue" | "green" | "purple"
+export type MarkerColor = 'yellow' | 'red' | 'blue' | 'green' | 'purple'
export interface MarkerEntry {
- id: string
- mangaId: number
- mangaTitle: string
+ id: string
+ mangaId: number
+ mangaTitle: string
thumbnailUrl: string
- chapterId: number
- chapterName: string
- pageNumber: number
- note: string
- color: MarkerColor
- createdAt: number
- updatedAt?: number
+ chapterId: number
+ chapterName: string
+ pageNumber: number
+ note: string
+ color: MarkerColor
+ createdAt: number
+ updatedAt?: number
}
-export interface ReadLogEntry {
- mangaId: number
- chapterId: number
- readAt: number
- minutes: number
+export interface ReadSession {
+ id: string
+ mangaId: 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 {
- totalChaptersRead: number
- totalMangaRead: number
- totalMinutesRead: number
- firstReadAt: number
- lastReadAt: number
- currentStreakDays: number
- longestStreakDays: number
- lastStreakDate: string
+ totalChaptersRead: number
+ totalMangaRead: number
+ totalMinutesRead: number
+ firstReadAt: number
+ lastReadAt: number
+ currentStreakDays: number
+ longestStreakDays: number
+ lastStreakDate: string
}
export const DEFAULT_READING_STATS: ReadingStats = {
- totalChaptersRead: 0,
- totalMangaRead: 0,
- totalMinutesRead: 0,
- firstReadAt: 0,
- lastReadAt: 0,
- currentStreakDays: 0,
- longestStreakDays: 0,
- lastStreakDate: "",
+ totalChaptersRead: 0,
+ totalMangaRead: 0,
+ totalMinutesRead: 0,
+ firstReadAt: 0,
+ lastReadAt: 0,
+ currentStreakDays: 0,
+ longestStreakDays: 0,
+ lastStreakDate: '',
}
export interface LibraryUpdateEntry {
- mangaId: number
- mangaTitle: string
+ mangaId: number
+ mangaTitle: string
thumbnailUrl: string
- newChapters: number
- checkedAt: number
-}
+ newChapters: number
+ checkedAt: number
+}
\ No newline at end of file
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 3ce36d9..a1cc05c 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -1,10 +1,14 @@
-
-
- heroManga && goto(`/series/${heroManga.id}`)}
- />
-
-
-
-
-
-
goto('/recent')}
- onopenlibrary={() => goto('/library')}
- />
-
-
-
- goto(`/series/${m.id}`)}
- />
-
-
-
-
-
-
-
-{#if pickerOpen && pickerSlotIndex !== null}
-
-{/if}
-
-
\ No newline at end of file
+
\ No newline at end of file