From 18027baee18907f50d248582b590f55d7f700d80 Mon Sep 17 00:00:00 2001 From: Youwes09 Date: Tue, 2 Jun 2026 08:27:37 -0500 Subject: [PATCH] Chore: Completed Splash-Screen & Iniital Tauri Wire-Up --- _old/core/persistence/index.ts | 14 +- .../home/components/ActivityFeed.svelte | 138 ++--- .../features/home/components/HeroStage.svelte | 435 +++++-------- _old/features/home/components/RecsRow.svelte | 239 +------- .../features/home/components/StatsGrid.svelte | 73 +-- package.json | 3 +- pnpm-lock.yaml | 11 + src/hooks.client.ts | 22 +- src/lib/components/chrome/SplashScreen.svelte | 569 +++++++++++++----- src/lib/components/chrome/TitleBar.svelte | 255 +++++--- src/lib/components/home/ActivityFeed.svelte | 32 +- .../components/home/ActivityHeatmap.svelte | 25 +- src/lib/components/home/HeroSlotPicker.svelte | 5 +- src/lib/components/home/HeroStage.svelte | 21 +- src/lib/components/home/Home.svelte | 257 ++++++++ src/lib/components/home/RecsRow.svelte | 233 ++++++- src/lib/components/home/StatsGrid.svelte | 4 +- .../components/home/{ => lib}/homeHelpers.ts | 0 .../components/home/lib/recommendations.ts | 66 ++ src/lib/components/reader/PageView.svelte | 19 +- src/lib/components/reader/Reader.svelte | 59 +- .../components/reader/ReaderControls.svelte | 520 ++++++++++------ .../components/reader/ReaderOverlay.svelte | 24 +- .../reader/ReaderPresetPanel.svelte | 44 +- .../reader/ReaderProgressBar.svelte | 184 ++++-- src/lib/components/recent/HistoryTab.svelte | 39 +- src/lib/components/recent/Recent.svelte | 19 +- .../components/recent/lib/recentHistory.ts | 68 +-- .../settings/sections/StorageSettings.svelte | 8 +- src/lib/core/backup.ts | 52 +- src/lib/core/discord.ts | 66 ++ src/lib/core/persistence/persist.ts | 274 ++++----- src/lib/platform-adapters/capacitor/index.ts | 38 ++ src/lib/platform-adapters/tauri/index.ts | 156 +++-- src/lib/platform-adapters/types.ts | 140 +++-- src/lib/platform-adapters/web/index.ts | 15 + src/lib/platform-service/index.ts | 80 +-- src/lib/state/boot.svelte.ts | 8 +- src/lib/state/history.svelte.ts | 202 +++++++ src/lib/state/home.svelte.ts | 45 +- src/lib/state/reader.svelte.ts | 12 +- src/lib/state/settings.svelte.ts | 44 +- src/lib/types/history.ts | 107 ++-- src/routes/+layout.svelte | 111 +++- src/routes/+page.svelte | 258 +------- 45 files changed, 2981 insertions(+), 2013 deletions(-) create mode 100644 src/lib/components/home/Home.svelte rename src/lib/components/home/{ => lib}/homeHelpers.ts (100%) create mode 100644 src/lib/components/home/lib/recommendations.ts create mode 100644 src/lib/core/discord.ts create mode 100644 src/lib/state/history.svelte.ts 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)} {/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 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} {/each}
@@ -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'} {:else if heroManga} - {/if} {#if activeSlot?.slotIndex !== 0} - {#if activeSlot?.kind === "pinned"} + {#if activeSlot?.kind === 'pinned'} @@ -164,7 +167,7 @@ @@ -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} - {activeGenre} - -
- {/if} -
- -
- {#if loading} -

Loading…

- {:else if visibleRecs.length > 0} -
- {#each visibleRecs as r (r.manga.id)} - - {/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} +
+
- Moku + Moku +
+
+

Enter PIN

+
+
+ {#each Array(settingsState.settings.appLockPin?.length ?? 4) as _, i} +
+ {/each} +
+ +
+
+
+ + {:else if mode === "idle"} +
+
+
+ Moku

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} - Moku + Moku
- -
-
+
+
{#if failed || notConfigured}
-

{failed ? 'Could not reach server' : 'Server not configured'}

+

{failed ? "Could not reach server" : "Server not configured"}

{:else} -

{ringFull ? '' : `Initializing server${dots}`}

+

{ringFull ? "" : `Initializing server${dots}`}

{/if}
+ + {#if lockEnabled} +
+
+

Enter PIN

+
+
+ {#each Array(settingsState.settings.appLockPin?.length ?? 4) as _, i} +
+ {/each} +
+ +
+
+
+ {/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}
- - -
@@ -64,7 +81,7 @@
{:else if isWindows}
- -
{/if} +{#if closeDialogOpen} + +{/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)} {/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}`)} + /> +
+
+ +
+
+ Activity + +
+
+
+ +
+
+
+
+ +{#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 @@
- Recommended + + Recommended + + {#if genres.length > 1} +
+ + {activeGenre} + +
+ {/if} +
+ +
+ {#if loading} +

Loading…

+ {:else if visibleRecs.length > 0} +
+ {#each visibleRecs as r (r.manga.id)} + + {/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 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` + }
@@ -79,7 +88,7 @@
- {#each items as session (session.latestChapterId)} + {#each items as session (session.id)} {/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}`)} - /> -
-
- -
-
- Activity - -
-
-
- -
-
-
-
- -{#if pickerOpen && pickerSlotIndex !== null} - -{/if} - - \ No newline at end of file + \ No newline at end of file