From d9a9427e3b1e9dda7a81b06e8382b6a4f548cf85 Mon Sep 17 00:00:00 2001 From: Youwes09 Date: Sun, 24 May 2026 20:31:46 -0500 Subject: [PATCH] Chore: Port over Settings (Barely Works) --- package.json | 3 + pnpm-lock.yaml | 30 + src/app.css | 2 + .../{ui => components}/chrome/AuthGate.svelte | 51 +- .../{ui => components}/chrome/Sidebar.svelte | 3 +- .../chrome/SplashScreen.svelte | 2 +- .../{ui => components}/chrome/TitleBar.svelte | 4 +- .../{ui => components}/chrome/Toaster.svelte | 0 .../{ui => components}/chrome/splashCanvas.ts | 0 .../{ui => components}/chrome/titlebarOs.ts | 0 .../home/ActivityFeed.svelte | 2 +- .../home/ActivityHeatmap.svelte | 0 .../home/HeroSlotPicker.svelte | 0 .../{ui => components}/home/HeroStage.svelte | 2 +- .../{ui => components}/home/RecsRow.svelte | 0 .../{ui => components}/home/StatsGrid.svelte | 2 +- .../{ui => components}/home/homeHelpers.ts | 0 .../library/LibraryFilters.svelte | 0 .../library/LibraryGrid.svelte | 0 .../library/LibraryToolbar.svelte | 0 src/lib/components/settings/Settings.css | 1383 +++++++++++++++++ src/lib/components/settings/Settings.svelte | 190 +++ .../components/settings/ThemeEditor.svelte | 508 ++++++ .../settings/sections/AboutSettings.svelte | 297 ++++ .../sections/AppearanceSettings.svelte | 161 ++ .../sections/AutomationSettings.svelte | 143 ++ .../settings/sections/ContentSettings.svelte | 212 +++ .../settings/sections/DevToolsSettings.svelte | 241 +++ .../settings/sections/FoldersSettings.svelte | 368 +++++ .../settings/sections/GeneralSettings.svelte | 204 +++ .../settings/sections/KeybindsSettings.svelte | 55 + .../settings/sections/LibrarySettings.svelte | 92 ++ .../sections/PerformanceSettings.svelte | 161 ++ .../settings/sections/ReaderSettings.svelte | 128 ++ .../settings/sections/SecuritySettings.svelte | 340 ++++ .../settings/sections/StorageSettings.svelte | 869 +++++++++++ .../settings/sections/TrackingSettings.svelte | 274 ++++ .../shared/manga/MangaPreview.svelte | 839 ++++++++++ .../shared/manga/SourceBrowse.svelte | 195 +++ .../components/shared/manga/ThreeDCard.svelte | 130 ++ .../components/shared/manga/Thumbnail.svelte | 52 + src/lib/core/algorithms/filter.ts | 4 +- src/lib/core/algorithms/paginate.ts | 24 +- src/lib/core/algorithms/queue.ts | 35 +- src/lib/core/algorithms/search.ts | 39 +- src/lib/core/algorithms/sort.ts | 33 +- src/lib/core/async/batchRequests.ts | 35 + src/lib/core/async/createPaginatedQuery.ts | 22 + src/lib/core/async/fetchWithRetry.ts | 31 + src/lib/core/auth.ts | 103 +- src/lib/core/backup.ts | 256 +++ src/lib/core/cache/imageCache.ts | 134 ++ src/lib/core/cache/queryCache.ts | 14 +- src/lib/core/cover/autoLink.ts | 24 + src/lib/core/cover/autoLinkWorker.ts | 37 +- src/lib/core/cover/coverHash.ts | 57 +- src/lib/core/cover/coverResolver.ts | 92 ++ src/lib/core/keybinds/index.ts | 3 + src/lib/core/persistence/index.ts | 5 + src/lib/core/persistence/persist.ts | 166 ++ src/lib/core/theme.ts | 67 + src/lib/platform-adapters/capacitor/index.ts | 14 +- src/lib/platform-adapters/tauri/index.ts | 10 +- src/lib/platform-adapters/tauri/updater.ts | 40 + src/lib/platform-adapters/types.ts | 1 + src/lib/platform-adapters/web/index.ts | 46 +- src/lib/platform-service/index.ts | 34 +- src/lib/request-manager/extensions.ts | 17 + src/lib/request-manager/index.ts | 17 + src/lib/request-manager/manga.ts | 4 +- src/lib/server-adapters/moku/index.ts | 35 +- src/lib/server-adapters/suwayomi/index.ts | 106 +- src/lib/server-adapters/suwayomi/pageCache.ts | 84 + src/lib/server-adapters/types.ts | 48 +- src/lib/state/app.svelte.ts | 51 +- src/lib/state/boot.svelte.ts | 156 ++ src/lib/state/downloads.svelte.ts | 16 +- src/lib/state/extensions.svelte.ts | 4 +- src/lib/state/notifications.svelte.ts | 43 +- src/lib/state/reader.svelte.ts | 23 +- src/lib/state/settings.svelte.ts | 28 + src/lib/state/tracking.svelte.ts | 44 +- src/lib/types/settings.ts | 418 ++--- src/routes/+layout.svelte | 46 +- src/routes/+page.svelte | 12 +- src/routes/library/+page.svelte | 4 +- src/routes/settings/+page.svelte | 11 +- 87 files changed, 8821 insertions(+), 615 deletions(-) rename src/lib/{ui => components}/chrome/AuthGate.svelte (74%) rename src/lib/{ui => components}/chrome/Sidebar.svelte (97%) rename src/lib/{ui => components}/chrome/SplashScreen.svelte (99%) rename src/lib/{ui => components}/chrome/TitleBar.svelte (97%) rename src/lib/{ui => components}/chrome/Toaster.svelte (100%) rename src/lib/{ui => components}/chrome/splashCanvas.ts (100%) rename src/lib/{ui => components}/chrome/titlebarOs.ts (100%) rename src/lib/{ui => components}/home/ActivityFeed.svelte (99%) rename src/lib/{ui => components}/home/ActivityHeatmap.svelte (100%) rename src/lib/{ui => components}/home/HeroSlotPicker.svelte (100%) rename src/lib/{ui => components}/home/HeroStage.svelte (99%) rename src/lib/{ui => components}/home/RecsRow.svelte (100%) rename src/lib/{ui => components}/home/StatsGrid.svelte (98%) rename src/lib/{ui => components}/home/homeHelpers.ts (100%) rename src/lib/{ui => components}/library/LibraryFilters.svelte (100%) rename src/lib/{ui => components}/library/LibraryGrid.svelte (100%) rename src/lib/{ui => components}/library/LibraryToolbar.svelte (100%) create mode 100644 src/lib/components/settings/Settings.css create mode 100644 src/lib/components/settings/Settings.svelte create mode 100644 src/lib/components/settings/ThemeEditor.svelte create mode 100644 src/lib/components/settings/sections/AboutSettings.svelte create mode 100644 src/lib/components/settings/sections/AppearanceSettings.svelte create mode 100644 src/lib/components/settings/sections/AutomationSettings.svelte create mode 100644 src/lib/components/settings/sections/ContentSettings.svelte create mode 100644 src/lib/components/settings/sections/DevToolsSettings.svelte create mode 100644 src/lib/components/settings/sections/FoldersSettings.svelte create mode 100644 src/lib/components/settings/sections/GeneralSettings.svelte create mode 100644 src/lib/components/settings/sections/KeybindsSettings.svelte create mode 100644 src/lib/components/settings/sections/LibrarySettings.svelte create mode 100644 src/lib/components/settings/sections/PerformanceSettings.svelte create mode 100644 src/lib/components/settings/sections/ReaderSettings.svelte create mode 100644 src/lib/components/settings/sections/SecuritySettings.svelte create mode 100644 src/lib/components/settings/sections/StorageSettings.svelte create mode 100644 src/lib/components/settings/sections/TrackingSettings.svelte create mode 100644 src/lib/components/shared/manga/MangaPreview.svelte create mode 100644 src/lib/components/shared/manga/SourceBrowse.svelte create mode 100644 src/lib/components/shared/manga/ThreeDCard.svelte create mode 100644 src/lib/components/shared/manga/Thumbnail.svelte create mode 100644 src/lib/core/async/batchRequests.ts create mode 100644 src/lib/core/async/createPaginatedQuery.ts create mode 100644 src/lib/core/async/fetchWithRetry.ts create mode 100644 src/lib/core/backup.ts create mode 100644 src/lib/core/cache/imageCache.ts create mode 100644 src/lib/core/cover/autoLink.ts create mode 100644 src/lib/core/cover/coverResolver.ts create mode 100644 src/lib/core/keybinds/index.ts create mode 100644 src/lib/core/persistence/index.ts create mode 100644 src/lib/core/persistence/persist.ts create mode 100644 src/lib/core/theme.ts create mode 100644 src/lib/platform-adapters/tauri/updater.ts create mode 100644 src/lib/server-adapters/suwayomi/pageCache.ts create mode 100644 src/lib/state/boot.svelte.ts create mode 100644 src/lib/state/settings.svelte.ts diff --git a/package.json b/package.json index c333284..4e70471 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,10 @@ }, "dependencies": { "@tauri-apps/api": "^2.0.0", + "@tauri-apps/plugin-http": "^2.5.9", "@tauri-apps/plugin-os": "^2.3.2", + "@tauri-apps/plugin-shell": "^2.3.5", + "@tauri-apps/plugin-store": "^2.4.3", "phosphor-svelte": "^3.1.0" } } \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 376cca2..56df0dd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,18 @@ importers: '@tauri-apps/api': specifier: ^2.0.0 version: 2.11.0 + '@tauri-apps/plugin-http': + specifier: ^2.5.9 + version: 2.5.9 '@tauri-apps/plugin-os': specifier: ^2.3.2 version: 2.3.2 + '@tauri-apps/plugin-shell': + specifier: ^2.3.5 + version: 2.3.5 + '@tauri-apps/plugin-store': + specifier: ^2.4.3 + version: 2.4.3 phosphor-svelte: specifier: ^3.1.0 version: 3.1.0(svelte@5.55.5(@typescript-eslint/types@8.57.1))(vite@8.0.10) @@ -477,9 +486,18 @@ packages: engines: {node: '>= 10'} hasBin: true + '@tauri-apps/plugin-http@2.5.9': + resolution: {integrity: sha512-lCiY0+vs4HvIUSvZrBs8TC3TiCB0MOPRmiUjTq4prW7SlcJE2jdLeT6KBsJrT9Tlplufl7W1pY6SFAO3gCWxDA==} + '@tauri-apps/plugin-os@2.3.2': resolution: {integrity: sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A==} + '@tauri-apps/plugin-shell@2.3.5': + resolution: {integrity: sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==} + + '@tauri-apps/plugin-store@2.4.3': + resolution: {integrity: sha512-9LWPj9yMphRi9czEtUv87XHbl1b6xgd9EXpPrUnq6nG7+nbtoF84d4Kwz9xhAv/Hf30sr58pq7EOlyI936y8qw==} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -1145,10 +1163,22 @@ snapshots: '@tauri-apps/cli-win32-ia32-msvc': 2.11.2 '@tauri-apps/cli-win32-x64-msvc': 2.11.2 + '@tauri-apps/plugin-http@2.5.9': + dependencies: + '@tauri-apps/api': 2.11.0 + '@tauri-apps/plugin-os@2.3.2': dependencies: '@tauri-apps/api': 2.11.0 + '@tauri-apps/plugin-shell@2.3.5': + dependencies: + '@tauri-apps/api': 2.11.0 + + '@tauri-apps/plugin-store@2.4.3': + dependencies: + '@tauri-apps/api': 2.11.0 + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 diff --git a/src/app.css b/src/app.css index 027fdb2..27393ac 100644 --- a/src/app.css +++ b/src/app.css @@ -1,3 +1,5 @@ +@import '$lib/components/settings/Settings.css'; + :root { --bg-void: #080808; --bg-base: #0c0c0c; diff --git a/src/lib/ui/chrome/AuthGate.svelte b/src/lib/components/chrome/AuthGate.svelte similarity index 74% rename from src/lib/ui/chrome/AuthGate.svelte rename to src/lib/components/chrome/AuthGate.svelte index fb05126..44b4efe 100644 --- a/src/lib/ui/chrome/AuthGate.svelte +++ b/src/lib/components/chrome/AuthGate.svelte @@ -1,35 +1,10 @@ @@ -43,8 +18,8 @@

{appState.serverUrl || 'localhost:4567'}

- {#if loginError} -

{loginError}

+ {#if boot.loginError} +

{boot.loginError}

{/if}
@@ -52,28 +27,28 @@ class="input" type="text" placeholder="Username" - bind:value={loginUser} - disabled={loginBusy} + bind:value={boot.loginUser} + disabled={boot.loginBusy} autocomplete="username" - onkeydown={(e) => e.key === 'Enter' && handleLogin()} + onkeydown={(e) => e.key === 'Enter' && submitLogin()} /> e.key === 'Enter' && handleLogin()} + onkeydown={(e) => e.key === 'Enter' && submitLogin()} />
diff --git a/src/lib/ui/chrome/Sidebar.svelte b/src/lib/components/chrome/Sidebar.svelte similarity index 97% rename from src/lib/ui/chrome/Sidebar.svelte rename to src/lib/components/chrome/Sidebar.svelte index eee7a72..13fbebe 100644 --- a/src/lib/ui/chrome/Sidebar.svelte +++ b/src/lib/components/chrome/Sidebar.svelte @@ -1,6 +1,7 @@ + + \ No newline at end of file diff --git a/src/lib/components/settings/ThemeEditor.svelte b/src/lib/components/settings/ThemeEditor.svelte new file mode 100644 index 0000000..c956274 --- /dev/null +++ b/src/lib/components/settings/ThemeEditor.svelte @@ -0,0 +1,508 @@ + + + + +
e.key === "Escape" && onClose()}> + +
+ + \ No newline at end of file diff --git a/src/lib/components/settings/sections/AboutSettings.svelte b/src/lib/components/settings/sections/AboutSettings.svelte new file mode 100644 index 0000000..6c94ea3 --- /dev/null +++ b/src/lib/components/settings/sections/AboutSettings.svelte @@ -0,0 +1,297 @@ + + +
+ +
+

Moku

+
+
+ A manga reader frontend for Suwayomi / Tachidesk. + Built with Tauri + Svelte. +
+
+
+ +
+

Version

+
+
+
Installedv{appVersion}
+ +
+ {#if onLatestVersion} +
+ ✓ You're on the latest version. +
+ {/if} + {#if updatePhase === 'downloading' && IS_WINDOWS} +
+
+
+
+
+ Downloading {targetTag ?? 'update'}… + {fmtProgress()} +
+
+ {/if} + {#if updatePhase === 'launching'} +
+ Launching installer for {targetTag}… +
+ {/if} + {#if updatePhase === 'ready'} +
+ {targetTag} downloaded — restart to finish installing. + + +
+ {/if} + {#if updatePhase === 'error'} +
+ {updateError} + +
+ {/if} +
+
+ + {#if serverInfo} +
+

Server

+
+
+
+ Version + + {serverInfo.version} + {#if serverInfo.buildType} + {serverInfo.buildType} + {/if} + +
+
+ {#if serverInfo.buildTime} +
+
+ Built + {fmtBuildTime(serverInfo.buildTime)} +
+
+ {/if} + {#if webuiInfo?.channel} +
+
+ Channel + {webuiInfo.channel} +
+
+ {/if} +
+
+ {/if} + +
+

Releases

+
+ {#if releasesError} +

{releasesError}

+ {:else if releasesLoading} +

Fetching releases…

+ {:else if releases.length === 0} +

No releases found.

+ {:else} +
+ {#each releases as release} + {@const isCurrent = isCurrentVersion(release.tag_name)} + {@const isExpanded = expandedTag === release.tag_name} + {@const isTarget = targetTag === release.tag_name} + {@const isInstalling = isTarget && updatePhase === 'downloading'} +
+
+
+ {release.tag_name} + {#if isCurrent}installed{/if} + {#if release.published_at}{fmtDate(release.published_at)}{/if} +
+
+ {#if release.body.trim()} + + {/if} + {#if !isCurrent} + {#if IS_WINDOWS} + + {:else} + + {/if} + {/if} +
+
+ {#if isExpanded && release.body.trim()} +
+
{release.body.trim()}
+
+ {/if} +
+ {/each} +
+ {/if} +
+
+ +
+

Links

+
+
+ GitHub → + Discord → + {#if serverInfo?.github && serverInfo.github !== 'https://github.com/moku-project/Moku'} + Suwayomi GitHub → + {/if} + {#if serverInfo?.discord && serverInfo.discord !== 'https://discord.gg/Jq3pwuNqPp'} + Suwayomi Discord → + {/if} +
+
+
+ +
\ No newline at end of file diff --git a/src/lib/components/settings/sections/AppearanceSettings.svelte b/src/lib/components/settings/sections/AppearanceSettings.svelte new file mode 100644 index 0000000..0bc9fe3 --- /dev/null +++ b/src/lib/components/settings/sections/AppearanceSettings.svelte @@ -0,0 +1,161 @@ + + +
+ +
+
+
+ Match system theme + Automatically switch theme when your OS switches between light and dark +
+ +
+ + {#if settingsState.settings.systemThemeSync} +
+
+ Dark theme +
+ + {#if selectOpen === 'sync-dark' || closingSelect === 'sync-dark'} +
+ {#each allThemeOptions as opt} + + {/each} +
+ {/if} +
+
+
+ Light theme +
+ + {#if selectOpen === 'sync-light' || closingSelect === 'sync-light'} +
+ {#each allThemeOptions as opt} + + {/each} +
+ {/if} +
+
+
+ {/if} +
+ +
+

Theme

+
+ {#each THEMES as theme} + {@const active = (settingsState.settings.theme ?? 'dark') === theme.id} +
+ +
+ {/each} + + {#each settingsState.settings.customThemes ?? [] as custom} + {@const active = settingsState.settings.theme === custom.id} +
+
+ + +
+ + {#if active}{/if} +
+ {/each} + + +
+
+ +
\ No newline at end of file diff --git a/src/lib/components/settings/sections/AutomationSettings.svelte b/src/lib/components/settings/sections/AutomationSettings.svelte new file mode 100644 index 0000000..9e99bae --- /dev/null +++ b/src/lib/components/settings/sections/AutomationSettings.svelte @@ -0,0 +1,143 @@ + + +
+ +
+

Behaviour

+
+ + + {#if enforceGlobal} +
Per-series overrides are paused.
+ {/if} +
+
+ +
+

Global Defaults

+
+

Downloads

+
+
Auto-download new chaptersQueue new chapters when a series refreshes
+ +
+
+
Download aheadPre-fetch chapters while reading
+
+ {#each DOWNLOAD_AHEAD_OPTIONS as opt} + + {/each} +
+
+
+
Max chapters to keepDelete oldest downloads when limit is exceeded
+
+ {#each MAX_KEEP_OPTIONS as opt} + + {/each} +
+
+

On Read

+
+
Delete after readingRemove download when a chapter is marked read
+ +
+ {#if getGlobal('deleteOnRead')} +
+ Delete delay +
+ {#each DELETE_DELAY_OPTIONS as opt} + + {/each} +
+
+ {/if} +

Updates

+
+
Default refresh intervalHow often series check for new chapters by default
+
+ {#each REFRESH_INTERVAL_OPTIONS as opt} + + {/each} +
+
+
+
+ +
+

Custom Overrides

+
+
+
Series with custom rulesPer-series settings set via the series automation panel
+ 0}>{customCount} +
+
+
Reset all custom rulesRevert every series to the global defaults above
+ {#if confirmReset} +
+ + +
+ {:else} + + {/if} +
+
+
+ +
+ + \ No newline at end of file diff --git a/src/lib/components/settings/sections/ContentSettings.svelte b/src/lib/components/settings/sections/ContentSettings.svelte new file mode 100644 index 0000000..ef322bf --- /dev/null +++ b/src/lib/components/settings/sections/ContentSettings.svelte @@ -0,0 +1,212 @@ + + +
+ +
+

Content Level

+
+
+ Controls what content is visible across library, search, and discover. +
+
+ {#each LEVELS as lvl} + {@const active = settingsState.settings.contentLevel === lvl.value} + + {/each} +
+
+
+ +
+

Source Overrides

+
+ + + {#if settingsState.settings.sourceOverridesEnabled} +
+ +
+ {#if contentSourcesLoading} +

Loading sources…

+ {:else if contentSources.length === 0} +

No sources found — check your server connection.

+ {:else} +
+ {#each contentSourcesFiltered as group (group.name)} + {@const ids = group.sources.map(s => s.id)} + {@const allowed = settingsState.settings.nsfwAllowedSourceIds ?? []} + {@const blocked = settingsState.settings.nsfwBlockedSourceIds ?? []} + {@const isAllowed = ids.every(id => allowed.includes(id))} + {@const isBlocked = ids.every(id => blocked.includes(id))} +
+ +
+ {group.name} + + {group.sources[0].isNsfw ? 'NSFW · ' : ''}{group.sources.length > 1 ? `${group.sources.length} languages` : group.sources[0].lang.toUpperCase()} + +
+
+ + +
+
+ {/each} +
+ {/if} + {/if} +
+
+ +
+ + \ No newline at end of file diff --git a/src/lib/components/settings/sections/DevToolsSettings.svelte b/src/lib/components/settings/sections/DevToolsSettings.svelte new file mode 100644 index 0000000..71eb81b --- /dev/null +++ b/src/lib/components/settings/sections/DevToolsSettings.svelte @@ -0,0 +1,241 @@ + + +
+ +
+

Toasts

+
+
+
Fire test toastTriggers each kind with realistic content
+
+ {#each (['success', 'error', 'info', 'download'] as const) as kind (kind)} + {@const label = kind === 'success' ? 'S' : kind === 'error' ? 'E' : kind === 'info' ? 'I' : 'D'} + {@const title = kind === 'success' ? 'Library updated' : kind === 'error' ? 'Could not reach server' : kind === 'info' ? 'Already up to date' : 'Download complete'} + {@const body = kind === 'success' ? '3 new chapters across 2 series' : kind === 'error' ? 'Connection refused on port 4567' : kind === 'info' ? 'No new chapters found' : 'Berserk · Ch. 372 ready to read'} + + {/each} +
+
+
+
+ +
+

Previews

+
+
+
Idle splashDismiss with any click or key
+ +
+
+
+ +
+

Biometrics

+
+
+
+ Windows Hello + Available: {helloAvailable === null ? '…' : helloAvailable ? 'yes' : 'no'} +
+ +
+
+
+ +
+ + {#if expOpen} +
+
+ 3D tilt cards — hover to preview +
+ {#each [{ title: 'Berserk', sub: 'Ch. 372', hue: '265' }, { title: 'Vinland Saga', sub: 'Ch. 208', hue: '200' }, { title: 'Dungeon Meshi', sub: 'Ch. 97', hue: '140' }] as card (card.title)} + +
+ {card.title} + {card.sub} +
+
+ {/each} +
+
+
+ {/if} +
+ +
+

Runtime

+
+
+ Filter {appState.libraryFilter} + Folders {appState.categories.filter(c => c.id !== 0).map(c => c.name).join(', ') || 'none'} + History {appState.history.length} entries + Cache {perfSnapshot?.cacheEntries ?? '—'} entries + Toasts {appState.toasts.length} queued + Version {appVersion} · {import.meta.env.MODE} +
+
+
+ {#if perfSnapshot && perfSnapshot.cacheEntries > 0} + {perfSnapshot.cacheKeys.join(', ')} + Oldest: {fmtAge(perfSnapshot.oldestEntryMs)} · Newest: {fmtAge(perfSnapshot.newestEntryMs)} + {/if} +
+ +
+
+
+ +
+

Auth (UI Login)

+
+
+ Mode {authStatus?.mode ?? '—'} + Session {authStatus?.hasSession ? 'present' : 'none'} + Refresh token {authStatus?.hasRefreshToken ? 'present' : 'none'} + Access expires in {fmtCountdown(authStatus?.accessExpiresInMs ?? null)} + Refresh expires in {fmtCountdown(authStatus?.refreshExpiresInMs ?? null)} + Refresh window {authStatus?.shouldRefreshSoon ? 'open' : 'not yet'} + Refresh in-flight {authStatus?.refreshInFlight ? 'yes' : 'no'} +
+
+
+ Access expiry at: {fmtTime(authStatus?.accessExpiresAt ?? null)} + Refresh expiry at: {fmtTime(authStatus?.refreshExpiresAt ?? null)} + Skew window: {Math.round((authStatus?.skewMs ?? 0) / 1000)}s before expiry +
+
+ + +
+
+
+
+ +
\ No newline at end of file diff --git a/src/lib/components/settings/sections/FoldersSettings.svelte b/src/lib/components/settings/sections/FoldersSettings.svelte new file mode 100644 index 0000000..d0b782d --- /dev/null +++ b/src/lib/components/settings/sections/FoldersSettings.svelte @@ -0,0 +1,368 @@ + + +
+
+

Manage Folders

+
+
+ Folders are stored as Suwayomi categories. Changes sync across all clients. +
+ + {#if catsError} +
{catsError}
+ {/if} + + {#if catsLoading} +

Loading folders…

+ {:else} +
+ {#each orderedAllIds as id} + {@const isBuiltin = id === 'library' || id === 'downloaded'} + {@const isCompleted = id === completedId} + {@const cat = isBuiltin ? null : (categories.find(c => String(c.id) === id) ?? null)} + {@const hidden = isHidden(id)} + + {#if isBuiltin || cat} +
onDragStart(e, id)} + ondragover={(e) => onDragOver(e, id)} + ondragleave={() => { if (dragOverStrId === id) { dragOverStrId = null; dropPosition = null } }} + ondrop={(e) => onDrop(e, id)} + ondragend={onDragEnd} + > + {#if isCompleted} + + + + + {cat?.name ?? 'Completed'} + {cat?.mangas?.nodes.length ?? 0} manga + built-in +
+ + +
+ + {:else if isBuiltin} + + {#if id === 'library'}{:else}{/if} + + + {id === 'library' ? 'Saved' : 'Downloaded'} + built-in +
+ + +
+ + {:else if cat} + {#if editingId === cat.id} + { if (e.key === 'Enter') commitEdit(); if (e.key === 'Escape') { editingId = null } }} + onblur={commitEdit} use:focusInput /> + + {:else} +
onDragStart(e, id)} + ondragend={onDragEnd}> + + + + + { e.stopPropagation(); startEdit(cat.id, cat.name) }} title="Click to rename">{cat.name} +
+ {cat.mangas?.nodes.length ?? 0} manga +
+ + + + + +
+ {/if} + {/if} +
+ {/if} + {/each} +
+ + {#if categories.filter(c => c.id !== 0 && c.name !== 'Completed').length === 0} +

No custom folders yet. Create one below.

+ {/if} + {/if} + +
+ e.key === 'Enter' && createFolder()} /> + +
+
+
+
+ + \ No newline at end of file diff --git a/src/lib/components/settings/sections/GeneralSettings.svelte b/src/lib/components/settings/sections/GeneralSettings.svelte new file mode 100644 index 0000000..f979dfd --- /dev/null +++ b/src/lib/components/settings/sections/GeneralSettings.svelte @@ -0,0 +1,204 @@ + + +
+ +
+

Interface Scale

+
+
+ updateSettings({ uiZoom: Number(e.currentTarget.value) / 100 })} + class="s-slider" /> + { const n = parseInt(e.currentTarget.value, 10); if (!isNaN(n) && n >= 50 && n <= 200) updateSettings({ uiZoom: n / 100 }) }} + onblur={(e) => { const n = parseInt(e.currentTarget.value, 10); if (isNaN(n) || n < 50) { updateSettings({ uiZoom: 0.5 }); e.currentTarget.value = '50' } else if (n > 200) { updateSettings({ uiZoom: 2.0 }); e.currentTarget.value = '200' } }} + /> + % + +
+
+ {#each [50,60,70,80,90,100,110,125,150,175,200] as v} + + {/each} +
+
+
+ +
+

Server

+
+
+
+ Server URL + Base URL of your Suwayomi instance +
+
+ updateSettings({ serverUrl: e.currentTarget.value })} + placeholder="http://localhost:4567" spellcheck="false" /> + +
+
+ + + + + + {#if serverAdvancedOpen} +
+
+
+ Server binary + Path to server executable — leave blank to use bundled +
+
+ updateSettings({ serverBinary: e.currentTarget.value })} + placeholder="auto-detect" spellcheck="false" /> + +
+
+
+ {/if} +
+
+ +
+

Window

+
+
+
Close button behaviorWhat happens when you click the X button
+
+ {#each [['ask','Ask'],['tray','Tray'],['quit','Quit']] as [v, l]} + + {/each} +
+
+
+
+ +
+

Inactivity

+
+
+
Idle screen timeoutShow the Moku idle splash after this much inactivity
+
+ + {#if selectOpen === 'idle-timeout' || closingSelect === 'idle-timeout'} +
+ {#each [['0','Never'],['1','1 minute'],['2','2 minutes'],['5','5 minutes'],['10','10 minutes'],['15','15 minutes'],['30','30 minutes']] as [v, l]} + + {/each} +
+ {/if} +
+
+
+
+ +
+

Integrations

+
+ +
+
+ +
+

Animations

+
+ +
+
+ +
+

Language

+
+
+
+ Preferred source language + Used to pre-select languages in Search and deduplicate sources +
+ updateSettings({ preferredExtensionLang: e.currentTarget.value.trim().toLowerCase() })} + placeholder="en" spellcheck="false" /> +
+
+
+ +
+ + \ No newline at end of file diff --git a/src/lib/components/settings/sections/KeybindsSettings.svelte b/src/lib/components/settings/sections/KeybindsSettings.svelte new file mode 100644 index 0000000..9115fb1 --- /dev/null +++ b/src/lib/components/settings/sections/KeybindsSettings.svelte @@ -0,0 +1,55 @@ + + +
+
+

+ Keyboard Shortcuts + +

+

Click a binding to rebind, then press the new key combination.

+
+ {#each Object.keys(KEYBIND_LABELS) as key} + {@const k = key as keyof Keybinds} + {@const isListening = listeningKey === k} + {@const isDefault = settingsState.settings.keybinds[k] === DEFAULT_KEYBINDS[k]} +
+ {KEYBIND_LABELS[k]} +
+ + +
+
+ {/each} +
+
+
\ No newline at end of file diff --git a/src/lib/components/settings/sections/LibrarySettings.svelte b/src/lib/components/settings/sections/LibrarySettings.svelte new file mode 100644 index 0000000..311419a --- /dev/null +++ b/src/lib/components/settings/sections/LibrarySettings.svelte @@ -0,0 +1,92 @@ + + +
+ +
+

Display

+
+ + + + {#if settingsState.settings.libraryShowAllInSaved ?? true} + + {/if} +
+
+ +
+

Chapters

+
+
+
Default sort directionInitial chapter list order when opening a manga
+
+ + {#if selectOpen === 'sort-dir'} +
+ {#each [['desc','Newest first'],['asc','Oldest first']] as [v, l]} + + {/each} +
+ {/if} +
+
+
+
+ +
+

Series

+
+ + +
+
+ +
+
+
+
Reading history{homeState.history.length} entries
+ +
+
+
+ +
\ No newline at end of file diff --git a/src/lib/components/settings/sections/PerformanceSettings.svelte b/src/lib/components/settings/sections/PerformanceSettings.svelte new file mode 100644 index 0000000..7cf3847 --- /dev/null +++ b/src/lib/components/settings/sections/PerformanceSettings.svelte @@ -0,0 +1,161 @@ + + +
+ +
+

Render Limit

+
+
+
+ Items per page + Lower = faster on large libraries +
+
+ + {settingsState.settings.renderLimit ?? 48} + +
+
+
+ {#each [12, 24, 48, 96, 200] as v} + + {/each} +
+
+
+ +
+

Rendering

+
+ +
+
+ +
+

Idle / Splash Screen

+
+ +
+
+ +
+

Session Cache

+
+
+
+ Cache entries + In-memory, cleared on restart +
+
+ {perfSnapshot?.cacheEntries ?? 0} entries + +
+
+ {#if perfSnapshot && perfSnapshot.cacheEntries > 0} +
+
Oldest entry
+ {fmtAge(perfSnapshot.oldestEntryMs)} +
+
+
Newest entry
+ {fmtAge(perfSnapshot.newestEntryMs)} +
+
+
+ Cached keys + {perfSnapshot.cacheKeys.join(', ')} +
+
+ {/if} +
+
+ +
+

Cache

+
+
+
Image cacheWebview page image cache
+ +
+
+
+ +
\ No newline at end of file diff --git a/src/lib/components/settings/sections/ReaderSettings.svelte b/src/lib/components/settings/sections/ReaderSettings.svelte new file mode 100644 index 0000000..d93ea35 --- /dev/null +++ b/src/lib/components/settings/sections/ReaderSettings.svelte @@ -0,0 +1,128 @@ + + +
+ +
+

Page Layout

+
+
+
Default layoutHow chapters open by default
+
+ + {#if selectOpen === 'page-style'} +
+ {#each [['single','Single page'],['longstrip','Long strip']] as [v, l]} + + {/each} +
+ {/if} +
+
+
+
Reading directionLeft-to-right for most manga, right-to-left for Japanese
+
+ + {#if selectOpen === 'reading-dir'} +
+ {#each [['ltr','Left to right'],['rtl','Right to left']] as [v, l]} + + {/each} +
+ {/if} +
+
+ + + +
+
+ +
+

Fit & Zoom

+
+
+
Default fit modeHow pages are scaled to fill the reader on open
+
+ + {#if selectOpen === 'fit-mode'} +
+ {#each [['width','Fit width'],['height','Fit height'],['screen','Fit screen'],['original','Original (1:1)']] as [v, l]} + + {/each} +
+ {/if} +
+
+ +
+
+ +
+

Behaviour

+
+ + + {#if !(settingsState.settings.autoNextChapter ?? false)} + + {/if} + +
+
Pages to preloadHow many pages ahead to fetch in the background while reading
+
+ + {settingsState.settings.preloadPages} + +
+
+
+
+ +
\ No newline at end of file diff --git a/src/lib/components/settings/sections/SecuritySettings.svelte b/src/lib/components/settings/sections/SecuritySettings.svelte new file mode 100644 index 0000000..ed285be --- /dev/null +++ b/src/lib/components/settings/sections/SecuritySettings.svelte @@ -0,0 +1,340 @@ + + +
+ + {#if secError} +
{secError}
+ {/if} + +
+

+ Server Authentication + + {settingsState.settings.serverAuthMode === 'BASIC_AUTH' ? 'Basic Auth' : + settingsState.settings.serverAuthMode === 'UI_LOGIN' ? 'UI Login' : 'Disabled'} + +

+
+
+
ModeHow Moku authenticates with the server
+
+ {#each [{ value: 'NONE', label: 'None' }, { value: 'BASIC_AUTH', label: 'Basic' }, { value: 'UI_LOGIN', label: 'UI Login' }] as opt} + + {/each} +
+
+ {#if authMode !== 'NONE'} +
+
Username
+ +
+
+
Password
+
+ + +
+
+ {/if} + {#if settingsState.settings.serverAuthMode === 'BASIC_AUTH'} +
+ Images are proxied through Tauri when Basic Auth is active, which reduces loading speed. +
+ {/if} +
+
+ +
+
+ {#if settingsState.settings.serverAuthMode !== 'NONE'} + + {/if} + +
+
+
+
+ +
+

SOCKS Proxy

+
+ + {#if socksEnabled} +
+
Version
+
+ + {#if selectOpen === 'socks-ver'} +
+ {#each [[4, 'SOCKS4'], [5, 'SOCKS5']] as [v, l]} + + {/each} +
+ {/if} +
+
+
+
Host
+ +
+
+
Port
+ +
+
+
UsernameOptional
+ +
+
+
PasswordOptional
+
+ + +
+
+
+
+ +
+ {/if} +
+
+ +
+

FlareSolverr

+
+ + {#if flareEnabled} +
+
URLFlareSolverr instance address
+ +
+
+
TimeoutMax wait per request, in seconds
+
+ + {flareTimeout}s + +
+
+
+
Session nameReuse browser session across requests
+ +
+
+
Session TTLMinutes before session is refreshed
+
+ + {flareTtl}m + +
+
+ +
+
+ +
+ {/if} +
+
+ +
+ + \ No newline at end of file diff --git a/src/lib/components/settings/sections/StorageSettings.svelte b/src/lib/components/settings/sections/StorageSettings.svelte new file mode 100644 index 0000000..6e03b89 --- /dev/null +++ b/src/lib/components/settings/sections/StorageSettings.svelte @@ -0,0 +1,869 @@ + + +
+ + {#if migrateFrom && !isExternalServer} +
+
+ Manga found at previous path — move to new location? + {migrateFrom} → {migrateTo} + {#if migrateProgress && migrateProgress.total > 0} +
+ {migrateProgress.current} · {migrateProgress.done} / {migrateProgress.total} + {/if} + {#if migrateError}{migrateError}{/if} +
+
+ + +
+
+ {/if} + +
+

+ Disk Usage + +

+
+ {#if storageLoading} +

Reading filesystem…

+ {:else if storageError} +

{storageError}

+ {:else if isExternalServer} +

Disk usage is unavailable for external servers — filesystem access requires a local connection.

+ {:else if multiStorageInfos.length > 0} + {#each multiStorageInfos as info} + {@const limitGb = settingsState.settings.storageLimitGb ?? null} + {@const limitBytes = limitGb !== null ? limitGb * 1024 ** 3 : null} + {@const available = info.manga_bytes + info.free_bytes} + {@const cap = limitBytes !== null ? Math.min(limitBytes, available) : available} + {@const pct = cap > 0 ? Math.min(100, (info.manga_bytes / cap) * 100) : 0} +
+
+ {info.label} + {fmtBytes(info.manga_bytes)} of {fmtBytes(cap)} +
+
+
90} class:warn={pct > 75 && pct <= 90} style="width:{pct}%">
+
+ +
+ {/each} + {:else} +

No download path configured.

+ {/if} +
+
+ +
+

Downloads Path

+
+ {#if isExternalServer} +
+ Connected to an external server. The path below is read from the server — changes here will update the server's config directly. +
+ {/if} +
+ e.key === 'Enter' && savePaths()} + oninput={() => { pathsFieldError = { ...pathsFieldError, dl: undefined } }} /> + {#if !isExternalServer} + + {/if} +
+
+
+ {#if pathsFieldError.dl} + {pathsFieldError.dl} + {/if} + {#if pathsError} + {pathsError} + {/if} +
+
+ {#if pathsFieldError.dl && !isExternalServer} + + {/if} + {#if downloadsPathInput.trim() !== confirmedDownloadsPath} + + {/if} +
+
+
+
+ +
+

Storage Limit

+
+
+
+ Warn when limit is reached + {settingsState.settings.storageLimitGb === null ? 'No limit set' : `Warn above ${settingsState.settings.storageLimitGb} GB`} +
+ {#if settingsState.settings.storageLimitGb === null} + + {:else} +
+ + { const n = parseFloat(e.currentTarget.value); if (!isNaN(n) && n > 0) updateSettings({ storageLimitGb: n }) }} /> + GB + + +
+ {/if} +
+
+
+ +
+ + {#if advStorageOpen} +
+
+
+ Local source path + Read manga already on disk without an extension. Leave blank if unused. +
+
+
+ e.key === 'Enter' && savePaths()} + oninput={() => { pathsFieldError = { ...pathsFieldError, loc: undefined } }} /> + {#if !isExternalServer} + + {/if} + {#if pathsFieldError.loc && !isExternalServer} + + {/if} +
+ {#if pathsFieldError.loc}{pathsFieldError.loc}{/if} +
+
+ + {#each extraScanDirs as dir} +
+
+ {dir} + Extra scan directory +
+ +
+ {/each} + +
+
+ Additional scan path + Include an extra directory in disk usage readings +
+
+ e.key === 'Enter' && addExtraScanDir()} /> + {#if !isExternalServer} + + {/if} +
+
+
+ {/if} +
+ +
+ + {#if backupSectionOpen} +
+ +

Library backup

+ +
+
+ Create backup + Snapshot your library, categories, and tracker links +
+ +
+ + {#if backupError} +
{backupError}
+ {/if} + + {#if backupList.length === 0} +

No backups yet — create one above.

+ {:else} + {#each backupList as backup} +
+ + {backup.name} + + +
+ {/each} + {/if} + +
+
+ Restore from file + {restoreFile ? restoreFile.name : 'Select a .tachibk file'} +
+ +
+ + {#if restoreFile} +
+
+
+ + +
+
+ {/if} + + {#if validateError} +
{validateError}
+ {/if} + + {#if validateResult} + {#if validateResult.missingSources.length === 0 && validateResult.missingTrackers.length === 0} +
✓ All sources and trackers present
+ {:else} + {#if validateResult.missingSources.length > 0} +
+
+ Missing sources + {validateResult.missingSources.map(s => s.name).join(', ')} +
+
+ {/if} + {#if validateResult.missingTrackers.length > 0} +
+
+ Missing trackers + {validateResult.missingTrackers.map(t => t.name).join(', ')} +
+
+ {/if} + {/if} + {/if} + + {#if restoreError} +
{restoreError}
+ {/if} + + {#if restoreStatus} +
+
+ + {restoreStatus.state === 'SUCCESS' ? '✓ Restore complete' : + restoreStatus.state === 'FAILURE' ? '✗ Restore failed' : 'Restoring…'} + + {#if restoreStatus.totalManga > 0} + {restoreStatus.mangaProgress} / {restoreStatus.totalManga} manga + {/if} +
+ {#if restoreStatus.state !== 'SUCCESS' && restoreStatus.state !== 'FAILURE' && restoreStatus.totalManga > 0} +
+
+
+ {/if} +
+ {/if} + +

App data backup

+ +
+
+ Export settings + Save all Moku app settings to a .zip via a native save dialog. +
+ +
+ +
+
+ Import settings + Restore from a previously exported .zip file. Reloads the app immediately. +
+ +
+ + {#if appDataError} +
{appDataError}
+ {/if} + + {#if appDataMsg} +
+ {appDataMsg} +
+ {/if} + + {#if appDataBackupDir} +
+
+ Auto-backup location + Pre-update snapshots are kept here (last 5). +
+ +
+ {/if} + +
+ {/if} +
+ +
+ + {#if resetSectionOpen} +
+ {#each resetItems as item} +
+
+ {item.label} + {item.desc} + {#if item.error}{item.error}{/if} +
+
+ {#if item.state === 'done'} + Done + {:else if item.state === 'busy'} + + {:else if confirming === item.key} + Sure? + + + {:else} + + {/if} +
+
+ {/each} +
+ {/if} +
+ +
\ No newline at end of file diff --git a/src/lib/components/settings/sections/TrackingSettings.svelte b/src/lib/components/settings/sections/TrackingSettings.svelte new file mode 100644 index 0000000..8d2d59e --- /dev/null +++ b/src/lib/components/settings/sections/TrackingSettings.svelte @@ -0,0 +1,274 @@ + + +
+ +
+

Connected Trackers

+
+ {#if trackersError} +
trackersError = null} role="button" tabindex="0" onkeydown={(e) => e.key === "Enter" && (trackersError = null)}>{trackersError}
+ {/if} + {#if trackersLoading} +

Loading trackers…

+ {:else} + {#each trackers as tracker} +
+
+ +
+ {tracker.name} +
+ + {tracker.isLoggedIn ? "Connected" : "Not connected"} + + {#if tracker.isLoggedIn && tracker.isTokenExpired} + Token expired — reconnect + {/if} +
+
+
+
+ {#if tracker.isLoggedIn && tracker.isTokenExpired} + + + {:else if tracker.isLoggedIn} + + {:else if oauthTrackerId !== tracker.id && credsTrackerId !== tracker.id} + + {/if} +
+ {#if oauthTrackerId === tracker.id} +
+ {#if oauthError} +
oauthError = null} role="button" tabindex="0" onkeydown={(e) => e.key === "Enter" && (oauthError = null)}>{oauthError}
+ {/if} +

Browser opened {tracker.name} login — authorise then paste the callback URL below.

+ { if (e.key === "Enter") submitOAuth(); if (e.key === "Escape") cancelOAuth(); }} + use:focusEl /> +
+ + +
+
+ {/if} + {#if credsTrackerId === tracker.id} +
+ {#if credsError} +
credsError = null} role="button" tabindex="0" onkeydown={(e) => e.key === "Enter" && (credsError = null)}>{credsError}
+ {/if} + e.key === "Escape" && cancelCredentials()} use:focusEl /> + { if (e.key === "Enter") submitCredentials(); if (e.key === "Escape") cancelCredentials(); }} /> +
+ + +
+
+ {/if} +
+ {/each} + {/if} +
+
+ +
+

Sync back from tracker

+
+
+ Enable sync back + Mark chapters read locally based on tracker progress +
+ +
+ + {#if settings.trackerSyncBack} + + {#if settings.trackerSyncBackThreshold !== null} +
+
ToleranceMax chapter number difference allowed (1–20)
+
+ + {settings.trackerSyncBackThreshold} + +
+
+ {/if} + +
+
+ Respect scanlator filter + Only mark chapters matching the series' active scanlator filter +
+ +
+ +
+
+ Sync now + Apply tracker progress to all linked manga in your library +
+ +
+ {/if} +
+ +
+ + \ No newline at end of file diff --git a/src/lib/components/shared/manga/MangaPreview.svelte b/src/lib/components/shared/manga/MangaPreview.svelte new file mode 100644 index 0000000..2be5aa2 --- /dev/null +++ b/src/lib/components/shared/manga/MangaPreview.svelte @@ -0,0 +1,839 @@ + + +{#if appState.previewManga} +
{ if (e.target === e.currentTarget) close(); }} + onkeydown={(e) => { if (e.key === "Escape") close(); }} +> + +
+{/if} + +{#if linkPickerOpen && appState.previewManga} + linkPickerOpen = false} + /> +{/if} + +{#if coverPickerOpen && appState.previewManga} + coverPickerOpen = false} + /> +{/if} + + \ No newline at end of file diff --git a/src/lib/components/shared/manga/SourceBrowse.svelte b/src/lib/components/shared/manga/SourceBrowse.svelte new file mode 100644 index 0000000..4a81d05 --- /dev/null +++ b/src/lib/components/shared/manga/SourceBrowse.svelte @@ -0,0 +1,195 @@ + + +{#if appState.activeSource} +
+
+ + {appState.activeSource.displayName} +
+ +
+
+ {#each (["POPULAR", "LATEST"] as BrowseType[]) as mode} + + {/each} + {#if search}{/if} +
+
+ + e.key === "Enter" && submitSearch()} /> +
+
+ + {#if loading} +
+ {#each Array(18) as _} +
+ {/each} +
+ {:else if mangas.length === 0} +
No results.
+ {:else} +
+ {#each mangas as m (m.id)} + + {/each} +
+ {/if} + + {#if !loading && (page > 1 || hasNextPage)} + + {/if} +
+{/if} + +{#if ctx} + ctx = null} /> +{/if} + + \ No newline at end of file diff --git a/src/lib/components/shared/manga/ThreeDCard.svelte b/src/lib/components/shared/manga/ThreeDCard.svelte new file mode 100644 index 0000000..83c21cb --- /dev/null +++ b/src/lib/components/shared/manga/ThreeDCard.svelte @@ -0,0 +1,130 @@ + + +
+
+ {@render children()} +
+
+
+
+
+
+
+
+
+
+
+
+
+ + \ No newline at end of file diff --git a/src/lib/components/shared/manga/Thumbnail.svelte b/src/lib/components/shared/manga/Thumbnail.svelte new file mode 100644 index 0000000..8cb0ca6 --- /dev/null +++ b/src/lib/components/shared/manga/Thumbnail.svelte @@ -0,0 +1,52 @@ + + + \ No newline at end of file diff --git a/src/lib/core/algorithms/filter.ts b/src/lib/core/algorithms/filter.ts index f5ecd05..9dc29e2 100644 --- a/src/lib/core/algorithms/filter.ts +++ b/src/lib/core/algorithms/filter.ts @@ -1,5 +1,5 @@ -export { shouldHideNsfw, shouldHideSource, dedupeSourcesByLang } from "@core/util"; +export { shouldHideNsfw, shouldHideSource, dedupeSourcesByLang } from '$lib/core/util' export function buildFilter(...predicates: ((item: T) => boolean)[]): (item: T) => boolean { - return (item) => predicates.every((p) => p(item)); + return item => predicates.every(p => p(item)) } \ No newline at end of file diff --git a/src/lib/core/algorithms/paginate.ts b/src/lib/core/algorithms/paginate.ts index 979dab8..16b347c 100644 --- a/src/lib/core/algorithms/paginate.ts +++ b/src/lib/core/algorithms/paginate.ts @@ -1,11 +1,11 @@ export interface PaginationState { - visible: number; + visible: number } export interface PaginationResult { - items: T[]; - hasMore: boolean; - remaining: number; + items: T[] + hasMore: boolean + remaining: number } export function createPaginator(pageSize: number) { @@ -15,15 +15,9 @@ export function createPaginator(pageSize: number) { items: all.slice(0, visible), hasMore: all.length > visible, remaining: Math.max(0, all.length - visible), - }; + } }, - - nextVisible(current: number): number { - return current + pageSize; - }, - - reset(): number { - return pageSize; - }, - }; -} + nextVisible(current: number): number { return current + pageSize }, + reset(): number { return pageSize }, + } +} \ No newline at end of file diff --git a/src/lib/core/algorithms/queue.ts b/src/lib/core/algorithms/queue.ts index 81a6a8c..5f71b73 100644 --- a/src/lib/core/algorithms/queue.ts +++ b/src/lib/core/algorithms/queue.ts @@ -1,29 +1,26 @@ export interface AsyncQueue { - enqueue(item: T): void; - drain(): void; - clear(): void; - size(): number; + enqueue(item: T): void + drain(): void + clear(): void + size(): number } -export function createAsyncQueue( - worker: (item: T) => Promise, - concurrency = 1, -): AsyncQueue { - const queue: T[] = []; - let active = 0; +export function createAsyncQueue(worker: (item: T) => Promise, concurrency = 1): AsyncQueue { + const queue: T[] = [] + let active = 0 function next() { while (active < concurrency && queue.length > 0) { - const item = queue.shift()!; - active++; - worker(item).finally(() => { active--; next(); }); + const item = queue.shift()! + active++ + worker(item).finally(() => { active--; next() }) } } return { - enqueue(item) { queue.push(item); next(); }, - drain() { next(); }, - clear() { queue.length = 0; }, - size() { return queue.length; }, - }; -} + enqueue(item) { queue.push(item); next() }, + drain() { next() }, + clear() { queue.length = 0 }, + size() { return queue.length }, + } +} \ No newline at end of file diff --git a/src/lib/core/algorithms/search.ts b/src/lib/core/algorithms/search.ts index 0c92805..d206753 100644 --- a/src/lib/core/algorithms/search.ts +++ b/src/lib/core/algorithms/search.ts @@ -1,33 +1,24 @@ export interface SearchResult { - item: T; - score: number; + item: T + score: number } -export function searchItems( - items: T[], - query: string, - getField: (item: T) => string, -): T[] { - const q = query.trim().toLowerCase(); - if (!q) return items; - return items.filter(item => getField(item).toLowerCase().includes(q)); +export function searchItems(items: T[], query: string, getField: (item: T) => string): T[] { + const q = query.trim().toLowerCase() + if (!q) return items + return items.filter(item => getField(item).toLowerCase().includes(q)) } -export function searchWithScore( - items: T[], - query: string, - getField: (item: T) => string, -): SearchResult[] { - const q = query.trim().toLowerCase(); - if (!q) return items.map(item => ({ item, score: 0 })); - +export function searchWithScore(items: T[], query: string, getField: (item: T) => string): SearchResult[] { + const q = query.trim().toLowerCase() + if (!q) return items.map(item => ({ item, score: 0 })) return items .map(item => { - const field = getField(item).toLowerCase(); - if (!field.includes(q)) return null; - const score = field === q ? 2 : field.startsWith(q) ? 1 : 0; - return { item, score }; + const field = getField(item).toLowerCase() + if (!field.includes(q)) return null + const score = field === q ? 2 : field.startsWith(q) ? 1 : 0 + return { item, score } }) .filter((r): r is SearchResult => r !== null) - .sort((a, b) => b.score - a.score); -} + .sort((a, b) => b.score - a.score) +} \ No newline at end of file diff --git a/src/lib/core/algorithms/sort.ts b/src/lib/core/algorithms/sort.ts index d148045..e75e3aa 100644 --- a/src/lib/core/algorithms/sort.ts +++ b/src/lib/core/algorithms/sort.ts @@ -1,32 +1,31 @@ -export type SortDir = "asc" | "desc"; +export type SortDir = 'asc' | 'desc' export interface SortField { - key: string; - comparator: (a: T, b: T, context?: Record) => number; + key: string + comparator: (a: T, b: T, context?: Record) => number } export interface SortConfig { - fields: SortField[]; - defaultField: string; - defaultDir: SortDir; + fields: SortField[] + defaultField: string + defaultDir: SortDir } export interface Sorter { - sort(items: T[], field: string, dir: SortDir, context?: Record): T[]; + sort(items: T[], field: string, dir: SortDir, context?: Record): T[] } export function createSorter(config: SortConfig): Sorter { - const fieldMap = new Map(config.fields.map(f => [f.key, f])); - + const fieldMap = new Map(config.fields.map(f => [f.key, f])) return { sort(items, field, dir, context) { - const f = fieldMap.get(field) ?? fieldMap.get(config.defaultField); - if (!f) return [...items]; - const d = dir ?? config.defaultDir; + const f = fieldMap.get(field) ?? fieldMap.get(config.defaultField) + if (!f) return [...items] + const d = dir ?? config.defaultDir return [...items].sort((a, b) => { - const cmp = f.comparator(a, b, context); - return d === "asc" ? cmp : -cmp; - }); + const cmp = f.comparator(a, b, context) + return d === 'asc' ? cmp : -cmp + }) }, - }; -} + } +} \ No newline at end of file diff --git a/src/lib/core/async/batchRequests.ts b/src/lib/core/async/batchRequests.ts new file mode 100644 index 0000000..bdcadaa --- /dev/null +++ b/src/lib/core/async/batchRequests.ts @@ -0,0 +1,35 @@ +const _inflight = new Map>() + +export async function runConcurrent( + items: T[], + fn: (item: T) => Promise, + signal: AbortSignal, + concurrency = 6, +): Promise { + let i = 0 + async function worker() { + while (i < items.length) { + if (signal.aborted) return + const item = items[i++] + await fn(item).catch(() => {}) + } + } + await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker)) +} + +export function dedupeRequest(key: string, factory: () => Promise): Promise +export function dedupeRequest(fn: (key: string) => Promise): (key: string) => Promise +export function dedupeRequest( + keyOrFn: string | ((key: string) => Promise), + factory?: () => Promise, +): Promise | ((key: string) => Promise) { + if (typeof keyOrFn === 'function') { + const fn = keyOrFn + return (key: string) => dedupeRequest(key, () => fn(key)) + } + const key = keyOrFn + if (_inflight.has(key)) return _inflight.get(key) as Promise + const p = factory!().finally(() => _inflight.delete(key)) + _inflight.set(key, p) + return p +} \ No newline at end of file diff --git a/src/lib/core/async/createPaginatedQuery.ts b/src/lib/core/async/createPaginatedQuery.ts new file mode 100644 index 0000000..349e494 --- /dev/null +++ b/src/lib/core/async/createPaginatedQuery.ts @@ -0,0 +1,22 @@ +export interface PaginatedQuery { + fetchPage(page: number): Promise + reset(): void + hasMore(): boolean +} + +export interface PaginatedQueryConfig { + fetcher: (page: number) => Promise<{ items: T[]; hasNextPage: boolean }> +} + +export function createPaginatedQuery(config: PaginatedQueryConfig): PaginatedQuery { + let _hasMore = true + return { + async fetchPage(page) { + const { items, hasNextPage } = await config.fetcher(page) + _hasMore = hasNextPage + return items + }, + reset() { _hasMore = true }, + hasMore() { return _hasMore }, + } +} \ No newline at end of file diff --git a/src/lib/core/async/fetchWithRetry.ts b/src/lib/core/async/fetchWithRetry.ts new file mode 100644 index 0000000..90cf913 --- /dev/null +++ b/src/lib/core/async/fetchWithRetry.ts @@ -0,0 +1,31 @@ +export interface RetryOptions { + maxAttempts?: number + baseDelayMs?: number + maxDelayMs?: number + shouldRetry?: (err: unknown, attempt: number) => boolean +} + +export async function fetchWithRetry( + fetcher: () => Promise, + options: RetryOptions = {}, +): Promise { + const { + maxAttempts = 3, + baseDelayMs = 500, + maxDelayMs = 10_000, + shouldRetry = () => true, + } = options + + let lastErr: unknown + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await fetcher() + } catch (err) { + lastErr = err + if (attempt === maxAttempts || !shouldRetry(err, attempt)) throw err + const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), maxDelayMs) + await new Promise(r => setTimeout(r, delay)) + } + } + throw lastErr +} \ No newline at end of file diff --git a/src/lib/core/auth.ts b/src/lib/core/auth.ts index 99043fc..0cdd999 100644 --- a/src/lib/core/auth.ts +++ b/src/lib/core/auth.ts @@ -1,16 +1,74 @@ const DEFAULT_URL = 'http://127.0.0.1:4567' interface AuthConfig { - baseUrl: string - mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' - user?: string - pass?: string + baseUrl: string + mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' + user?: string + pass?: string } +export interface UiAuthDebugStatus { + mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' + hasSession: boolean + hasRefreshToken: boolean + accessExpiresAt: number | null + refreshExpiresAt: number | null + accessExpiresInMs: number | null + refreshExpiresInMs: number | null + shouldRefreshSoon: boolean + refreshInFlight: boolean + skewMs: number +} + +const SKEW_MS = 60_000 * 2 + let config: AuthConfig = { baseUrl: DEFAULT_URL, mode: 'NONE' } -let accessToken: string | null = null -let refreshToken: string | null = null +let accessToken: string | null = null +let refreshToken: string | null = null +let accessExpiresAt: number | null = null +let refreshExpiresAt: number | null = null +let refreshInFlight = false + +function parseExpiry(token: string): number | null { + try { + const payload = JSON.parse(atob(token.split('.')[1])) + return typeof payload.exp === 'number' ? payload.exp * 1000 : null + } catch { + return null + } +} + +export const authSession = { + clearTokens() { + accessToken = null + refreshToken = null + accessExpiresAt = null + refreshExpiresAt = null + }, +} + +export function getUIAccessToken(): string | null { + return accessToken +} + +export function getUiAuthDebugStatus(): UiAuthDebugStatus { + const now = Date.now() + const accessExpiresInMs = accessExpiresAt !== null ? accessExpiresAt - now : null + const refreshExpiresInMs = refreshExpiresAt !== null ? refreshExpiresAt - now : null + return { + mode: config.mode, + hasSession: accessToken !== null, + hasRefreshToken: refreshToken !== null, + accessExpiresAt, + refreshExpiresAt, + accessExpiresInMs, + refreshExpiresInMs, + shouldRefreshSoon: accessExpiresInMs !== null && accessExpiresInMs < SKEW_MS, + refreshInFlight, + skewMs: SKEW_MS, + } +} export function configureAuth( baseUrl: string, @@ -19,8 +77,7 @@ export function configureAuth( pass?: string, ): void { config = { baseUrl: baseUrl.replace(/\/$/, ''), mode, user, pass } - accessToken = null - refreshToken = null + authSession.clearTokens() } export function authHeaders(): Record { @@ -92,10 +149,12 @@ export async function loginUI(user: string, pass: string): Promise { const data = await gqlRaw(LOGIN_MUTATION, { username: user, password: pass }) as { login: { accessToken: string; refreshToken: string } } - accessToken = data.login.accessToken - refreshToken = data.login.refreshToken - config.mode = 'UI_LOGIN' - config.user = user + accessToken = data.login.accessToken + refreshToken = data.login.refreshToken + accessExpiresAt = parseExpiry(accessToken) + refreshExpiresAt = parseExpiry(refreshToken) + config.mode = 'UI_LOGIN' + config.user = user } export async function refreshAccessToken(): Promise { @@ -104,9 +163,25 @@ export async function refreshAccessToken(): Promise { const data = await gqlRaw(REFRESH_MUTATION, { refreshToken }) as { refreshToken: { accessToken: string } } - accessToken = data.refreshToken.accessToken + accessToken = data.refreshToken.accessToken + accessExpiresAt = parseExpiry(accessToken) return true } catch { return false } -} \ No newline at end of file +} + +export async function refreshUiAccessToken(force = false): Promise { + if (config.mode !== 'UI_LOGIN') return null + if (!refreshToken) return null + const now = Date.now() + if (!force && accessExpiresAt !== null && accessExpiresAt - now > SKEW_MS) return accessToken + if (refreshInFlight) return accessToken + refreshInFlight = true + try { + const ok = await refreshAccessToken() + return ok ? accessToken : null + } finally { + refreshInFlight = false + } +} diff --git a/src/lib/core/backup.ts b/src/lib/core/backup.ts new file mode 100644 index 0000000..8446b7e --- /dev/null +++ b/src/lib/core/backup.ts @@ -0,0 +1,256 @@ +import { invoke } from "@tauri-apps/api/core"; +import { + persistSettings, + persistLibrary, + persistUpdates, +} from "$lib/core/persistence/persist"; + +const STORE_FILES = ["settings.json", "library.json", "updates.json"] as const; + +export async function exportAppData(): Promise { + 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("export_app_data", { bytes: Array.from(zip) }); +} + +export async function importAppData(): Promise { + const raw: number[] = await invoke("import_app_data"); + const files = parseZip(new Uint8Array(raw)); + + const decode = (name: string) => { + const bytes = files.get(name); + if (!bytes) throw new Error(`Backup is missing ${name}`); + return JSON.parse(new TextDecoder().decode(bytes)); + }; + + const s = decode("settings.json"); + const l = decode("library.json"); + const u = decode("updates.json"); + + await Promise.all([ + persistSettings({ + settings: s.settings ?? null, + storeVersion: s.storeVersion ?? 1, + }), + persistLibrary({ + history: l.history ?? [], + bookmarks: l.bookmarks ?? [], + markers: l.markers ?? [], + readLog: l.readLog ?? [], + readingStats: l.readingStats ?? null, + dailyReadCounts: l.dailyReadCounts ?? {}, + }), + persistUpdates({ + libraryUpdates: u.libraryUpdates ?? [], + lastLibraryRefresh: u.lastLibraryRefresh ?? 0, + acknowledgedUpdateIds: u.acknowledgedUpdateIds ?? [], + }), + ]); + + await showExitModal(); + invoke("exit_app"); +} + +function showExitModal(): Promise { + return new Promise(resolve => { + const backdrop = document.createElement("div"); + backdrop.className = "s-backdrop"; + backdrop.style.cssText = "z-index:99999"; + + const modal = document.createElement("div"); + modal.style.cssText = [ + "background:var(--bg-surface)", + "border:1px solid var(--border-base)", + "border-radius:var(--radius-2xl)", + "box-shadow:0 0 0 1px rgba(255,255,255,0.04) inset,0 24px 80px rgba(0,0,0,0.7),0 8px 24px rgba(0,0,0,0.4)", + "width:min(400px,calc(100vw - 40px))", + "display:flex", + "flex-direction:column", + "overflow:hidden", + "animation:s-scale-in 0.2s cubic-bezier(0.16,1,0.3,1) both", + ].join(";"); + + const header = document.createElement("div"); + header.style.cssText = "padding:var(--sp-4) var(--sp-5) var(--sp-3);border-bottom:1px solid var(--border-dim)"; + + const title = document.createElement("p"); + title.style.cssText = "margin:0;font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--text-primary);letter-spacing:0.01em"; + title.textContent = "Import complete"; + header.appendChild(title); + + const body = document.createElement("div"); + body.style.cssText = "padding:var(--sp-4) var(--sp-5);display:flex;flex-direction:column;gap:var(--sp-2)"; + + const sub = document.createElement("p"); + sub.style.cssText = "margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-faint);letter-spacing:var(--tracking-wide);line-height:var(--leading-snug)"; + sub.textContent = "Your settings have been restored. Moku will close so you can relaunch with the imported data."; + + const counter = document.createElement("p"); + counter.style.cssText = "margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-faint);letter-spacing:var(--tracking-wide)"; + counter.textContent = "Closing in 3…"; + + body.append(sub, counter); + + const footer = document.createElement("div"); + footer.style.cssText = "padding:var(--sp-3) var(--sp-5);border-top:1px solid var(--border-dim);display:flex;justify-content:flex-end"; + + const btn = document.createElement("button"); + btn.className = "s-btn s-btn-danger"; + btn.textContent = "Close now"; + + footer.appendChild(btn); + modal.append(header, body, footer); + backdrop.appendChild(modal); + document.body.appendChild(backdrop); + + let secs = 3; + const tick = setInterval(() => { + secs--; + counter.textContent = secs > 0 ? `Closing in ${secs}…` : "Closing…"; + if (secs <= 0) { clearInterval(tick); backdrop.remove(); resolve(); } + }, 1000); + + btn.addEventListener("click", () => { clearInterval(tick); backdrop.remove(); resolve(); }); + }); +} + +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) { + crc ^= byte; + for (let j = 0; j < 8; j++) { + crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1; + } + } + return (crc ^ 0xffffffff) >>> 0; +} + +function localHeader(name: Uint8Array, data: Uint8Array): Uint8Array { + const buf = new ArrayBuffer(30 + name.byteLength); + const v = new DataView(buf); + v.setUint32(0, 0x04034b50, true); + v.setUint16(4, 20, true); + v.setUint16(6, 0, true); + v.setUint16(8, 0, true); + v.setUint16(10, 0, true); + v.setUint16(12, 0, true); + v.setUint32(14, crc32(data), true); + v.setUint32(18, data.byteLength, true); + v.setUint32(22, data.byteLength, true); + v.setUint16(26, name.byteLength, true); + v.setUint16(28, 0, true); + new Uint8Array(buf).set(name, 30); + return new Uint8Array(buf); +} + +function centralHeader(name: Uint8Array, data: Uint8Array, offset: number): Uint8Array { + const buf = new ArrayBuffer(46 + name.byteLength); + const v = new DataView(buf); + v.setUint32(0, 0x02014b50, true); + v.setUint16(4, 20, true); + v.setUint16(6, 20, true); + v.setUint16(8, 0, true); + v.setUint16(10, 0, true); + v.setUint16(12, 0, true); + v.setUint16(14, 0, true); + v.setUint32(16, crc32(data), true); + v.setUint32(20, data.byteLength, true); + v.setUint32(24, data.byteLength, true); + v.setUint16(28, name.byteLength, true); + v.setUint16(30, 0, true); + v.setUint16(32, 0, true); + v.setUint16(34, 0, true); + v.setUint16(36, 0, true); + v.setUint32(38, 0, true); + v.setUint32(42, offset, true); + new Uint8Array(buf).set(name, 46); + return new Uint8Array(buf); +} + +function eocd(count: number, cdSize: number, cdOffset: number): Uint8Array { + const buf = new ArrayBuffer(22); + const v = new DataView(buf); + v.setUint32(0, 0x06054b50, true); + v.setUint16(4, 0, true); + v.setUint16(6, 0, true); + v.setUint16(8, count, true); + v.setUint16(10, count, true); + v.setUint32(12, cdSize, true); + v.setUint32(16, cdOffset, true); + v.setUint16(20, 0, true); + return new Uint8Array(buf); +} + +function buildZip(files: { name: string; bytes: Uint8Array }[]): Uint8Array { + const enc = new TextEncoder(); + const parts: Uint8Array[] = []; + const offsets: number[] = []; + let pos = 0; + + for (const { name, bytes } of files) { + const nameBytes = enc.encode(name); + const lh = localHeader(nameBytes, bytes); + offsets.push(pos); + parts.push(lh, bytes); + pos += lh.byteLength + bytes.byteLength; + } + + const cdParts = files.map(({ name, bytes }, i) => + centralHeader(enc.encode(name), bytes, offsets[i]) + ); + const cd = concat(cdParts); + + return concat([...parts, cd, eocd(files.length, cd.byteLength, pos)]); +} + +function parseZip(data: Uint8Array): Map { + const view = new DataView(data.buffer, data.byteOffset, data.byteLength); + const files = new Map(); + let pos = 0; + + while (pos + 30 <= data.byteLength && view.getUint32(pos, true) === 0x04034b50) { + const fnLen = view.getUint16(pos + 26, true); + const exLen = view.getUint16(pos + 28, true); + const cSize = view.getUint32(pos + 18, true); + const name = new TextDecoder().decode(data.subarray(pos + 30, pos + 30 + fnLen)); + const start = pos + 30 + fnLen + exLen; + files.set(name, data.subarray(start, start + cSize)); + pos = start + cSize; + } + + return files; +} + +function concat(arrays: Uint8Array[]): Uint8Array { + const total = arrays.reduce((n, a) => n + a.byteLength, 0); + const out = new Uint8Array(total); + let pos = 0; + for (const a of arrays) { out.set(a, pos); pos += a.byteLength; } + return out; +} \ No newline at end of file diff --git a/src/lib/core/cache/imageCache.ts b/src/lib/core/cache/imageCache.ts new file mode 100644 index 0000000..2c8863a --- /dev/null +++ b/src/lib/core/cache/imageCache.ts @@ -0,0 +1,134 @@ +import { fetch as tauriFetch } from "@tauri-apps/plugin-http"; +import { settingsState } from "$lib/state/settings.svelte"; +import { getUIAccessToken } from "$lib/core/auth"; + +const cache = new Map(); +const inflight = new Map>(); +const MAX_CONCURRENT = 6; +let active = 0; +let drainScheduled = false; +let clearing = false; + +interface QueueEntry { + url: string; + priority: number; + resolve: (v: string) => void; + reject: (e: unknown) => void; +} + +const queue: QueueEntry[] = []; + +async function getAuthHeaders(): Promise> { + const mode = settingsState.serverAuthMode ?? "NONE"; + if (mode === "UI_LOGIN") { + const token = await getUIAccessToken(); + return token ? { Authorization: `Bearer ${token}` } : {}; + } + if (mode === "BASIC_AUTH") { + const user = settingsState.serverAuthUser?.trim() ?? ""; + const pass = settingsState.serverAuthPass?.trim() ?? ""; + return user && pass ? { Authorization: `Basic ${btoa(`${user}:${pass}`)}` } : {}; + } + return {}; +} + +async function doFetch(url: string): Promise { + const headers = await getAuthHeaders(); + const res = await tauriFetch(url, { method: "GET", headers }); + if (!res.ok) throw new Error(`${res.status}`); + const blob = await res.blob(); + if (clearing) throw new DOMException("Cancelled", "AbortError"); + const blobUrl = URL.createObjectURL(blob); + cache.set(url, blobUrl); + return blobUrl; +} + +function insertSorted(entry: QueueEntry) { + let lo = 0, hi = queue.length; + while (lo < hi) { + const mid = (lo + hi) >>> 1; + if (queue[mid].priority > entry.priority) lo = mid + 1; + else hi = mid; + } + queue.splice(lo, 0, entry); +} + +function drain() { + drainScheduled = false; + while (active < MAX_CONCURRENT && queue.length > 0) { + const entry = queue.shift()!; + active++; + doFetch(entry.url) + .then(entry.resolve, entry.reject) + .finally(() => { active--; drain(); }); + } +} + +function scheduleDrain() { + if (drainScheduled) return; + drainScheduled = true; + requestAnimationFrame(drain); +} + +function enqueue(url: string, priority: number): Promise { + const promise = new Promise((resolve, reject) => { + insertSorted({ url, priority, resolve, reject }); + }).catch(err => { + inflight.delete(url); + return Promise.reject(err); + }); + inflight.set(url, promise); + scheduleDrain(); + return promise; +} + +export function getBlobUrl(url: string, priority = 0): Promise { + if (!url) return Promise.resolve(""); + const cached = cache.get(url); + if (cached) return Promise.resolve(cached); + const existing = inflight.get(url); + if (existing) { + const idx = queue.findIndex(e => e.url === url); + if (idx !== -1 && priority > queue[idx].priority) { + const [entry] = queue.splice(idx, 1); + entry.priority = priority; + insertSorted(entry); + } + return existing; + } + return enqueue(url, priority); +} + +export function preloadBlobUrls(urls: string[], basePriority = 0): void { + urls.forEach((url, i) => { + if (!url || cache.has(url) || inflight.has(url)) return; + enqueue(url, basePriority - i); + }); +} + +export function revokeBlobUrl(url: string): void { + const blob = cache.get(url); + if (blob) { URL.revokeObjectURL(blob); cache.delete(url); } +} + +export function deprioritizeQueue(): void { + for (const entry of queue) entry.priority = 0; + queue.sort((a, b) => b.priority - a.priority); +} + +export function cancelQueuedFetches(): void { + const dropped = queue.splice(0); + for (const entry of dropped) { + inflight.delete(entry.url); + entry.reject(new DOMException("Cancelled", "AbortError")); + } +} + +export function clearBlobCache(): void { + clearing = true; + cancelQueuedFetches(); + cache.forEach(blob => URL.revokeObjectURL(blob)); + cache.clear(); + inflight.clear(); + clearing = false; +} \ No newline at end of file diff --git a/src/lib/core/cache/queryCache.ts b/src/lib/core/cache/queryCache.ts index 1baae89..9c47070 100644 --- a/src/lib/core/cache/queryCache.ts +++ b/src/lib/core/cache/queryCache.ts @@ -146,13 +146,13 @@ export const CACHE_GROUPS = { } as const; export const CACHE_KEYS = { - LIBRARY: "library", + LIBRARY: "library", RECENT_UPDATES: "recent_updates", - ALL_MANGA: "all_manga_unfiltered", - CATEGORIES: "categories", - SEARCH: "search_all_manga", - SOURCES: "sources", - POPULAR: "popular", + ALL_MANGA: "all_manga_unfiltered", + CATEGORIES: "categories", + SEARCH: "search_all_manga", + SOURCES: "sources", + POPULAR: "popular", GENRE: (genre: string) => `genre:${genre}`, MANGA: (id: number) => `manga:${id}`, CHAPTERS: (id: number) => `chapters:${id}`, @@ -234,7 +234,7 @@ export async function refreshMangaCache(mangaId: number, thumbnailUrl?: string): cache.clear(CACHE_KEYS.ALL_MANGA); if (thumbnailUrl) { - const { revokeBlobUrl, getBlobUrl } = await import("@core/cache/imageCache"); + const { revokeBlobUrl, getBlobUrl } = await import("$lib/core/cache/imageCache"); revokeBlobUrl(thumbnailUrl); getBlobUrl(thumbnailUrl, 999).catch(() => {}); } diff --git a/src/lib/core/cover/autoLink.ts b/src/lib/core/cover/autoLink.ts new file mode 100644 index 0000000..ce50ecd --- /dev/null +++ b/src/lib/core/cover/autoLink.ts @@ -0,0 +1,24 @@ +import { appState } from '$lib/state/app.svelte' +import type { Manga } from '$lib/types' + +export function autoLinkLibrary(focal: Manga, allManga: Manga[]): Promise { + return new Promise(resolve => { + const worker = new Worker(new URL('./autoLinkWorker.ts', import.meta.url), { type: 'module' }) + + worker.onmessage = (e: MessageEvent) => { + const matches = e.data + for (const id of matches) appState.linkManga(focal.id, id) + worker.terminate() + resolve(matches.length) + } + + worker.onerror = () => { worker.terminate(); resolve(0) } + + worker.postMessage({ + focalTitle: focal.title, + focalId: focal.id, + allManga: allManga.map(m => ({ id: m.id, title: m.title })), + linkedIds: appState.settings.mangaLinks?.[focal.id] ?? [], + }) + }) +} \ No newline at end of file diff --git a/src/lib/core/cover/autoLinkWorker.ts b/src/lib/core/cover/autoLinkWorker.ts index b5bae69..95a84bd 100644 --- a/src/lib/core/cover/autoLinkWorker.ts +++ b/src/lib/core/cover/autoLinkWorker.ts @@ -1,29 +1,26 @@ interface WorkerMsg { - focalTitle: string; - focalId: number; - allManga: { id: number; title: string }[]; - linkedIds: number[]; + focalTitle: string + focalId: number + allManga: { id: number; title: string }[] + linkedIds: number[] } function titleSimilarity(a: string, b: string): number { - const norm = (s: string) => - s.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter(Boolean); - const wa = new Set(norm(a)); - const wb = new Set(norm(b)); - if (!wa.size || !wb.size) return 0; - const intersection = [...wa].filter(w => wb.has(w)).length; - return intersection / new Set([...wa, ...wb]).size; + const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9\s]/g, '').split(/\s+/).filter(Boolean) + const wa = new Set(norm(a)) + const wb = new Set(norm(b)) + if (!wa.size || !wb.size) return 0 + const intersection = [...wa].filter(w => wb.has(w)).length + return intersection / new Set([...wa, ...wb]).size } self.onmessage = (e: MessageEvent) => { - const { focalTitle, focalId, allManga, linkedIds } = e.data; - const matches: number[] = []; - + const { focalTitle, focalId, allManga, linkedIds } = e.data + const matches: number[] = [] for (const m of allManga) { - if (m.id === focalId) continue; - if (linkedIds.includes(m.id)) continue; - if (titleSimilarity(focalTitle, m.title) >= 0.65) matches.push(m.id); + if (m.id === focalId) continue + if (linkedIds.includes(m.id)) continue + if (titleSimilarity(focalTitle, m.title) >= 0.65) matches.push(m.id) } - - self.postMessage(matches); -}; \ No newline at end of file + self.postMessage(matches) +} \ No newline at end of file diff --git a/src/lib/core/cover/coverHash.ts b/src/lib/core/cover/coverHash.ts index 5dbf7c2..6a73a77 100644 --- a/src/lib/core/cover/coverHash.ts +++ b/src/lib/core/cover/coverHash.ts @@ -1,54 +1,53 @@ -const THUMB_SIZE = 16; -const DUPE_THRESH = 0.12; - -const hashCache = new Map(); +const THUMB_SIZE = 16 +const DUPE_THRESH = 0.12 +const hashCache = new Map() function toGray(data: Uint8ClampedArray, pixels: number): Uint8ClampedArray { - const gray = new Uint8ClampedArray(pixels); + const gray = new Uint8ClampedArray(pixels) for (let i = 0; i < pixels; i++) { - const o = i * 4; - gray[i] = (data[o] * 299 + data[o + 1] * 587 + data[o + 2] * 114) / 1000; + const o = i * 4 + gray[i] = (data[o] * 299 + data[o + 1] * 587 + data[o + 2] * 114) / 1000 } - return gray; + return gray } function loadThumb(url: string): Promise { return new Promise((resolve, reject) => { - const img = new Image(); - img.crossOrigin = "anonymous"; + const img = new Image() + img.crossOrigin = 'anonymous' img.onload = () => { - const canvas = document.createElement("canvas"); - canvas.width = canvas.height = THUMB_SIZE; - const ctx = canvas.getContext("2d")!; - ctx.drawImage(img, 0, 0, THUMB_SIZE, THUMB_SIZE); - resolve(toGray(ctx.getImageData(0, 0, THUMB_SIZE, THUMB_SIZE).data, THUMB_SIZE * THUMB_SIZE)); - }; - img.onerror = reject; - img.src = url; - }); + const canvas = document.createElement('canvas') + canvas.width = canvas.height = THUMB_SIZE + const ctx = canvas.getContext('2d')! + ctx.drawImage(img, 0, 0, THUMB_SIZE, THUMB_SIZE) + resolve(toGray(ctx.getImageData(0, 0, THUMB_SIZE, THUMB_SIZE).data, THUMB_SIZE * THUMB_SIZE)) + } + img.onerror = reject + img.src = url + }) } function similarity(a: Uint8ClampedArray, b: Uint8ClampedArray): number { - let diff = 0; - for (let i = 0; i < a.length; i++) diff += Math.abs(a[i] - b[i]); - return diff / (a.length * 255); + let diff = 0 + for (let i = 0; i < a.length; i++) diff += Math.abs(a[i] - b[i]) + return diff / (a.length * 255) } export async function getHash(url: string): Promise { - if (hashCache.has(url)) return hashCache.get(url)!; + if (hashCache.has(url)) return hashCache.get(url)! try { - const thumb = await loadThumb(url); - hashCache.set(url, thumb); - return thumb; + const thumb = await loadThumb(url) + hashCache.set(url, thumb) + return thumb } catch { - return null; + return null } } export function areDuplicates(a: Uint8ClampedArray, b: Uint8ClampedArray): boolean { - return similarity(a, b) <= DUPE_THRESH; + return similarity(a, b) <= DUPE_THRESH } export function clearHashCache(): void { - hashCache.clear(); + hashCache.clear() } \ No newline at end of file diff --git a/src/lib/core/cover/coverResolver.ts b/src/lib/core/cover/coverResolver.ts new file mode 100644 index 0000000..da4da85 --- /dev/null +++ b/src/lib/core/cover/coverResolver.ts @@ -0,0 +1,92 @@ +import { appState } from '$lib/state/app.svelte' +import { searchWithScore } from '$lib/core/algorithms/search' +import { getHash, areDuplicates } from '$lib/core/cover/coverHash' + +type CoverManga = { id: number; thumbnailUrl: string; source?: { displayName: string } | null } + +export type CoverCandidate = { + mangaId: number + url: string + label: string + isActive: boolean +} + +const FUZZY_SCORE_THRESHOLD = 0.65 + +function normalizeUrl(url: string): string { + try { + const u = new URL(url) + u.search = '' + return u.href.toLowerCase() + } catch { + return url.toLowerCase() + } +} + +export function resolvedCover(mangaId: number, ownUrl: string): string { + return appState.settings.mangaPrefs?.[mangaId]?.coverUrl ?? ownUrl +} + +function fuzzyMatchIds( + mangaId: number, + title: string, + mangaById: Map, +): number[] { + return searchWithScore( + [...mangaById.values()].filter(m => m.id !== mangaId), + title, + m => m.title, + ) + .filter(r => r.score >= FUZZY_SCORE_THRESHOLD) + .map(r => r.item.id) +} + +export function coverCandidatesSync( + mangaId: number, + title: string, + ownUrl: string, + mangaById: Map, +): CoverCandidate[] { + const linkedIds = appState.getLinkedMangaIds(mangaId) + const fuzzyIds = fuzzyMatchIds(mangaId, title, mangaById) + const current = appState.settings.mangaPrefs?.[mangaId]?.coverUrl ?? ownUrl + const allIds = Array.from(new Set([...linkedIds, ...fuzzyIds])) + + const raw: { mangaId: number; url: string; label: string }[] = [ + { mangaId, url: ownUrl, label: 'This source' }, + ...allIds.flatMap(id => { + const m = mangaById.get(id) + return m ? [{ mangaId: m.id, url: m.thumbnailUrl, label: m.source?.displayName ?? `ID ${m.id}` }] : [] + }), + ] + + const seen = new Set() + return raw + .filter(c => { + const key = normalizeUrl(c.url) + if (seen.has(key)) return false + seen.add(key) + return true + }) + .map(c => ({ ...c, isActive: normalizeUrl(c.url) === normalizeUrl(current) })) +} + +export async function dedupeByImage(candidates: CoverCandidate[]): Promise { + const hashes = await Promise.all(candidates.map(c => getHash(c.url))) + const groups: number[][] = [] + + for (let i = 0; i < candidates.length; i++) { + const hi = hashes[i] + const existing = hi + ? groups.find(g => { const hj = hashes[g[0]]; return hj ? areDuplicates(hi, hj) : false }) + : undefined + if (existing) existing.push(i) + else groups.push([i]) + } + + return groups.map(group => { + const active = group.find(i => candidates[i].isActive) ?? group[0] + const labels = [...new Set(group.map(i => candidates[i].label))] + return { ...candidates[active], label: labels.join(' · ') } + }) +} \ No newline at end of file diff --git a/src/lib/core/keybinds/index.ts b/src/lib/core/keybinds/index.ts new file mode 100644 index 0000000..e3d213b --- /dev/null +++ b/src/lib/core/keybinds/index.ts @@ -0,0 +1,3 @@ +export { eventToKeybind, matchesKeybind, toggleFullscreen } from './keybindEngine' +export { DEFAULT_KEYBINDS, KEYBIND_LABELS } from './defaultBinds' +export type { Keybinds } from './defaultBinds' \ No newline at end of file diff --git a/src/lib/core/persistence/index.ts b/src/lib/core/persistence/index.ts new file mode 100644 index 0000000..1b1f9d9 --- /dev/null +++ b/src/lib/core/persistence/index.ts @@ -0,0 +1,5 @@ +export { loadAllStores, persistSettings, persistLibrary, persistUpdates } from "./persist"; +export type { PersistedData } from "./persist"; + +export { vaultExists, lockVault, unlockVault, clearVault, rekeyVault } from "./credentialVault"; +export type { VaultPayload } from "./credentialVault"; \ No newline at end of file diff --git a/src/lib/core/persistence/persist.ts b/src/lib/core/persistence/persist.ts new file mode 100644 index 0000000..6591a4e --- /dev/null +++ b/src/lib/core/persistence/persist.ts @@ -0,0 +1,166 @@ +import { LazyStore } from "@tauri-apps/plugin-store"; + +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 }); + +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 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 ?? [], + }; +} + +async function migrateFromLocalStorage(): Promise { + try { + const raw = localStorage.getItem("moku-store"); + if (!raw) return null; + const data = JSON.parse(raw); + + 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 ?? [], + }), + ]); + + localStorage.removeItem("moku-store"); + + 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; + } +} + +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 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 persistUpdates(data: { + libraryUpdates: any[]; + lastLibraryRefresh: number; + acknowledgedUpdateIds: number[]; +}) { + await Promise.all([ + updatesStore.set("libraryUpdates", data.libraryUpdates), + updatesStore.set("lastLibraryRefresh", data.lastLibraryRefresh), + updatesStore.set("acknowledgedUpdateIds", data.acknowledgedUpdateIds), + ]); + await updatesStore.save(); +} + +export interface BackupEntry { url: string; name: string; } + +export async function loadBackups(): Promise { + 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 persistBackups(list: BackupEntry[]): Promise { + await backupsStore.set("backupList", list); + await backupsStore.save(); +} + +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"); +} \ No newline at end of file diff --git a/src/lib/core/theme.ts b/src/lib/core/theme.ts new file mode 100644 index 0000000..6a0bc1f --- /dev/null +++ b/src/lib/core/theme.ts @@ -0,0 +1,67 @@ +import { settingsState, updateSettings } from "$lib/state/settings.svelte"; + +let themeStyleEl: HTMLStyleElement | null = null; +let mediaQuery: MediaQueryList | null = null; +let mediaHandler: (() => void) | null = null; + +export function applyTheme() { + const themeId = settingsState.theme ?? "dark"; + const isCustom = themeId.startsWith("custom:"); + + if (!isCustom) { + themeStyleEl?.remove(); + themeStyleEl = null; + document.documentElement.setAttribute("data-theme", themeId); + return; + } + + const custom = settingsState.customThemes?.find(t => t.id === themeId); + if (!custom) { + themeStyleEl?.remove(); + themeStyleEl = null; + document.documentElement.setAttribute("data-theme", "dark"); + return; + } + + const vars = Object.entries(custom.tokens) + .map(([k, v]) => ` --${k}: ${v};`) + .join("\n"); + const css = `[data-theme="custom"] {\n${vars}\n}`; + + if (!themeStyleEl) { + themeStyleEl = document.createElement("style"); + themeStyleEl.id = "moku-custom-theme"; + document.head.appendChild(themeStyleEl); + } + themeStyleEl.textContent = css; + document.documentElement.setAttribute("data-theme", "custom"); +} + +function applySystemTheme(dark: boolean) { + const themeId = dark + ? (settingsState.systemThemeDark ?? "dark") + : (settingsState.systemThemeLight ?? "light"); + updateSettings({ theme: themeId }); +} + +export function mountSystemThemeSync() { + if (mediaQuery && mediaHandler) { + mediaQuery.removeEventListener("change", mediaHandler); + mediaHandler = null; + } + + if (!settingsState.systemThemeSync) return; + + mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + mediaHandler = () => applySystemTheme(mediaQuery!.matches); + mediaQuery.addEventListener("change", mediaHandler); + applySystemTheme(mediaQuery.matches); +} + +export function unmountSystemThemeSync() { + if (mediaQuery && mediaHandler) { + mediaQuery.removeEventListener("change", mediaHandler); + mediaHandler = null; + mediaQuery = null; + } +} \ 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 2789f06..5f66fdf 100644 --- a/src/lib/platform-adapters/capacitor/index.ts +++ b/src/lib/platform-adapters/capacitor/index.ts @@ -16,9 +16,7 @@ export class CapacitorAdapter implements PlatformAdapter { async launchServer(_config: ServerLaunchConfig) {} async stopServer() {} - async getServerStatus(): Promise<'running' | 'stopped' | 'error'> { - return 'stopped' - } + async getServerStatus(): Promise<'running' | 'stopped' | 'error'> { return 'stopped' } async readFile(path: string): Promise { const { Filesystem, Directory } = await import('@capacitor/filesystem') @@ -37,9 +35,7 @@ export class CapacitorAdapter implements PlatformAdapter { await Filesystem.writeFile({ path, data: base64, directory: Directory.Data }) } - async pickFolder(): Promise { - return null - } + async pickFolder(): Promise { return null } async authenticateBiometric(): Promise { try { @@ -70,6 +66,7 @@ export class CapacitorAdapter implements PlatformAdapter { async minimize() {} async maximize() {} async close() {} + async toggleFullscreen() {} async setDiscordPresence(_presence: DiscordPresence) {} async clearDiscordPresence() {} @@ -85,9 +82,6 @@ export class CapacitorAdapter implements PlatformAdapter { await Browser.open({ url }) } - async checkForAppUpdate(): Promise { - return null - } - + async checkForAppUpdate(): Promise { return null } async installAppUpdate(): 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 5c30343..6389734 100644 --- a/src/lib/platform-adapters/tauri/index.ts +++ b/src/lib/platform-adapters/tauri/index.ts @@ -1,4 +1,5 @@ import { invoke } from '@tauri-apps/api/core' +import { getCurrentWindow } from '@tauri-apps/api/window' import { open } from '@tauri-apps/plugin-dialog' import { readFile, writeFile } from '@tauri-apps/plugin-fs' import { open as openUrl } from '@tauri-apps/plugin-shell' @@ -83,6 +84,11 @@ export class TauriAdapter implements PlatformAdapter { await invoke('close_window') } + async toggleFullscreen() { + const win = getCurrentWindow() + await win.setFullscreen(!await win.isFullscreen()) + } + async setDiscordPresence(presence: DiscordPresence) { await invoke('set_discord_presence', { presence }) } @@ -104,8 +110,8 @@ export class TauriAdapter implements PlatformAdapter { if (!update?.available) return null return { version: update.version, - url: update.body ?? '', - notes: update.body, + url: update.body ?? '', + notes: update.body, } } diff --git a/src/lib/platform-adapters/tauri/updater.ts b/src/lib/platform-adapters/tauri/updater.ts new file mode 100644 index 0000000..7fc6e82 --- /dev/null +++ b/src/lib/platform-adapters/tauri/updater.ts @@ -0,0 +1,40 @@ +import { invoke } from "@tauri-apps/api/core"; +import { getVersion } from "@tauri-apps/api/app"; +import { toast } from "$lib/state/app.svelte"; + +function parse(tag: string): number[] { + return tag.replace(/^v/, "").split(".").map(Number); +} + +function compare(a: number[], b: number[]): number { + for (let i = 0; i < 3; i++) { + if ((a[i] ?? 0) !== (b[i] ?? 0)) return (b[i] ?? 0) - (a[i] ?? 0); + } + return 0; +} + +export async function checkForUpdateSilently(): Promise { + try { + const [currentVersion, releases] = await Promise.all([ + getVersion(), + invoke>("list_releases"), + ]); + + const valid = releases.filter(r => typeof r.tag_name === "string" && r.tag_name.trim()); + if (!valid.length) return; + + const latestTag = valid + .map(r => r.tag_name) + .sort((a, b) => compare(parse(a), parse(b)))[0] + .replace(/^v/, ""); + + if (compare(parse(latestTag), parse(currentVersion)) < 0) { + toast({ + kind: "info", + title: `Update available — v${latestTag}`, + body: "Open Settings → About to install.", + duration: 8000, + }); + } + } catch {} +} \ No newline at end of file diff --git a/src/lib/platform-adapters/types.ts b/src/lib/platform-adapters/types.ts index 974de87..8c2cd97 100644 --- a/src/lib/platform-adapters/types.ts +++ b/src/lib/platform-adapters/types.ts @@ -44,6 +44,7 @@ export interface PlatformAdapter { minimize(): Promise maximize(): Promise close(): Promise + toggleFullscreen(): Promise setDiscordPresence(presence: DiscordPresence): Promise clearDiscordPresence(): Promise diff --git a/src/lib/platform-adapters/web/index.ts b/src/lib/platform-adapters/web/index.ts index a55aa15..727cd01 100644 --- a/src/lib/platform-adapters/web/index.ts +++ b/src/lib/platform-adapters/web/index.ts @@ -15,52 +15,38 @@ export class WebAdapter implements PlatformAdapter { async launchServer(_config: ServerLaunchConfig) {} async stopServer() {} - async getServerStatus(): Promise<'running' | 'stopped' | 'error'> { - return 'stopped' - } - - async readFile(_path: string): Promise { - return new Uint8Array() - } + async getServerStatus(): Promise<'running' | 'stopped' | 'error'> { return 'stopped' } + async readFile(_path: string): Promise { return new Uint8Array() } async writeFile(_path: string, _data: Uint8Array) {} + async pickFolder(): Promise { return null } - async pickFolder(): Promise { - return null - } - - async authenticateBiometric(): Promise { - return false - } - + async authenticateBiometric(): Promise { return false } async storeCredential(_key: string, _value: string) {} + async getCredential(_key: string): Promise { return null } - async getCredential(_key: string): Promise { - return null - } - - async setTitle(title: string) { - document.title = title - } - + async setTitle(title: string) { document.title = title } async minimize() {} async maximize() {} async close() {} + async toggleFullscreen() { + if (!document.fullscreenElement) { + await document.documentElement.requestFullscreen().catch(() => {}) + } else { + await document.exitFullscreen().catch(() => {}) + } + } + async setDiscordPresence(_presence: DiscordPresence) {} async clearDiscordPresence() {} - async getVersion(): Promise { - return __APP_VERSION__ - } + async getVersion(): Promise { return __APP_VERSION__ } async openExternal(url: string) { window.open(url, '_blank', 'noopener,noreferrer') } - async checkForAppUpdate(): Promise { - return null - } - + async checkForAppUpdate(): Promise { return null } async installAppUpdate() {} } \ No newline at end of file diff --git a/src/lib/platform-service/index.ts b/src/lib/platform-service/index.ts index 3aaf28a..82f8a13 100644 --- a/src/lib/platform-service/index.ts +++ b/src/lib/platform-service/index.ts @@ -1,4 +1,5 @@ import type { PlatformAdapter } from '$lib/platform-adapters/types' +import type { ServerLaunchConfig, DiscordPresence, AppUpdateInfo, PlatformFeature } from '$lib/platform-adapters/types' let adapter: PlatformAdapter @@ -6,7 +7,38 @@ export function initPlatformService(a: PlatformAdapter) { adapter = a } -export function getPlatformService(): PlatformAdapter { +function get(): PlatformAdapter { if (!adapter) throw new Error('PlatformService not initialized') return adapter +} + +export const platformService = { + isSupported: (f: PlatformFeature) => get().isSupported(f), + init: () => get().init(), + + 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(), + + 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(), + + setDiscordPresence: (p: DiscordPresence) => get().setDiscordPresence(p), + clearDiscordPresence: () => get().clearDiscordPresence(), + + getVersion: () => get().getVersion(), + openExternal: (url: string) => get().openExternal(url), + checkForAppUpdate: () => get().checkForAppUpdate(), + installAppUpdate: () => get().installAppUpdate(), } \ No newline at end of file diff --git a/src/lib/request-manager/extensions.ts b/src/lib/request-manager/extensions.ts index b643e42..26797b3 100644 --- a/src/lib/request-manager/extensions.ts +++ b/src/lib/request-manager/extensions.ts @@ -1,5 +1,6 @@ import { getAdapter } from '$lib/request-manager' import { extensionsState } from '$lib/state/extensions.svelte' +import type { SetServerAuthInput, SetSocksProxyInput, SetFlareSolverrInput } from '$lib/server-adapters/types' export async function loadExtensions() { extensionsState.loading = true @@ -60,4 +61,20 @@ export async function browseSource(sourceId: string, page: number) { } finally { extensionsState.browseLoading = false } +} + +export async function getServerSecurity() { + return getAdapter().getServerSecurity() +} + +export async function setServerAuth(input: SetServerAuthInput) { + await getAdapter().setServerAuth(input) +} + +export async function setSocksProxy(input: SetSocksProxyInput) { + await getAdapter().setSocksProxy(input) +} + +export async function setFlareSolverr(input: SetFlareSolverrInput) { + await getAdapter().setFlareSolverr(input) } \ No newline at end of file diff --git a/src/lib/request-manager/index.ts b/src/lib/request-manager/index.ts index bdac274..727d40b 100644 --- a/src/lib/request-manager/index.ts +++ b/src/lib/request-manager/index.ts @@ -1,4 +1,9 @@ import type { ServerAdapter } from '$lib/server-adapters/types' +import * as extensions from './extensions' +import * as chapters from './chapters' +import * as downloads from './downloads' +import * as manga from './manga' +import * as tracking from './tracking' let adapter: ServerAdapter @@ -9,4 +14,16 @@ export function initRequestManager(a: ServerAdapter) { export function getAdapter(): ServerAdapter { if (!adapter) throw new Error('RequestManager not initialized') return adapter +} + +export function clearPageCache(chapterId?: number): void { + getAdapter().clearPageCache(chapterId) +} + +export const requestManager = { + extensions, + chapters, + downloads, + manga, + tracking, } \ No newline at end of file diff --git a/src/lib/request-manager/manga.ts b/src/lib/request-manager/manga.ts index 4b18071..e2793bf 100644 --- a/src/lib/request-manager/manga.ts +++ b/src/lib/request-manager/manga.ts @@ -78,9 +78,9 @@ export async function refreshLibrary() { try { await getAdapter().checkForUpdates() await loadLibrary() - toast('success', 'Library updated') + toast({ kind: 'success', message: 'Library updated' }) } catch (e) { - toast('error', 'Update failed', String(e)) + toast({ kind: 'error', message: 'Update failed', detail: String(e) }) } finally { libraryState.refreshing = false } diff --git a/src/lib/server-adapters/moku/index.ts b/src/lib/server-adapters/moku/index.ts index f74bdf7..071d44c 100644 --- a/src/lib/server-adapters/moku/index.ts +++ b/src/lib/server-adapters/moku/index.ts @@ -8,8 +8,9 @@ import type { Page, DownloadItem, UpdateResult, + LibraryUpdateProgress, } from '$lib/server-adapters/types' -import type { Manga, Chapter, Extension, Source, Tracker } from '$lib/types' +import type { Manga, Chapter, Extension, Source, Tracker, Category } from '$lib/types' function notImplemented(): never { throw new Error('MokuAdapter: not implemented') @@ -17,37 +18,69 @@ function notImplemented(): never { export class MokuAdapter implements ServerAdapter { async connect(_config: ServerConfig): Promise { notImplemented() } + getServerUrl(): string { return notImplemented() } async getStatus(): Promise { return notImplemented() } async getManga(_id: string): Promise { return notImplemented() } async getMangaList(_filters: MangaFilters): Promise> { return notImplemented() } async searchManga(_query: string, _sourceId?: string): Promise { return notImplemented() } + async fetchManga(_id: string): Promise { return notImplemented() } async addToLibrary(_mangaId: string): Promise { notImplemented() } async removeFromLibrary(_mangaId: string): Promise { notImplemented() } + async updateMangas(_ids: string[], _patch: { inLibrary?: boolean }): Promise { notImplemented() } async updateMangaMeta(_id: string, _meta: Partial): Promise { notImplemented() } + async deleteMangaMeta(_id: string, _key: string): Promise { notImplemented() } async getChapters(_mangaId: string): Promise { return notImplemented() } async getChapter(_id: string): Promise { return notImplemented() } async getChapterPages(_id: string): Promise { return notImplemented() } + async fetchChapters(_mangaId: string): Promise { return notImplemented() } + async getRecentlyUpdated(): Promise { return notImplemented() } async markChapterRead(_id: string, _read: boolean): Promise { notImplemented() } async markChaptersRead(_ids: string[], _read: boolean): Promise { notImplemented() } + async updateChaptersProgress(_ids: string[], _patch: { isRead?: boolean; isBookmarked?: boolean; lastPageRead?: number }): Promise { notImplemented() } + async deleteDownloadedChapters(_ids: string[]): Promise { notImplemented() } + async setChapterMeta(_chapterId: string, _key: string, _value: string): Promise { notImplemented() } + async deleteChapterMeta(_chapterId: string, _key: string): Promise { notImplemented() } async getDownloads(): Promise { return notImplemented() } async enqueueDownload(_chapterId: string): Promise { notImplemented() } + async enqueueDownloads(_chapterIds: string[]): Promise { notImplemented() } async dequeueDownload(_chapterId: string): Promise { notImplemented() } + async dequeueDownloads(_chapterIds: string[]): Promise { notImplemented() } async clearDownloads(): Promise { notImplemented() } + async startDownloader(): Promise { notImplemented() } + async stopDownloader(): Promise { notImplemented() } async getExtensions(): Promise { return notImplemented() } async installExtension(_id: string): Promise { notImplemented() } async uninstallExtension(_id: string): Promise { notImplemented() } async updateExtension(_id: string): Promise { notImplemented() } + async updateExtensions(_ids: string[]): Promise { notImplemented() } + async installExternalExtension(_url: string): Promise { notImplemented() } async getSources(): Promise { return notImplemented() } async browseSource(_sourceId: string, _page: number): Promise> { return notImplemented() } + async getCategories(): Promise { return notImplemented() } + async createCategory(_name: string): Promise { return notImplemented() } + async deleteCategory(_id: number): Promise { notImplemented() } + async updateCategoryOrder(_id: number, _position: number): Promise { return notImplemented() } + async updateMangaCategories(_mangaId: string, _addTo: number[], _removeFrom: number[]): Promise { notImplemented() } + async updateMangasCategories(_mangaIds: string[], _addTo: number[], _removeFrom: number[]): Promise { notImplemented() } + async updateCategoryManga(_categoryId: number): Promise { notImplemented() } + async getTrackers(): Promise { return notImplemented() } + async getMangaTrackRecords(_mangaId: string): Promise { return notImplemented() } + async searchTracker(_trackerId: string, _query: string): Promise { return notImplemented() } async linkTracker(_mangaId: string, _trackerId: string, _remoteId: string): Promise { notImplemented() } + async unlinkTracker(_recordId: string): Promise { notImplemented() } + async fetchTrackRecord(_recordId: string): Promise { notImplemented() } async syncTracking(_mangaId: string): Promise { notImplemented() } async checkForUpdates(_mangaIds?: string[]): Promise { return notImplemented() } + async stopLibraryUpdate(): Promise { notImplemented() } + async getLibraryUpdateStatus(): Promise { return notImplemented() } + + clearPageCache(_chapterId?: number): void {} } \ No newline at end of file diff --git a/src/lib/server-adapters/suwayomi/index.ts b/src/lib/server-adapters/suwayomi/index.ts index 43d2cbb..4e7b630 100644 --- a/src/lib/server-adapters/suwayomi/index.ts +++ b/src/lib/server-adapters/suwayomi/index.ts @@ -9,6 +9,10 @@ import type { DownloadItem, UpdateResult, LibraryUpdateProgress, + ServerSecurity, + SetServerAuthInput, + SetSocksProxyInput, + SetFlareSolverrInput, } from '$lib/server-adapters/types' import type { Manga, Chapter, Extension, Source, Tracker, Category } from '$lib/types' import { @@ -58,10 +62,12 @@ import { import { GET_EXTENSIONS, GET_SOURCES, + GET_SERVER_SECURITY, FETCH_EXTENSIONS, UPDATE_EXTENSION, UPDATE_EXTENSIONS, INSTALL_EXTERNAL_EXTENSION, + SET_SERVER_AUTH, } from './extensions' import { GET_TRACKERS, @@ -80,6 +86,51 @@ import { mapDownloadItem, mapCategory, } from './types' +import { clearPageCache as _clearPageCache } from './pageCache' + +const SET_SOCKS_PROXY = ` + mutation SetSocksProxy( + $socksProxyEnabled: Boolean! + $socksProxyHost: String! + $socksProxyPort: String! + $socksProxyVersion: Int! + $socksProxyUsername: String! + $socksProxyPassword: String! + ) { + setSettings(input: { settings: { + socksProxyEnabled: $socksProxyEnabled + socksProxyHost: $socksProxyHost + socksProxyPort: $socksProxyPort + socksProxyVersion: $socksProxyVersion + socksProxyUsername: $socksProxyUsername + socksProxyPassword: $socksProxyPassword + }}) { + settings { socksProxyEnabled socksProxyHost socksProxyPort } + } + } +` + +const SET_FLARE_SOLVERR = ` + mutation SetFlareSolverr( + $flareSolverrEnabled: Boolean! + $flareSolverrUrl: String! + $flareSolverrTimeout: Int! + $flareSolverrSessionName: String! + $flareSolverrSessionTtl: Int! + $flareSolverrAsResponseFallback: Boolean! + ) { + setSettings(input: { settings: { + flareSolverrEnabled: $flareSolverrEnabled + flareSolverrUrl: $flareSolverrUrl + flareSolverrTimeout: $flareSolverrTimeout + flareSolverrSessionName: $flareSolverrSessionName + flareSolverrSessionTtl: $flareSolverrSessionTtl + flareSolverrAsResponseFallback: $flareSolverrAsResponseFallback + }}) { + settings { flareSolverrEnabled flareSolverrUrl } + } + } +` export class SuwayomiAdapter implements ServerAdapter { private baseUrl = 'http://127.0.0.1:4567' @@ -133,8 +184,6 @@ export class SuwayomiAdapter implements ServerAdapter { return json.data } - // ─── Manga ─────────────────────────────────────────────────────────────── - async getManga(id: string): Promise { const data = await this.gql<{ manga: Record }>(GET_MANGA, { id: Number(id) }) return mapManga(data.manga) @@ -150,10 +199,10 @@ export class SuwayomiAdapter implements ServerAdapter { async getMangaList(filters: MangaFilters): Promise> { const data = await this.gql<{ mangas: { nodes: Record[] } }>(GET_LIBRARY) let items = data.mangas.nodes.map(mapManga) - if (filters.status) items = items.filter(m => m.status === filters.status) + if (filters.status) items = items.filter(m => m.status === filters.status) if (filters.tags?.length) items = items.filter(m => filters.tags!.every(t => m.tags?.includes(t))) - if (filters.unread) items = items.filter(m => (m.unreadCount ?? 0) > 0) - if (filters.sourceId) items = items.filter(m => String(m.source?.id) === filters.sourceId) + if (filters.unread) items = items.filter(m => (m.unreadCount ?? 0) > 0) + if (filters.sourceId) items = items.filter(m => String(m.source?.id) === filters.sourceId) return { items, hasNextPage: false } } @@ -188,8 +237,6 @@ export class SuwayomiAdapter implements ServerAdapter { await this.gql(DELETE_MANGA_META, { mangaId: Number(id), key }) } - // ─── Chapters ──────────────────────────────────────────────────────────── - async getChapters(mangaId: string): Promise { const data = await this.gql<{ chapters: { nodes: Record[] } }>( GET_CHAPTERS, { mangaId: Number(mangaId) } @@ -252,8 +299,6 @@ export class SuwayomiAdapter implements ServerAdapter { await this.gql(DELETE_CHAPTER_META, { chapterId: Number(chapterId), key }) } - // ─── Downloads ─────────────────────────────────────────────────────────── - async getDownloads(): Promise { const data = await this.gql<{ downloadStatus: { queue: Record[] } }>( GET_DOWNLOAD_STATUS @@ -289,8 +334,6 @@ export class SuwayomiAdapter implements ServerAdapter { await this.gql(STOP_DOWNLOADER) } - // ─── Extensions ────────────────────────────────────────────────────────── - async getExtensions(): Promise { await this.gql(FETCH_EXTENSIONS) const data = await this.gql<{ extensions: { nodes: Record[] } }>(GET_EXTENSIONS) @@ -332,8 +375,6 @@ export class SuwayomiAdapter implements ServerAdapter { } } - // ─── Categories ────────────────────────────────────────────────────────── - async getCategories(): Promise { const data = await this.gql<{ categories: { nodes: Record[] } }>(GET_CATEGORIES) return data.categories.nodes.map(mapCategory) @@ -369,24 +410,22 @@ export class SuwayomiAdapter implements ServerAdapter { await this.gql(UPDATE_CATEGORY_MANGA, { categoryId }) } - // ─── Tracking ──────────────────────────────────────────────────────────── - async getTrackers(): Promise { const data = await this.gql<{ trackers: { nodes: Tracker[] } }>(GET_TRACKERS) return data.trackers.nodes } async getMangaTrackRecords(mangaId: string): Promise { - const data = await this.gql<{ - manga: { trackRecords: { nodes: unknown[] } } - }>(GET_MANGA_TRACK_RECORDS, { mangaId: Number(mangaId) }) + const data = await this.gql<{ manga: { trackRecords: { nodes: unknown[] } } }>( + GET_MANGA_TRACK_RECORDS, { mangaId: Number(mangaId) } + ) return data.manga.trackRecords.nodes } async searchTracker(trackerId: string, query: string): Promise { - const data = await this.gql<{ - searchTracker: { trackSearches: unknown[] } - }>(SEARCH_TRACKER, { trackerId: Number(trackerId), query }) + const data = await this.gql<{ searchTracker: { trackSearches: unknown[] } }>( + SEARCH_TRACKER, { trackerId: Number(trackerId), query } + ) return data.searchTracker.trackSearches } @@ -410,7 +449,26 @@ export class SuwayomiAdapter implements ServerAdapter { await this.gql(TRACK_PROGRESS, { mangaId: Number(mangaId) }) } - // ─── Library updates ───────────────────────────────────────────────────── + async getServerSecurity(): Promise { + const data = await this.gql<{ settings: ServerSecurity }>(GET_SERVER_SECURITY) + return data.settings + } + + async setServerAuth(input: SetServerAuthInput): Promise { + await this.gql(SET_SERVER_AUTH, { + authMode: input.authMode, + authUsername: input.authUsername, + authPassword: input.authPassword, + }) + } + + async setSocksProxy(input: SetSocksProxyInput): Promise { + await this.gql(SET_SOCKS_PROXY, input) + } + + async setFlareSolverr(input: SetFlareSolverrInput): Promise { + await this.gql(SET_FLARE_SOLVERR, input) + } async checkForUpdates(mangaIds?: string[]): Promise { if (mangaIds?.length) { @@ -440,4 +498,8 @@ export class SuwayomiAdapter implements ServerAdapter { const { isRunning, finishedJobs, totalJobs } = data.libraryUpdateStatus.jobsInfo return { isRunning, finishedJobs, totalJobs } } + + clearPageCache(chapterId?: number): void { + _clearPageCache(chapterId) + } } \ No newline at end of file diff --git a/src/lib/server-adapters/suwayomi/pageCache.ts b/src/lib/server-adapters/suwayomi/pageCache.ts new file mode 100644 index 0000000..50564a4 --- /dev/null +++ b/src/lib/server-adapters/suwayomi/pageCache.ts @@ -0,0 +1,84 @@ +import { gql, getServerUrl } from "$lib/server-adapters/suwayomi"; +import { getBlobUrl, preloadBlobUrls } from "$lib/core/cache/imageCache"; +import { dedupeRequest } from "$lib/core/async/batchRequests"; +import { FETCH_CHAPTER_PAGES } from "$lib/server-adapters/suwayomi/chapters"; + +const pageCache = new Map(); +const inflight = new Map>(); +const resolvedUrlCache = new Map>(); +const aspectCache = new Map(); + +export function resolveUrl(url: string, useBlob: boolean, priority = 0): Promise { + if (!useBlob) return Promise.resolve(url); + const cached = resolvedUrlCache.get(url); + if (cached) return cached; + const p = getBlobUrl(url, priority).catch(err => { + resolvedUrlCache.delete(url); + return Promise.reject(err); + }); + resolvedUrlCache.set(url, p); + return p; +} + +export function fetchPages( + chapterId: number, + useBlob: boolean, + signal?: AbortSignal, + priorityPage = 0, +): Promise { + const cached = pageCache.get(chapterId); + if (cached) return Promise.resolve(cached); + if (signal?.aborted) return Promise.reject(new DOMException("Aborted", "AbortError")); + + if (!inflight.has(chapterId)) { + const p = dedupeRequest(`chapter-pages:${chapterId}`, () => + gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId }) + .then(d => { + const urls = d.fetchChapterPages.pages.map(p => p.startsWith("http") ? p : `${getServerUrl()}${p}`); + if (useBlob && urls[priorityPage]) getBlobUrl(urls[priorityPage], 999); + pageCache.set(chapterId, urls); + return urls; + }) + ).finally(() => inflight.delete(chapterId)); + inflight.set(chapterId, p); + } + + const base = inflight.get(chapterId)!; + if (!signal) return base; + return new Promise((resolve, reject) => { + signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true }); + base.then(resolve, reject); + }); +} + +export function measureAspect(url: string, useBlob: boolean): Promise { + if (aspectCache.has(url)) return Promise.resolve(aspectCache.get(url)!); + return resolveUrl(url, useBlob).then(src => new Promise(res => { + const img = new Image(); + img.onload = () => { const r = img.naturalHeight > 0 ? img.naturalWidth / img.naturalHeight : 0.67; aspectCache.set(url, r); res(r); }; + img.onerror = () => res(0.67); + img.src = src; + })); +} + +export function preloadImage(url: string, useBlob: boolean): void { + if (useBlob) { preloadBlobUrls([url], 0); return; } + resolveUrl(url, useBlob).then(src => { new Image().src = src; }).catch(() => {}); +} + +export function clearResolvedUrlCache(): void { + resolvedUrlCache.clear(); + aspectCache.clear(); +} + +export function clearPageCache(chapterId?: number): void { + if (chapterId !== undefined) { + pageCache.delete(chapterId); + inflight.delete(chapterId); + } else { + pageCache.clear(); + inflight.clear(); + resolvedUrlCache.clear(); + aspectCache.clear(); + } +} \ No newline at end of file diff --git a/src/lib/server-adapters/types.ts b/src/lib/server-adapters/types.ts index e374d86..43051aa 100644 --- a/src/lib/server-adapters/types.ts +++ b/src/lib/server-adapters/types.ts @@ -63,6 +63,46 @@ export interface LibraryUpdateProgress { totalJobs: number } +export interface ServerSecurity { + authMode: string + authUsername: string + socksProxyEnabled: boolean + socksProxyHost: string + socksProxyPort: string + socksProxyVersion: number + socksProxyUsername: string + flareSolverrEnabled: boolean + flareSolverrUrl: string + flareSolverrTimeout: number + flareSolverrSessionName: string + flareSolverrSessionTtl: number + flareSolverrAsResponseFallback: boolean +} + +export interface SetServerAuthInput { + authMode: string + authUsername: string + authPassword: string +} + +export interface SetSocksProxyInput { + socksProxyEnabled: boolean + socksProxyHost: string + socksProxyPort: string + socksProxyVersion: number + socksProxyUsername: string + socksProxyPassword: string +} + +export interface SetFlareSolverrInput { + flareSolverrEnabled: boolean + flareSolverrUrl: string + flareSolverrTimeout: number + flareSolverrSessionName: string + flareSolverrSessionTtl: number + flareSolverrAsResponseFallback: boolean +} + export interface ServerAdapter { connect(config: ServerConfig): Promise getStatus(): Promise @@ -80,7 +120,7 @@ export interface ServerAdapter { getChapters(mangaId: string): Promise getChapter(id: string): Promise - getChapterPages(id: string): Promise + getChapterPages(id: string, signal?: AbortSignal): Promise fetchChapters(mangaId: string): Promise getRecentlyUpdated(): Promise markChapterRead(id: string, read: boolean): Promise @@ -125,7 +165,13 @@ export interface ServerAdapter { fetchTrackRecord(recordId: string): Promise syncTracking(mangaId: string): Promise + getServerSecurity(): Promise + setServerAuth(input: SetServerAuthInput): Promise + setSocksProxy(input: SetSocksProxyInput): Promise + setFlareSolverr(input: SetFlareSolverrInput): Promise + checkForUpdates(mangaIds?: string[]): Promise stopLibraryUpdate(): Promise getLibraryUpdateStatus(): Promise + clearPageCache(chapterId?: number): void } \ No newline at end of file diff --git a/src/lib/state/app.svelte.ts b/src/lib/state/app.svelte.ts index ec19fcd..2439ceb 100644 --- a/src/lib/state/app.svelte.ts +++ b/src/lib/state/app.svelte.ts @@ -1,11 +1,46 @@ +export type NavPage = + | 'home' | 'library' | 'sources' | 'explore' + | 'downloads' | 'extensions' | 'history' | 'search' | 'tracking' + export type AppStatus = 'booting' | 'not-configured' | 'auth' | 'ready' | 'error' +class AppStore { + navPage: NavPage = $state('home') + settingsOpen: boolean = $state(false) + searchPrefill: string = $state('') + searchQuery: string = $state('') + genreFilter: string = $state('') + scrollPositions: Map = $state(new Map()) + + setNavPage(next: NavPage) { this.navPage = next } + setSettingsOpen(next: boolean) { this.settingsOpen = next } + setSearchPrefill(next: string) { this.searchPrefill = next } + setSearchQuery(next: string) { this.searchQuery = next } + setGenreFilter(next: string) { this.genreFilter = next } + saveScroll(key: string, top: number) { + const m = new Map(this.scrollPositions) + m.set(key, top) + this.scrollPositions = m + } + getScroll(key: string): number { return this.scrollPositions.get(key) ?? 0 } +} + +export const app = new AppStore() + export const appState = $state({ - status: 'booting' as AppStatus, - error: null as string | null, - serverUrl: '', - authenticated: false, - authMode: 'NONE' as 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN', - platform: 'web' as 'web' | 'tauri' | 'capacitor', - version: '', -}) \ No newline at end of file + status: 'booting' as AppStatus, + error: null as string | null, + serverUrl: '', + authenticated: false, + authMode: 'NONE' as 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN', + platform: 'web' as 'web' | 'tauri' | 'capacitor', + version: '', +}) + +export function setNavPage(next: NavPage) { app.setNavPage(next) } +export function setSettingsOpen(next: boolean) { app.setSettingsOpen(next) } +export function setSearchPrefill(next: string) { app.setSearchPrefill(next) } +export function setSearchQuery(next: string) { app.setSearchQuery(next) } +export function setGenreFilter(next: string) { app.setGenreFilter(next) } +export function saveScroll(key: string, top: number) { app.saveScroll(key, top) } +export function getScroll(key: string): number { return app.getScroll(key) } \ No newline at end of file diff --git a/src/lib/state/boot.svelte.ts b/src/lib/state/boot.svelte.ts new file mode 100644 index 0000000..499127c --- /dev/null +++ b/src/lib/state/boot.svelte.ts @@ -0,0 +1,156 @@ +import { probeServer, loginBasic, loginUI } from '$lib/core/auth' +import { appState } from '$lib/state/app.svelte' + +const MAX_ATTEMPTS = 15 +const BG_MAX_ATTEMPTS = 60 + +export const boot = $state({ + failed: false, + notConfigured: false, + loginRequired: false, + loginError: null as string | null, + loginBusy: false, + loginUser: '', + loginPass: '', + sessionExpired: false, + skipped: false, +}) + +let probeGeneration = 0 + +function handleProbeSuccess(gen: number) { + if (gen !== probeGeneration) return + boot.failed = false + boot.skipped = false + appState.authenticated = true + appState.status = 'ready' +} + +function handleAuthRequired(gen: number, authMode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN', user: string, pass: string) { + if (gen !== probeGeneration) return + boot.failed = false + + if (authMode === 'BASIC_AUTH' && user && pass) { + loginBasic(user, pass) + .then(() => { if (gen === probeGeneration) handleProbeSuccess(gen) }) + .catch(() => { + if (gen !== probeGeneration) return + boot.loginUser = user + boot.loginRequired = true + appState.status = 'auth' + }) + return + } + + boot.loginUser = user + boot.loginRequired = true + appState.status = 'auth' +} + +export function startProbe( + authMode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' = 'NONE', + user = '', + pass = '', +) { + const gen = ++probeGeneration + boot.failed = false + boot.loginRequired = false + boot.skipped = false + appState.status = 'booting' + let tries = 0 + + async function probe() { + if (gen !== probeGeneration) return + tries++ + const result = await probeServer() + if (gen !== probeGeneration) return + + if (result === 'ok') { handleProbeSuccess(gen); return } + if (result === 'auth_required') { handleAuthRequired(gen, authMode, user, pass); return } + if (tries >= MAX_ATTEMPTS) { boot.failed = true; appState.status = 'error'; startBackgroundProbe(gen, authMode, user, pass); return } + + setTimeout(probe, Math.min(300 + tries * 150, 1500)) + } + + setTimeout(probe, 100) +} + +function startBackgroundProbe( + gen: number, + authMode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN', + user: string, + pass: string, +) { + let bgTries = 0 + + async function bgProbe() { + if (gen !== probeGeneration) return + bgTries++ + const result = await probeServer() + if (gen !== probeGeneration) return + + if (result === 'ok') { handleProbeSuccess(gen); return } + if (result === 'auth_required') { handleAuthRequired(gen, authMode, user, pass); return } + if (bgTries >= BG_MAX_ATTEMPTS) return + + setTimeout(bgProbe, 2000) + } + + setTimeout(bgProbe, 2000) +} + +export function stopProbe() { + probeGeneration++ +} + +export async function submitLogin(): Promise { + if (!boot.loginUser.trim() || !boot.loginPass.trim()) { + boot.loginError = 'Username and password are required' + return + } + boot.loginBusy = true + boot.loginError = null + try { + if (appState.authMode === 'UI_LOGIN') { + await loginUI(boot.loginUser.trim(), boot.loginPass.trim()) + } else { + await loginBasic(boot.loginUser.trim(), boot.loginPass.trim()) + } + boot.loginRequired = false + boot.sessionExpired = false + boot.skipped = false + boot.loginPass = '' + boot.loginError = null + appState.authenticated = true + appState.status = 'ready' + } catch (e: unknown) { + boot.loginError = e instanceof Error ? e.message : 'Login failed' + } finally { + boot.loginBusy = false + } +} + +export function retryBoot( + authMode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' = 'NONE', + user = '', + pass = '', +) { + boot.failed = false + boot.notConfigured = false + boot.loginRequired = false + boot.skipped = false + startProbe(authMode, user, pass) +} + +export function bypassBoot( + authMode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' = 'NONE', + user = '', + pass = '', +) { + const gen = probeGeneration + boot.loginRequired = false + boot.sessionExpired = false + boot.skipped = true + appState.status = 'ready' + startBackgroundProbe(gen, authMode, user, pass) +} \ No newline at end of file diff --git a/src/lib/state/downloads.svelte.ts b/src/lib/state/downloads.svelte.ts index 65f994b..887b9ea 100644 --- a/src/lib/state/downloads.svelte.ts +++ b/src/lib/state/downloads.svelte.ts @@ -5,12 +5,14 @@ export const downloadsState = $state({ error: null as string | null, }) -export const activeDownloads = $derived( - downloadsState.items.filter(d => d.state === 'downloading') -) +export function activeDownloads() { + return downloadsState.items.filter(d => d.state === 'downloading') +} -export const queuedDownloads = $derived( - downloadsState.items.filter(d => d.state === 'queued') -) +export function queuedDownloads() { + return downloadsState.items.filter(d => d.state === 'queued') +} -export const downloadCount = $derived(downloadsState.items.length) +export function downloadCount() { + return downloadsState.items.length +} \ No newline at end of file diff --git a/src/lib/state/extensions.svelte.ts b/src/lib/state/extensions.svelte.ts index 69a619a..2c7bfe6 100644 --- a/src/lib/state/extensions.svelte.ts +++ b/src/lib/state/extensions.svelte.ts @@ -18,7 +18,7 @@ export const extensionsState = $state({ browseHasMore: false, }) -export const filteredExtensions = $derived.by(() => { +export function filteredExtensions() { let result = extensionsState.items if (extensionsState.filter.installed) { @@ -33,4 +33,4 @@ export const filteredExtensions = $derived.by(() => { } return result -}) +} \ No newline at end of file diff --git a/src/lib/state/notifications.svelte.ts b/src/lib/state/notifications.svelte.ts index 001cd65..22b00e6 100644 --- a/src/lib/state/notifications.svelte.ts +++ b/src/lib/state/notifications.svelte.ts @@ -1,25 +1,38 @@ export type ToastKind = 'info' | 'success' | 'error' | 'download' export interface Toast { - id: string - kind: ToastKind - message: string - detail?: string + id: string + kind: ToastKind + message: string + detail?: string duration?: number } -export const notificationsState = $state({ - toasts: [] as Toast[], -}) +export interface ActiveDownload { + chapterId: number + mangaId: number + progress: number +} -export function toast(kind: ToastKind, message: string, detail?: string, duration = 4000) { - const id = crypto.randomUUID() - notificationsState.toasts.push({ id, kind, message, detail, duration }) - if (duration > 0) { - setTimeout(() => dismissToast(id), duration) +class NotificationStore { + toasts: Toast[] = $state([]) + activeDownloads: ActiveDownload[] = $state([]) + + toast(toast: Omit) { + this.toasts = [...this.toasts, { ...toast, id: Math.random().toString(36).slice(2) }].slice(-5) + } + + dismissToast(id: string) { + this.toasts = this.toasts.filter(x => x.id !== id) + } + + setActiveDownloads(next: ActiveDownload[]) { + this.activeDownloads = next } } -export function dismissToast(id: string) { - notificationsState.toasts = notificationsState.toasts.filter(t => t.id !== id) -} +export const notifications = new NotificationStore() + +export function toast(toast: Omit) { notifications.toast(toast) } +export function dismissToast(id: string) { notifications.dismissToast(id) } +export function setActiveDownloads(next: ActiveDownload[]) { notifications.setActiveDownloads(next) } \ No newline at end of file diff --git a/src/lib/state/reader.svelte.ts b/src/lib/state/reader.svelte.ts index a5528f3..b7414db 100644 --- a/src/lib/state/reader.svelte.ts +++ b/src/lib/state/reader.svelte.ts @@ -25,17 +25,20 @@ export const readerState = $state({ fullscreen: false, }) -export const currentPageData = $derived( - readerState.pages[readerState.currentPage] ?? null -) +export function currentPageData() { + return readerState.pages[readerState.currentPage] ?? null +} -export const progress = $derived( - readerState.pages.length > 0 +export function progress() { + return readerState.pages.length > 0 ? (readerState.currentPage + 1) / readerState.pages.length : 0 -) +} -export const hasPrev = $derived(readerState.currentPage > 0) -export const hasNext = $derived( - readerState.currentPage < readerState.pages.length - 1 -) +export function hasPrev() { + return readerState.currentPage > 0 +} + +export function hasNext() { + return readerState.currentPage < readerState.pages.length - 1 +} \ No newline at end of file diff --git a/src/lib/state/settings.svelte.ts b/src/lib/state/settings.svelte.ts new file mode 100644 index 0000000..8e59096 --- /dev/null +++ b/src/lib/state/settings.svelte.ts @@ -0,0 +1,28 @@ +import type { Settings } from '$lib/types/settings' +import { DEFAULT_SETTINGS } from '$lib/types/settings' + +const KEY = 'moku_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() }) + +export function updateSettings(patch: Partial) { + Object.assign(settingsState.settings, patch) + save(settingsState.settings) +} + +export function resetSettings() { + settingsState.settings = { ...DEFAULT_SETTINGS } + save(settingsState.settings) +} \ No newline at end of file diff --git a/src/lib/state/tracking.svelte.ts b/src/lib/state/tracking.svelte.ts index 5866ec7..92b8ecd 100644 --- a/src/lib/state/tracking.svelte.ts +++ b/src/lib/state/tracking.svelte.ts @@ -1,4 +1,6 @@ -import type { Tracker } from '$lib/types' +import type { Tracker, TrackRecord } from '$lib/types' +import type { Chapter } from '$lib/types/chapter' +import type { MangaPrefs } from '$lib/types/settings' export const trackingState = $state({ trackers: [] as Tracker[], @@ -13,4 +15,42 @@ export const trackingState = $state({ searchResults: [] as unknown[], searchLoading: false, searchError: null as string | null, -}) \ No newline at end of file +}) + +export async function syncBackFromTracker( + records: TrackRecord[], + chapters: Chapter[], + opts: { + threshold: number | null + respectScanlatorFilter: boolean + chapterPrefs: Partial + }, + markChaptersRead: (ids: string[], read: boolean) => Promise, +): Promise { + const marked: Chapter[] = [] + + const activeScanlators: string[] | null = + opts.respectScanlatorFilter && opts.chapterPrefs.scanlatorFilter?.length + ? opts.chapterPrefs.scanlatorFilter + : null + + for (const record of records) { + const lastRead = record.lastChapterRead ?? 0 + if (lastRead <= 0) continue + + const toMark = chapters.filter(ch => { + if (ch.read) return false + if (activeScanlators && ch.scanlator && !activeScanlators.includes(ch.scanlator)) return false + return opts.threshold !== null + ? ch.chapterNumber <= lastRead && ch.chapterNumber >= lastRead - opts.threshold + : ch.chapterNumber <= lastRead + }) + + if (toMark.length === 0) continue + + await markChaptersRead(toMark.map(ch => String(ch.id)), true) + marked.push(...toMark) + } + + return marked +} \ No newline at end of file diff --git a/src/lib/types/settings.ts b/src/lib/types/settings.ts index 2016bd7..97b89d1 100644 --- a/src/lib/types/settings.ts +++ b/src/lib/types/settings.ts @@ -1,312 +1,164 @@ -import type { Keybinds } from "$lib/core/keybinds/defaultBinds"; +import { DEFAULT_KEYBINDS, type Keybinds } from '$lib/core/keybinds/defaultBinds' -export type PageStyle = "single" | "double" | "longstrip"; -export type FitMode = "width" | "height" | "screen" | "original"; -export type LibraryFilter = "all" | "library" | "downloaded" | string; -export type ReadingDirection = "ltr" | "rtl"; -export type ChapterSortDir = "desc" | "asc"; -export type ChapterSortMode = "source" | "chapterNumber" | "uploadDate"; -export type ContentLevel = "strict" | "moderate" | "unrestricted"; +export type PageStyle = 'single' | 'double' | 'longstrip' +export type FitMode = 'width' | 'height' | 'screen' | 'original' +export type LibraryFilter = 'all' | 'library' | 'downloaded' | string +export type ReadingDirection = 'ltr' | 'rtl' +export type ChapterSortDir = 'desc' | 'asc' +export type ChapterSortMode = 'source' | 'chapterNumber' | 'uploadDate' +export type ContentLevel = 'strict' | 'moderate' | 'unrestricted' export type LibrarySortMode = - | "az" | "unreadCount" | "totalChapters" - | "recentlyAdded" | "recentlyRead" | "latestFetched" | "latestUploaded"; + | 'az' | 'unreadCount' | 'totalChapters' + | 'recentlyAdded' | 'recentlyRead' | 'latestFetched' | 'latestUploaded' -export type LibrarySortDir = "asc" | "desc"; - -export type LibraryStatusFilter = "ALL" | "ONGOING" | "COMPLETED" | "CANCELLED" | "HIATUS" | "UNKNOWN"; -export type LibraryContentFilter = "unread" | "started" | "downloaded" | "bookmarked" | "marked"; - -export type BuiltinTheme = "original" | "dark" | "light" | "light-contrast" | "midnight" | "warm"; -export type Theme = BuiltinTheme | string; +export type LibrarySortDir = 'asc' | 'desc' +export type LibraryStatusFilter = 'ALL' | 'ONGOING' | 'COMPLETED' | 'CANCELLED' | 'HIATUS' | 'UNKNOWN' +export type LibraryContentFilter = 'unread' | 'started' | 'downloaded' | 'bookmarked' | 'marked' +export type BuiltinTheme = 'original' | 'dark' | 'light' | 'light-contrast' | 'midnight' | 'warm' +export type Theme = BuiltinTheme | string export interface ThemeTokens { - "bg-void": string; - "bg-base": string; - "bg-surface": string; - "bg-raised": string; - "bg-overlay": string; - "bg-subtle": string; - "border-dim": string; - "border-base": string; - "border-strong": string; - "border-focus": string; - "text-primary": string; - "text-secondary": string; - "text-muted": string; - "text-faint": string; - "text-disabled": string; - "accent": string; - "accent-dim": string; - "accent-muted": string; - "accent-fg": string; - "accent-bright": string; - "color-error": string; - "color-error-bg": string; - "color-success": string; - "color-info": string; - "color-info-bg": string; + 'bg-void': string; 'bg-base': string; 'bg-surface': string + 'bg-raised': string; 'bg-overlay': string; 'bg-subtle': string + 'border-dim': string; 'border-base': string; 'border-strong': string; 'border-focus': string + 'text-primary': string; 'text-secondary': string; 'text-muted': string + 'text-faint': string; 'text-disabled': string + 'accent': string; 'accent-dim': string; 'accent-muted': string + 'accent-fg': string; 'accent-bright': string + 'color-error': string; 'color-error-bg': string + 'color-success': string; 'color-info': string; 'color-info-bg': string } -export interface CustomTheme { - id: string; - name: string; - tokens: ThemeTokens; -} +export interface CustomTheme { id: string; name: string; tokens: ThemeTokens } export const DEFAULT_THEME_TOKENS: ThemeTokens = { - "bg-void": "#080808", - "bg-base": "#0c0c0c", - "bg-surface": "#101010", - "bg-raised": "#151515", - "bg-overlay": "#1a1a1a", - "bg-subtle": "#202020", - "border-dim": "#1c1c1c", - "border-base": "#242424", - "border-strong": "#2e2e2e", - "border-focus": "#4a5c4a", - "text-primary": "#f0efec", - "text-secondary": "#c8c6c0", - "text-muted": "#8a8880", - "text-faint": "#4e4d4a", - "text-disabled": "#2a2a28", - "accent": "#6b8f6b", - "accent-dim": "#2a3d2a", - "accent-muted": "#1a251a", - "accent-fg": "#a8c4a8", - "accent-bright": "#8fb88f", - "color-error": "#c47a7a", - "color-error-bg": "#1f1212", - "color-success": "#7aab7a", - "color-info": "#7a9ec4", - "color-info-bg": "#121a1f", -}; + 'bg-void': '#080808', 'bg-base': '#0c0c0c', 'bg-surface': '#101010', + 'bg-raised': '#151515', 'bg-overlay': '#1a1a1a', 'bg-subtle': '#202020', + 'border-dim': '#1c1c1c', 'border-base': '#242424', 'border-strong': '#2e2e2e', 'border-focus': '#4a5c4a', + 'text-primary': '#f0efec', 'text-secondary': '#c8c6c0', 'text-muted': '#8a8880', + 'text-faint': '#4e4d4a', 'text-disabled': '#2a2a28', + 'accent': '#6b8f6b', 'accent-dim': '#2a3d2a', 'accent-muted': '#1a251a', + 'accent-fg': '#a8c4a8', 'accent-bright': '#8fb88f', + 'color-error': '#c47a7a', 'color-error-bg': '#1f1212', + 'color-success': '#7aab7a', 'color-info': '#7a9ec4', 'color-info-bg': '#121a1f', +} export interface MangaPrefs { - autoDownload: boolean; - downloadAhead: number; - deleteOnRead: boolean; - deleteDelayHours: number; - maxKeepChapters: number; - pauseUpdates: boolean; - refreshInterval: "global" | "daily" | "weekly" | "manual"; - preferredScanlator: string; - scanlatorFilter: string[]; - scanlatorBlacklist: string[]; - scanlatorForce: boolean; - autoDownloadScanlators: string[]; - coverUrl?: string; + autoDownload: boolean; downloadAhead: number; deleteOnRead: boolean + deleteDelayHours: number; maxKeepChapters: number; pauseUpdates: boolean + refreshInterval: 'global' | 'daily' | 'weekly' | 'manual' + preferredScanlator: string; scanlatorFilter: string[] + scanlatorBlacklist: string[]; scanlatorForce: boolean + autoDownloadScanlators: string[] + coverUrl?: string } export const DEFAULT_MANGA_PREFS: MangaPrefs = { - autoDownload: false, - downloadAhead: 0, - deleteOnRead: false, - deleteDelayHours: 0, - maxKeepChapters: 0, - pauseUpdates: false, - refreshInterval: "global", - preferredScanlator: "", - scanlatorFilter: [], - scanlatorBlacklist: [], - scanlatorForce: false, + autoDownload: false, downloadAhead: 0, deleteOnRead: false, + deleteDelayHours: 0, maxKeepChapters: 0, pauseUpdates: false, + refreshInterval: 'global', preferredScanlator: '', scanlatorFilter: [], + scanlatorBlacklist: [], scanlatorForce: false, autoDownloadScanlators: [], -}; +} export interface ReaderSettings { - pageStyle: PageStyle; - fitMode: FitMode; - readingDirection: ReadingDirection; - readerZoom: number; - pageGap: boolean; - optimizeContrast: boolean; - offsetDoubleSpreads: boolean; - barPosition?: "top" | "left" | "right"; + pageStyle: PageStyle + fitMode: FitMode + readingDirection: ReadingDirection + readerZoom: number + pageGap: boolean + optimizeContrast: boolean + offsetDoubleSpreads: boolean + barPosition?: 'top' | 'left' | 'right' } export interface ReaderPreset { - id: string; - name: string; - settings: ReaderSettings; + id: string + name: string + settings: ReaderSettings } export interface Settings { - pageStyle: PageStyle; - readingDirection: ReadingDirection; - fitMode: FitMode; - readerZoom: number; - pageGap: boolean; - optimizeContrast: boolean; - offsetDoubleSpreads: boolean; - preloadPages: number; - autoMarkRead: boolean; - autoNextChapter: boolean; - libraryCropCovers: boolean; - libraryPageSize: number; - contentLevel: ContentLevel; - sourceOverridesEnabled: boolean; - nsfwAllowedSourceIds: string[]; - nsfwBlockedSourceIds: string[]; - discordRpc: boolean; - chapterSortDir: ChapterSortDir; - chapterSortMode: ChapterSortMode; - chapterPageSize: number; - uiZoom: number; - compactSidebar: boolean; - gpuAcceleration: boolean; - serverUrl: string; - serverBinary: string; - serverBinaryArgs: string; - autoStartServer: boolean; - suwayomiWebUI: boolean; - preferredExtensionLang: string; - keybinds: Keybinds; - idleTimeoutMin?: number; - splashCards?: boolean; - storageLimitGb: number | null; - markReadOnNext: boolean; - readerDebounceMs: number; - autoBookmark: boolean; - theme: Theme; - libraryBranches: boolean; - renderLimit: number; - heroSlots: (number | null)[]; - mangaLinks: Record; - mangaPrefs: Record>; - serverAuthUser: string; - serverAuthPass: string; - serverAuthMode: "NONE" | "BASIC_AUTH" | "SIMPLE_LOGIN" | "UI_LOGIN"; - socksProxyEnabled: boolean; - socksProxyHost: string; - socksProxyPort: string; - socksProxyVersion: number; - socksProxyUsername: string; - socksProxyPassword: string; - flareSolverrEnabled: boolean; - flareSolverrUrl: string; - flareSolverrTimeout: number; - flareSolverrSessionName: string; - flareSolverrSessionTtl: number; - flareSolverrAsResponseFallback: boolean; - appLockEnabled: boolean; - appLockPin: string; - customThemes: CustomTheme[]; - hiddenCategoryIds: number[]; - defaultLibraryCategoryId: number | null; - savedIsDefaultCategory: boolean; - libraryTabSort: Record; - libraryTabStatus: Record; - libraryTabFilters: Record>>; - maxPageWidth?: number; - uiScale?: number; - extraScanDirs: string[]; - serverDownloadsPath: string; - serverLocalSourcePath: string; - qolAnimations: boolean; - libraryStatsAlways: boolean; - pinnedSourceIds: string[]; - readerPresets: ReaderPreset[]; - mangaReaderSettings: Record; - barPosition?: "top" | "left" | "right"; - trackerSyncBack: boolean; - trackerSyncBackThreshold: number | null; - trackerRespectScanlatorFilter: boolean; - pinchZoom?: boolean; - autoLinkOnOpen: boolean; - downloadToastsEnabled: boolean; - downloadAutoRetry: boolean; - hiddenLibraryTabs: string[]; - libraryPinnedTabOrder: string[]; - autoScroll?: boolean; - autoScrollSpeed?: number; - disableAutoComplete: boolean; + pageStyle: PageStyle; readingDirection: ReadingDirection; fitMode: FitMode + readerZoom: number; pageGap: boolean; optimizeContrast: boolean + offsetDoubleSpreads: boolean; preloadPages: number + autoMarkRead: boolean; autoNextChapter: boolean + libraryCropCovers: boolean; libraryPageSize: number + contentLevel: ContentLevel; sourceOverridesEnabled: boolean + nsfwAllowedSourceIds: string[]; nsfwBlockedSourceIds: string[] + discordRpc: boolean + chapterSortDir: ChapterSortDir; chapterSortMode: ChapterSortMode; chapterPageSize: number + uiZoom: number; compactSidebar: boolean; gpuAcceleration: boolean + serverUrl: string; serverBinary: string; serverBinaryArgs: string; autoStartServer: boolean; suwayomiWebUI: boolean + preferredExtensionLang: string; keybinds: Keybinds + idleTimeoutMin?: number; splashCards?: boolean + storageLimitGb: number | null; markReadOnNext: boolean; readerDebounceMs: number + autoBookmark: boolean; theme: Theme; libraryBranches: boolean; renderLimit: number + heroSlots: (number | null)[]; mangaLinks: Record + mangaPrefs: Record> + serverAuthUser: string; serverAuthPass: string + serverAuthMode: 'NONE' | 'BASIC_AUTH' | 'SIMPLE_LOGIN' | 'UI_LOGIN' + socksProxyEnabled: boolean; socksProxyHost: string; socksProxyPort: string + socksProxyVersion: number; socksProxyUsername: string; socksProxyPassword: string + flareSolverrEnabled: boolean; flareSolverrUrl: string; flareSolverrTimeout: number + flareSolverrSessionName: string; flareSolverrSessionTtl: number; flareSolverrAsResponseFallback: boolean + appLockEnabled: boolean; appLockPin: string + customThemes: CustomTheme[]; hiddenCategoryIds: number[] + defaultLibraryCategoryId: number | null; savedIsDefaultCategory: boolean + libraryTabSort: Record + libraryTabStatus: Record + libraryTabFilters: Record>> + maxPageWidth?: number; uiScale?: number + extraScanDirs: string[]; serverDownloadsPath: string; serverLocalSourcePath: string + qolAnimations: boolean; libraryStatsAlways: boolean; pinnedSourceIds: string[] + readerPresets: ReaderPreset[]; mangaReaderSettings: Record + barPosition?: 'top' | 'left' | 'right' + trackerSyncBack: boolean; trackerSyncBackThreshold: number | null; trackerRespectScanlatorFilter: boolean + pinchZoom?: boolean; autoLinkOnOpen: boolean + downloadToastsEnabled: boolean; downloadAutoRetry: boolean + hiddenLibraryTabs: string[]; libraryPinnedTabOrder: string[] + autoScroll?: boolean; autoScrollSpeed?: number; disableAutoComplete: boolean + systemThemeSync?: boolean; systemThemeDark?: string; systemThemeLight?: string + closeAction?: 'ask' | 'tray' | 'quit' + overlayBars?: boolean; tapToToggleBar?: boolean + automationEnabled?: boolean; automationEnforceGlobal?: boolean + automationDefaults?: Partial + libraryShowAllInSaved?: boolean; libraryHideCompletedInSaved?: boolean } export const DEFAULT_SETTINGS: Settings = { - pageStyle: "longstrip", - readingDirection: "ltr", - fitMode: "width", - readerZoom: 1.0, - pageGap: true, - optimizeContrast: false, - offsetDoubleSpreads: false, - preloadPages: 3, - autoMarkRead: true, - autoNextChapter: true, - libraryCropCovers: true, - libraryPageSize: 48, - contentLevel: "strict", - sourceOverridesEnabled: false, - nsfwAllowedSourceIds: [], - nsfwBlockedSourceIds: [], + pageStyle: 'longstrip', readingDirection: 'ltr', fitMode: 'width', + readerZoom: 1.0, pageGap: true, optimizeContrast: false, offsetDoubleSpreads: false, + preloadPages: 3, autoMarkRead: true, autoNextChapter: true, + libraryCropCovers: true, libraryPageSize: 48, + contentLevel: 'strict', sourceOverridesEnabled: false, + nsfwAllowedSourceIds: [], nsfwBlockedSourceIds: [], discordRpc: false, - chapterSortDir: "desc", - chapterSortMode: "source", - chapterPageSize: 25, - uiZoom: 1.0, - compactSidebar: false, - gpuAcceleration: true, - serverUrl: "http://localhost:4567", - serverBinary: "", - serverBinaryArgs: "", - autoStartServer: true, - suwayomiWebUI: false, - preferredExtensionLang: "en", - keybinds: {} as Keybinds, - idleTimeoutMin: 5, - splashCards: true, - storageLimitGb: null, - markReadOnNext: true, - readerDebounceMs: 120, - autoBookmark: true, - theme: "dark", - libraryBranches: true, - renderLimit: 48, - heroSlots: [null, null, null, null], - mangaLinks: {}, - mangaPrefs: {}, - serverAuthUser: "", - serverAuthPass: "", - serverAuthMode: "NONE", - socksProxyEnabled: false, - socksProxyHost: "", - socksProxyPort: "1080", - socksProxyVersion: 5, - socksProxyUsername: "", - socksProxyPassword: "", - flareSolverrEnabled: false, - flareSolverrUrl: "http://localhost:8191", - flareSolverrTimeout: 60, - flareSolverrSessionName: "moku", - flareSolverrSessionTtl: 15, - flareSolverrAsResponseFallback: false, - appLockEnabled: false, - appLockPin: "", - customThemes: [], - hiddenCategoryIds: [], - defaultLibraryCategoryId: null, + chapterSortDir: 'desc', chapterSortMode: 'source', chapterPageSize: 25, + uiZoom: 1.0, compactSidebar: false, gpuAcceleration: true, + serverUrl: 'http://localhost:4567', serverBinary: '', serverBinaryArgs: '', autoStartServer: true, suwayomiWebUI: false, + preferredExtensionLang: 'en', keybinds: DEFAULT_KEYBINDS, + idleTimeoutMin: 5, splashCards: true, storageLimitGb: null, + markReadOnNext: true, readerDebounceMs: 120, autoBookmark: true, + theme: 'dark', libraryBranches: true, renderLimit: 48, + heroSlots: [null, null, null, null], mangaLinks: {}, mangaPrefs: {}, + serverAuthUser: '', serverAuthPass: '', serverAuthMode: 'NONE', + socksProxyEnabled: false, socksProxyHost: '', socksProxyPort: '1080', + socksProxyVersion: 5, socksProxyUsername: '', socksProxyPassword: '', + flareSolverrEnabled: false, flareSolverrUrl: 'http://localhost:8191', + flareSolverrTimeout: 60, flareSolverrSessionName: 'moku', + flareSolverrSessionTtl: 15, flareSolverrAsResponseFallback: false, + appLockEnabled: false, appLockPin: '', + customThemes: [], hiddenCategoryIds: [], defaultLibraryCategoryId: null, savedIsDefaultCategory: false, - libraryTabSort: {}, - libraryTabStatus: {}, - libraryTabFilters: {}, - extraScanDirs: [], - serverDownloadsPath: "", - serverLocalSourcePath: "", - qolAnimations: true, - libraryStatsAlways: false, - pinnedSourceIds: [], - readerPresets: [], - mangaReaderSettings: {}, - trackerSyncBack: false, - trackerSyncBackThreshold: 20, - trackerRespectScanlatorFilter: true, - pinchZoom: false, - autoLinkOnOpen: false, - downloadToastsEnabled: true, - downloadAutoRetry: false, - hiddenLibraryTabs: [], - libraryPinnedTabOrder: [], - autoScroll: false, - autoScrollSpeed: 5, - disableAutoComplete: false, -}; + libraryTabSort: {}, libraryTabStatus: {}, libraryTabFilters: {}, + extraScanDirs: [], serverDownloadsPath: '', serverLocalSourcePath: '', + qolAnimations: true, libraryStatsAlways: false, pinnedSourceIds: [], + readerPresets: [], mangaReaderSettings: {}, + trackerSyncBack: false, trackerSyncBackThreshold: 20, trackerRespectScanlatorFilter: true, + pinchZoom: false, autoLinkOnOpen: false, + downloadToastsEnabled: true, downloadAutoRetry: false, + hiddenLibraryTabs: [], libraryPinnedTabOrder: [], + autoScroll: false, autoScrollSpeed: 5, disableAutoComplete: false, +} \ No newline at end of file diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 94b992c..3ec78b4 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,11 +1,13 @@ @@ -59,8 +62,19 @@ {/if} +{#if app.settingsOpen} + app.setSettingsOpen(false)} + onOpenThemeEditor={openThemeEditor} + /> +{/if} + +{#if themeEditorOpen} + themeEditorOpen = false} editId={themeEditorId} /> +{/if} + - +