Compare commits

..

7 Commits

Author SHA1 Message Date
Youwes09 3747497041 Fix: Remove Rust Read-Store-Files for Native TS (#97) 2026-06-13 16:23:39 -05:00
Youwes09 bbf7092d9f Fix: Debounce on LibraryToolbar & Grid-Rendering Optimizations 2026-06-13 16:01:37 -05:00
Youwes09 b1bc3c81f9 Fix: Filesystem Platform-based Folder Buttons 2026-06-13 15:50:17 -05:00
Youwes09 d6ea1fab67 Fix: Handle Null Extensions on ExtensionLibrary 2026-06-13 02:50:44 -05:00
Youwes09 bf19ee02bc Fix: WebUI Splashscreen Boot & Extensions Issues 2026-06-13 02:48:46 -05:00
Youwes09 09d794da96 Chore: Fixed ServerURL & AppPin on WebUI 2026-06-13 02:25:12 -05:00
Youwes09 baece20f46 Chore: Flatpak Extra Arguments 2026-06-13 01:49:32 -05:00
20 changed files with 199 additions and 161 deletions
+2 -2
View File
@@ -238,6 +238,7 @@ modules:
XDG_DATA_HOME: /run/build/moku/xdg-data XDG_DATA_HOME: /run/build/moku/xdg-data
TAURI_SKIP_DEVSERVER_CHECK: 'true' TAURI_SKIP_DEVSERVER_CHECK: 'true'
PKG_CONFIG_PATH: /usr/lib/pkgconfig:/usr/share/pkgconfig:/app/lib/pkgconfig PKG_CONFIG_PATH: /usr/lib/pkgconfig:/usr/share/pkgconfig:/app/lib/pkgconfig
TAURI_CONFIG: '{"build":{"devUrl":null,"frontendDist":"../dist"},"app":{"windows":[{"devtools":false}]},"bundle":{"externalBin":[]}}'
build-commands: build-commands:
- tar -xzf frontend-dist.tar.gz - tar -xzf frontend-dist.tar.gz
- . /usr/lib/sdk/rust-stable/enable.sh && PKG_CONFIG_PATH=/usr/lib/pkgconfig:/usr/share/pkgconfig:/app/lib/pkgconfig cargo build --release --manifest-path src-tauri/Cargo.toml - . /usr/lib/sdk/rust-stable/enable.sh && PKG_CONFIG_PATH=/usr/lib/pkgconfig:/usr/share/pkgconfig:/app/lib/pkgconfig cargo build --release --manifest-path src-tauri/Cargo.toml
@@ -250,8 +251,7 @@ modules:
sources: sources:
- type: git - type: git
url: https://github.com/moku-project/Moku.git url: https://github.com/moku-project/Moku.git
tag: v0.10.0 commit: baece20f467d2c7d4cebaa9ea8892980aa93aa10
commit: 5c703bdba5f61cedea90a803a5f533e805070d59
- type: file - type: file
path: packaging/frontend-dist.tar.gz path: packaging/frontend-dist.tar.gz
sha256: 676ec2273ffd9a69248849c5d51dc4d59a5d5b68fbba7a4fe7e7b572a5f25f14 sha256: 676ec2273ffd9a69248849c5d51dc4d59a5d5b68fbba7a4fe7e7b572a5f25f14
+15 -15
View File
@@ -4992,40 +4992,40 @@
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/time/time-0.3.48.crate", "url": "https://static.crates.io/crates/time/time-0.3.47.crate",
"sha256": "fc1aa89044e7786ffb2ec017acb22cb7de5b0be46d0f21aea2b224b8561e5db2", "sha256": "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c",
"dest": "cargo/vendor/time-0.3.48" "dest": "cargo/vendor/time-0.3.47"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"fc1aa89044e7786ffb2ec017acb22cb7de5b0be46d0f21aea2b224b8561e5db2\", \"files\": {}}", "contents": "{\"package\": \"743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c\", \"files\": {}}",
"dest": "cargo/vendor/time-0.3.48", "dest": "cargo/vendor/time-0.3.47",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/time-core/time-core-0.1.9.crate", "url": "https://static.crates.io/crates/time-core/time-core-0.1.8.crate",
"sha256": "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109", "sha256": "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca",
"dest": "cargo/vendor/time-core-0.1.9" "dest": "cargo/vendor/time-core-0.1.8"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109\", \"files\": {}}", "contents": "{\"package\": \"7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca\", \"files\": {}}",
"dest": "cargo/vendor/time-core-0.1.9", "dest": "cargo/vendor/time-core-0.1.8",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
"type": "archive", "type": "archive",
"archive-type": "tar-gzip", "archive-type": "tar-gzip",
"url": "https://static.crates.io/crates/time-macros/time-macros-0.2.28.crate", "url": "https://static.crates.io/crates/time-macros/time-macros-0.2.27.crate",
"sha256": "9d3bfe86347f0cc659f586f01e26303ccd32418f26f30c7b0309b3ca3a07d695", "sha256": "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215",
"dest": "cargo/vendor/time-macros-0.2.28" "dest": "cargo/vendor/time-macros-0.2.27"
}, },
{ {
"type": "inline", "type": "inline",
"contents": "{\"package\": \"9d3bfe86347f0cc659f586f01e26303ccd32418f26f30c7b0309b3ca3a07d695\", \"files\": {}}", "contents": "{\"package\": \"2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215\", \"files\": {}}",
"dest": "cargo/vendor/time-macros-0.2.28", "dest": "cargo/vendor/time-macros-0.2.27",
"dest-filename": ".cargo-checksum.json" "dest-filename": ".cargo-checksum.json"
}, },
{ {
-17
View File
@@ -81,20 +81,3 @@ pub fn get_auto_backup_dir(app: tauri::AppHandle) -> String {
let _ = std::fs::create_dir_all(&dir); let _ = std::fs::create_dir_all(&dir);
dir.to_string_lossy().into_owned() dir.to_string_lossy().into_owned()
} }
#[tauri::command]
pub fn read_store_files(app: tauri::AppHandle, names: Vec<String>) -> Vec<(String, String)> {
let base = app
.path()
.app_local_data_dir()
.unwrap_or_else(|_| PathBuf::from("."));
names
.into_iter()
.map(|name| {
let content = std::fs::read_to_string(base.join(&name))
.unwrap_or_else(|_| "{}".to_string());
(name, content)
})
.collect()
}
-1
View File
@@ -107,7 +107,6 @@ pub fn run() {
commands::backup::import_app_data, commands::backup::import_app_data,
commands::backup::auto_backup_app_data, commands::backup::auto_backup_app_data,
commands::backup::get_auto_backup_dir, commands::backup::get_auto_backup_dir,
commands::backup::read_store_files,
commands::storage::load_store, commands::storage::load_store,
commands::storage::save_store, commands::storage::save_store,
commands::storage::store_credential, commands::storage::store_credential,
-1
View File
@@ -4,7 +4,6 @@
"version": "0.10.0", "version": "0.10.0",
"identifier": "io.github.MokuProject.Moku", "identifier": "io.github.MokuProject.Moku",
"build": { "build": {
"devUrl": "http://localhost:1420",
"frontendDist": "../dist", "frontendDist": "../dist",
"beforeBuildCommand": "pnpm build:static" "beforeBuildCommand": "pnpm build:static"
}, },
+1
View File
@@ -1,5 +1,6 @@
{ {
"build": { "build": {
"devUrl": "http://localhost:1420",
"beforeDevCommand": "pnpm dev" "beforeDevCommand": "pnpm dev"
}, },
"app": { "app": {
+8 -16
View File
@@ -9,15 +9,6 @@ import { historyState } from '$lib/state/history.svelt
import { readerState } from '$lib/state/reader.svelte' import { readerState } from '$lib/state/reader.svelte'
import { seriesState } from '$lib/state/series.svelte' import { seriesState } from '$lib/state/series.svelte'
const KEY_URL = 'moku_server_url'
const KEY_AUTH = 'moku_auth_config'
interface SavedAuth {
mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN'
user?: string
pass?: string
}
async function boot() { async function boot() {
try { try {
const platformAdapter = detectAdapter() const platformAdapter = detectAdapter()
@@ -43,20 +34,21 @@ async function boot() {
readerState.markers = libraryData.markers readerState.markers = libraryData.markers
historyState.load(libraryData.sessions, libraryData.dailyReadCounts) historyState.load(libraryData.sessions, libraryData.dailyReadCounts)
const savedUrl = (await platformAdapter.getCredential(KEY_URL)) ?? 'http://127.0.0.1:4567' const savedUrl = settingsState.settings.serverUrl ?? 'http://127.0.0.1:4567'
const savedAuthRaw = await platformAdapter.getCredential(KEY_AUTH) const authMode = settingsState.settings.serverAuthMode ?? 'NONE'
const savedAuth: SavedAuth = savedAuthRaw ? JSON.parse(savedAuthRaw) : { mode: 'NONE' } const authUser = settingsState.settings.serverAuthUser || undefined
const authPass = settingsState.settings.serverAuthPass || undefined
appState.serverUrl = savedUrl appState.serverUrl = savedUrl
appState.authMode = savedAuth.mode appState.authMode = authMode === 'SIMPLE_LOGIN' ? 'UI_LOGIN' : authMode
configureAuth(savedUrl, savedAuth.mode, savedAuth.user, savedAuth.pass) configureAuth(savedUrl, authMode, authUser, authPass)
await serverAdapter.connect({ await serverAdapter.connect({
baseUrl: savedUrl, baseUrl: savedUrl,
credentials: credentials:
savedAuth.mode === 'BASIC_AUTH' && savedAuth.user && savedAuth.pass authMode === 'BASIC_AUTH' && authUser && authPass
? { username: savedAuth.user, password: savedAuth.pass } ? { username: authUser, password: authPass }
: undefined, : undefined,
}) })
@@ -91,10 +91,11 @@
const PHASE2_MS = 10000 const PHASE2_MS = 10000
function triggerExit(cb?: () => void) { function triggerExit(cb?: () => void) {
if (exitLock) return console.log('[splash] triggerExit called — exitLock:', exitLock, 'mode:', mode, 'cb:', cb?.name ?? String(cb))
if (exitLock) { console.log('[splash] triggerExit blocked by exitLock'); return }
exitLock = true exitLock = true
exiting = true exiting = true
setTimeout(() => cb?.(), EXIT_MS) setTimeout(() => { console.log('[splash] triggerExit timeout — calling cb'); cb?.() }, EXIT_MS)
} }
let animFrame = 0 let animFrame = 0
@@ -125,11 +126,13 @@
}) })
$effect(() => { $effect(() => {
console.log('[splash] ringFull effect — ringFull:', ringFull, 'mode:', mode, 'exitLock:', exitLock)
if (!ringFull || mode === 'locked') { exitLock = false; exiting = false; return } if (!ringFull || mode === 'locked') { exitLock = false; exiting = false; return }
cancelAnimationFrame(animFrame) cancelAnimationFrame(animFrame)
animFrame = 0 animFrame = 0
ringProg = 1 ringProg = 1
setTimeout(() => triggerExit(onReady), 650) const t = setTimeout(() => { console.log('[splash] ringFull timeout firing — calling triggerExit(onReady)'); triggerExit(onReady) }, 650)
return () => { console.log('[splash] ringFull effect cleanup — cancelling timeout'); clearTimeout(t) }
}) })
function submitPin() { function submitPin() {
@@ -506,9 +509,9 @@
</div> </div>
</div> </div>
{:else} {:else if isTauri || failed || notConfigured || ringFull}
<div style="position:relative;width:{ringSize}px;height:{ringSize}px;margin-bottom:20px;display:flex;align-items:center;justify-content:center"> <div style="position:relative;width:{ringSize}px;height:{ringSize}px;margin-bottom:20px;display:flex;align-items:center;justify-content:center">
{#if !failed && !notConfigured} {#if !failed && !notConfigured && isTauri}
<svg width={ringSize} height={ringSize} class="loading-ring" style="position:absolute;top:0;left:0;pointer-events:none"> <svg width={ringSize} height={ringSize} class="loading-ring" style="position:absolute;top:0;left:0;pointer-events:none">
<circle cx={ringC} cy={ringC} r={ringR} fill="none" stroke="var(--border-base)" stroke-width="2" /> <circle cx={ringC} cy={ringC} r={ringR} fill="none" stroke="var(--border-base)" stroke-width="2" />
<circle cx={ringC} cy={ringC} r={ringR} fill="none" stroke="var(--accent)" stroke-width="2" <circle cx={ringC} cy={ringC} r={ringR} fill="none" stroke="var(--accent)" stroke-width="2"
@@ -13,9 +13,9 @@
import ExtensionLibrary from "$lib/components/extensions/ExtensionLibrary.svelte"; import ExtensionLibrary from "$lib/components/extensions/ExtensionLibrary.svelte";
const anims = $derived(settingsState.settings.qolAnimations ?? true); const anims = $derived(settingsState.settings.qolAnimations ?? true);
const cols = $derived(settingsState.settings.libraryCols ?? 5); const cols = $derived(settingsState.settings.libraryPageSize ?? 5);
const cropCovers = $derived(settingsState.settings.cropCovers ?? true); const cropCovers = $derived(settingsState.settings.libraryCropCovers ?? true);
const statsAlways = $derived(settingsState.settings.statsAlways ?? false); const statsAlways = $derived(settingsState.settings.libraryStatsAlways ?? false);
let tabsEl = $state<HTMLDivElement | undefined>(undefined); let tabsEl = $state<HTMLDivElement | undefined>(undefined);
let tabIndicator = $state({ left: 0, width: 0 }); let tabIndicator = $state({ left: 0, width: 0 });
@@ -337,14 +337,14 @@
{:else} {:else}
<div class="list"> <div class="list">
{#if showLocal} {#if showLocal}
<div class="local-row" style="cursor:pointer" onclick={() => libraryTarget = { pkgName: '__local__', extensionName: 'Local Source', iconUrl: '' }}> <button type="button" class="local-row" onclick={() => libraryTarget = { pkgName: '__local__', extensionName: 'Local Source', iconUrl: '' }}>
<div class="local-icon"><HardDrives size={18} weight="bold" /></div> <div class="local-icon"><HardDrives size={18} weight="bold" /></div>
<div class="info"> <div class="info">
<span class="name">Local Source</span> <span class="name">Local Source</span>
<span class="meta">Built-in · {localMangaCount} {localMangaCount === 1 ? "manga" : "manga"}</span> <span class="meta">Built-in · {localMangaCount} {localMangaCount === "1" ? "manga" : "mangas"}</span>
</div> </div>
<span class="local-badge">Built-in</span> <span class="local-badge">Built-in</span>
</div> </button>
{/if} {/if}
{#each groups as { base, primary, variants }} {#each groups as { base, primary, variants }}
<ExtensionCard <ExtensionCard
@@ -404,8 +404,7 @@
.repo-remove { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); } .repo-remove { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
.repo-remove:hover:not(:disabled) { color: var(--color-error); background: var(--bg-overlay); } .repo-remove:hover:not(:disabled) { color: var(--color-error); background: var(--bg-overlay); }
@keyframes panelSlide { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: translateY(0); } } @keyframes panelSlide { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: translateY(0); } }
.local-row { display: flex; align-items: center; gap: var(--sp-3); padding: 8px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; transition: background var(--t-fast), border-color var(--t-fast); margin-bottom: 1px; } .local-row { display: flex; align-items: center; gap: var(--sp-3); padding: 8px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; transition: background var(--t-fast), border-color var(--t-fast); margin-bottom: 1px; width: 100%; text-align: left; background: none; font: inherit; cursor: pointer; }
.local-row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.local-icon { width: 32px; height: 32px; border-radius: var(--radius-md); background: var(--accent-muted); border: 1px solid var(--accent-dim); display: flex; align-items: center; justify-content: center; color: var(--accent-fg); flex-shrink: 0; } .local-icon { width: 32px; height: 32px; border-radius: var(--radius-md); background: var(--accent-muted); border: 1px solid var(--accent-dim); display: flex; align-items: center; justify-content: center; color: var(--accent-fg); flex-shrink: 0; }
.info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; } .info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; }
.name { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .name { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@@ -4,7 +4,7 @@ export interface LibraryManga {
thumbnailUrl: string; thumbnailUrl: string;
unreadCount: number; unreadCount: number;
downloadCount: number; downloadCount: number;
source: { id: string; displayName: string }; source: { id: string; displayName: string } | null;
} }
export interface SourceLibrary { export interface SourceLibrary {
@@ -31,7 +31,7 @@ export function libraryByExtension(
const bySource = new Map<string, LibraryManga[]>(); const bySource = new Map<string, LibraryManga[]>();
for (const src of pkgSources) bySource.set(src.id, []); for (const src of pkgSources) bySource.set(src.id, []);
for (const m of libraryManga) { for (const m of libraryManga) {
if (sourceIds.has(m.source.id)) bySource.get(m.source.id)!.push(m); if (m.source && sourceIds.has(m.source.id)) bySource.get(m.source.id)!.push(m);
} }
return pkgSources return pkgSources
@@ -49,6 +49,7 @@ export function libraryCountByPkg(
} }
const counts: Record<string, number> = {}; const counts: Record<string, number> = {};
for (const m of libraryManga) { for (const m of libraryManga) {
if (!m.source) continue;
const pkg = sourceIdToPkg.get(m.source.id); const pkg = sourceIdToPkg.get(m.source.id);
if (pkg) counts[pkg] = (counts[pkg] ?? 0) + 1; if (pkg) counts[pkg] = (counts[pkg] ?? 0) + 1;
} }
+1 -1
View File
@@ -19,7 +19,7 @@
const entries = $derived( const entries = $derived(
historyState.sessions historyState.sessions
.filter((s, i, arr) => arr.findIndex(x => x.mangaId === s.mangaId) === i) .filter((s, i, arr) => arr.findIndex(x => x.mangaId === s.mangaId) === i)
.slice(0, 10) .slice(0, 6)
) )
</script> </script>
+33 -1
View File
@@ -31,6 +31,33 @@
const statsAlways = $derived(settingsState.settings.libraryStatsAlways ?? false) const statsAlways = $derived(settingsState.settings.libraryStatsAlways ?? false)
const cropCovers = $derived(settingsState.settings.libraryCropCovers ?? true) const cropCovers = $derived(settingsState.settings.libraryCropCovers ?? true)
// Virtual rendering — only mount cards up to visibleCount
const PAGE = 48
let visibleCount = $state(PAGE)
let sentinel: HTMLDivElement | undefined = $state()
let observer: IntersectionObserver | null = null
const renderedItems = $derived(items.slice(0, visibleCount))
const hasMore = $derived(visibleCount < items.length)
// Reset when items list changes (tab switch, filter, etc)
$effect(() => {
items
visibleCount = PAGE
})
$effect(() => {
observer?.disconnect()
if (!sentinel) return
observer = new IntersectionObserver((entries) => {
if (entries[0]?.isIntersecting && hasMore) {
visibleCount = Math.min(visibleCount + PAGE, items.length)
}
}, { rootMargin: '200px' })
observer.observe(sentinel)
return () => observer?.disconnect()
})
function onDocDown(e: MouseEvent) { function onDocDown(e: MouseEvent) {
if (movePanelOpen && !(e.target as HTMLElement).closest('.move-wrap')) movePanelOpen = false if (movePanelOpen && !(e.target as HTMLElement).closest('.move-wrap')) movePanelOpen = false
} }
@@ -111,7 +138,7 @@
{:else} {:else}
<div class="grid"> <div class="grid">
{#each items as m (m.id)} {#each renderedItems as m (m.id)}
{@const isSelected = selected.has(m.id)} {@const isSelected = selected.has(m.id)}
{@const isCompleted = m.status === 'COMPLETED' || (!m.unreadCount && (m.chapters?.totalCount ?? 0) > 0)} {@const isCompleted = m.status === 'COMPLETED' || (!m.unreadCount && (m.chapters?.totalCount ?? 0) > 0)}
<button <button
@@ -152,6 +179,9 @@
</button> </button>
{/each} {/each}
</div> </div>
{#if hasMore}
<div bind:this={sentinel} class="sentinel" aria-hidden="true"></div>
{/if}
{/if} {/if}
</div> </div>
@@ -289,6 +319,8 @@
.title-skeleton { height: 12px; margin-top: var(--sp-2); width: 80%; border-radius: var(--radius-sm); } .title-skeleton { height: 12px; margin-top: var(--sp-2); width: 80%; border-radius: var(--radius-sm); }
.skeleton { background: var(--bg-raised); animation: pulse 1.4s ease infinite; } .skeleton { background: var(--bg-raised); animation: pulse 1.4s ease infinite; }
.sentinel { height: 1px; width: 100%; }
.empty { .empty {
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
height: 60%; color: var(--text-muted); font-size: var(--text-sm); height: 60%; color: var(--text-muted); font-size: var(--text-sm);
@@ -3,6 +3,7 @@
MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimple, MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimple,
SortAscending, CaretUp, CaretDown, ArrowsClockwise, Star, X, CheckSquare, SortAscending, CaretUp, CaretDown, ArrowsClockwise, Star, X, CheckSquare,
} from "phosphor-svelte"; } from "phosphor-svelte";
import { canOpenFolder } from "$lib/core/filesystem";
import LibraryFilters from "./LibraryFilters.svelte"; import LibraryFilters from "./LibraryFilters.svelte";
import type { Category } from "$lib/types"; import type { Category } from "$lib/types";
import type { LibrarySortOption, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter } from "$lib/state/library.svelte"; import type { LibrarySortOption, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter } from "$lib/state/library.svelte";
@@ -62,7 +63,12 @@
onTabDragStart, onTabDragOver, onTabDragLeave, onTabDrop, onTabDragEnd, onTabDragStart, onTabDragOver, onTabDragLeave, onTabDrop, onTabDragEnd,
}: Props = $props(); }: Props = $props();
let wheelTimer: ReturnType<typeof setTimeout> | null = null
function onTabsWheel(e: WheelEvent) { function onTabsWheel(e: WheelEvent) {
e.preventDefault()
if (wheelTimer) return
wheelTimer = setTimeout(() => { wheelTimer = null }, 180)
const ids = visibleTabIds.filter(id => id === "library" || id === "downloaded" || visibleCategories.some(c => String(c.id) === id)); const ids = visibleTabIds.filter(id => id === "library" || id === "downloaded" || visibleCategories.some(c => String(c.id) === id));
const idx = ids.indexOf(tab); const idx = ids.indexOf(tab);
if (e.deltaY > 0 && idx < ids.length - 1) onTabChange(ids[idx + 1]); if (e.deltaY > 0 && idx < ids.length - 1) onTabChange(ids[idx + 1]);
@@ -165,9 +171,11 @@
</button> </button>
{/if} {/if}
{#if canOpenFolder()}
<button class="icon-btn" title="Open downloads folder" onclick={onOpenDownloadsFolder}> <button class="icon-btn" title="Open downloads folder" onclick={onOpenDownloadsFolder}>
<FolderSimple size={15} weight="bold" /> <FolderSimple size={15} weight="bold" />
</button> </button>
{/if}
<div class="sort-panel-wrap"> <div class="sort-panel-wrap">
<button <button
@@ -4,6 +4,7 @@
CaretDown, ArrowsClockwise, List, SquaresFour, FolderSimplePlus, CaretDown, ArrowsClockwise, List, SquaresFour, FolderSimplePlus,
Trash, DownloadSimple, X, MagnifyingGlass, Funnel, Check, FolderOpen, Trash, DownloadSimple, X, MagnifyingGlass, Funnel, Check, FolderOpen,
} from 'phosphor-svelte' } from 'phosphor-svelte'
import { canOpenFolder } from '$lib/core/filesystem'
import type { Chapter, Category } from '$lib/types' import type { Chapter, Category } from '$lib/types'
import type { ChapterSortMode, ChapterSortDir } from './lib/chapterList' import type { ChapterSortMode, ChapterSortDir } from './lib/chapterList'
@@ -275,7 +276,7 @@
<ArrowsClockwise size={14} weight="light" class={refreshing ? 'anim-spin' : ''} /> <ArrowsClockwise size={14} weight="light" class={refreshing ? 'anim-spin' : ''} />
</button> </button>
{#if downloadedCount > 0} {#if downloadedCount > 0 && canOpenFolder()}
<button class="icon-btn" onclick={onOpenFolder} title="Open manga folder"> <button class="icon-btn" onclick={onOpenFolder} title="Open manga folder">
<FolderOpen size={14} weight="light" /> <FolderOpen size={14} weight="light" />
</button> </button>
+19 -24
View File
@@ -1,24 +1,27 @@
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { import {
saveSettings, loadSettings, saveSettings,
saveLibrary, loadLibrary, saveLibrary,
saveUpdates, loadUpdates, saveUpdates,
} from "$lib/core/persistence/persist"; } from "$lib/core/persistence/persist";
const STORE_FILES = ["settings.json", "library.json", "updates.json"] as const; async function collectStoreFiles(): Promise<{ name: string; bytes: Uint8Array }[]> {
const [settings, library, updates] = await Promise.all([
loadSettings(),
loadLibrary(),
loadUpdates(),
]);
const enc = new TextEncoder();
return [
{ name: "settings.json", bytes: enc.encode(JSON.stringify(settings)) },
{ name: "library.json", bytes: enc.encode(JSON.stringify(library)) },
{ name: "updates.json", bytes: enc.encode(JSON.stringify(updates)) },
];
}
export async function exportAppData(): Promise<void> { export async function exportAppData(): Promise<void> {
const entries: [string, string][] = await invoke("read_store_files", { const zip = buildZip(await collectStoreFiles());
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) }); await invoke("export_app_data", { bytes: Array.from(zip) });
} }
@@ -60,15 +63,7 @@ export async function importAppData(): Promise<void> {
export async function autoBackupAppData(): Promise<void> { export async function autoBackupAppData(): Promise<void> {
try { try {
const entries: [string, string][] = await invoke("read_store_files", { const zip = buildZip(await collectStoreFiles());
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) }); await invoke("auto_backup_app_data", { bytes: Array.from(zip) });
} catch (e) { } catch (e) {
console.warn("[moku] auto-backup failed:", e); console.warn("[moku] auto-backup failed:", e);
+21 -4
View File
@@ -16,11 +16,28 @@ function join(root: string, ...parts: string[]): string {
return [root.replace(/[/\\]$/, ''), ...parts].join(sep) return [root.replace(/[/\\]$/, ''), ...parts].join(sep)
} }
function checkSupported(): boolean { function isLocalServer(): boolean {
try {
const host = new URL(settingsState.settings.serverUrl).hostname
return host === 'localhost' || host === '127.0.0.1' || host === '::1'
} catch {
return false
}
}
export function canOpenFolder(): boolean {
return platformService.isSupported('filesystem') && isLocalServer()
}
function checkCanOpenFolder(): boolean {
if (!platformService.isSupported('filesystem')) { if (!platformService.isSupported('filesystem')) {
addToast({ kind: 'info', title: 'Desktop only', body: 'Opening folders requires the desktop app.' }) addToast({ kind: 'info', title: 'Desktop only', body: 'Opening folders requires the desktop app.' })
return false return false
} }
if (!isLocalServer()) {
addToast({ kind: 'info', title: 'Remote server', body: 'Folder access is unavailable when connected to a remote server.' })
return false
}
return true return true
} }
@@ -33,7 +50,7 @@ function checkRoot(root: string): boolean {
} }
export async function openMangaFolder(manga: Manga): Promise<void> { export async function openMangaFolder(manga: Manga): Promise<void> {
if (!checkSupported()) return if (!checkCanOpenFolder()) return
const root = getDownloadsRoot() const root = getDownloadsRoot()
if (!checkRoot(root)) return if (!checkRoot(root)) return
const source = (manga as any).source?.displayName ?? (manga as any).source?.name ?? '' const source = (manga as any).source?.displayName ?? (manga as any).source?.name ?? ''
@@ -44,14 +61,14 @@ export async function openMangaFolder(manga: Manga): Promise<void> {
} }
export async function openDownloadsFolder(): Promise<void> { export async function openDownloadsFolder(): Promise<void> {
if (!checkSupported()) return if (!checkCanOpenFolder()) return
const root = getDownloadsRoot() const root = getDownloadsRoot()
if (!checkRoot(root)) return if (!checkRoot(root)) return
await platformService.openPath(root).catch(console.error) await platformService.openPath(root).catch(console.error)
} }
export async function openCustomFolder(path: string): Promise<void> { export async function openCustomFolder(path: string): Promise<void> {
if (!checkSupported()) return if (!checkCanOpenFolder()) return
if (!path?.trim()) return if (!path?.trim()) return
await platformService.openPath(path).catch(console.error) await platformService.openPath(path).catch(console.error)
} }
+2
View File
@@ -35,6 +35,8 @@ export const appState = $state({
history: [] as unknown[], history: [] as unknown[],
toasts: [] as unknown[], toasts: [] as unknown[],
appDir: '', appDir: '',
authUser: '',
authPass: '',
idleSplash: false, idleSplash: false,
devSplash: false, devSplash: false,
}) })
+7 -15
View File
@@ -6,6 +6,7 @@ import { appState } from '$lib/state/app.svelte'
import { settingsState } from '$lib/state/settings.svelte' import { settingsState } from '$lib/state/settings.svelte'
const MAX_ATTEMPTS = 40 const MAX_ATTEMPTS = 40
const WEB_MAX_ATTEMPTS = 1
const BG_MAX_ATTEMPTS = 120 const BG_MAX_ATTEMPTS = 120
export const boot = $state({ export const boot = $state({
@@ -33,11 +34,8 @@ export async function initPlatform(): Promise<void> {
} }
function pinLockEnabled(): boolean { function pinLockEnabled(): boolean {
return ( const pin = settingsState.settings.appLockPin
settingsState.settings.appLockEnabled === true && return typeof pin === 'string' && pin.length >= 4
typeof settingsState.settings.appLockPin === 'string' &&
settingsState.settings.appLockPin.length >= 4
)
} }
function handleProbeSuccess(gen: number) { function handleProbeSuccess(gen: number) {
@@ -56,6 +54,7 @@ function handleAuthRequired(
pass: string, pass: string,
) { ) {
if (gen !== probeGeneration) return if (gen !== probeGeneration) return
if (boot.skipped) return
boot.failed = false boot.failed = false
appState.authMode = authMode appState.authMode = authMode
@@ -93,13 +92,6 @@ export async function startProbe(
const baseUrl = settingsState.settings.serverUrl ?? 'http://127.0.0.1:4567' const baseUrl = settingsState.settings.serverUrl ?? 'http://127.0.0.1:4567'
configureAuth(baseUrl, authMode, user || undefined, pass || undefined) configureAuth(baseUrl, authMode, user || undefined, pass || undefined)
if (appState.platform === 'web') {
boot.failed = true
appState.status = 'error'
startBackgroundProbe(gen, authMode, user, pass)
return
}
let tries = 0 let tries = 0
async function probe() { async function probe() {
@@ -110,7 +102,8 @@ export async function startProbe(
if (result === 'ok') { handleProbeSuccess(gen); return } if (result === 'ok') { handleProbeSuccess(gen); return }
if (result === 'auth_required') { handleAuthRequired(gen, authMode, user, pass); 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 } const maxAttempts = appState.platform === 'tauri' ? MAX_ATTEMPTS : WEB_MAX_ATTEMPTS
if (tries >= maxAttempts) { boot.failed = true; appState.status = 'error'; startBackgroundProbe(gen, authMode, user, pass); return }
setTimeout(probe, Math.min(500 + tries * 200, 2000)) setTimeout(probe, Math.min(500 + tries * 200, 2000))
} }
@@ -191,10 +184,9 @@ export function bypassBoot(
user = '', user = '',
pass = '', pass = '',
) { ) {
const gen = probeGeneration
boot.loginRequired = false boot.loginRequired = false
boot.sessionExpired = false boot.sessionExpired = false
boot.skipped = true boot.skipped = true
appState.status = 'ready' appState.status = 'ready'
startBackgroundProbe(gen, authMode, user, pass) startBackgroundProbe(probeGeneration, authMode, user, pass)
} }
+1
View File
@@ -7,6 +7,7 @@ export interface Source {
isNsfw: boolean isNsfw: boolean
isConfigurable: boolean isConfigurable: boolean
supportsLatest: boolean supportsLatest: boolean
extension?: { pkgName: string }
} }
export interface Extension { export interface Extension {
+18 -5
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { page } from '$app/stores' import { page } from '$app/stores'
import { appState, app } from '$lib/state/app.svelte' import { appState, app, type AppStatus } from '$lib/state/app.svelte'
import { boot } from '$lib/state/boot.svelte' import { boot } from '$lib/state/boot.svelte'
import { notifications } from '$lib/state/notifications.svelte' import { notifications } from '$lib/state/notifications.svelte'
import { settingsState, loadSettingsIntoState, updateSettings } from '$lib/state/settings.svelte' import { settingsState, loadSettingsIntoState, updateSettings } from '$lib/state/settings.svelte'
@@ -34,16 +34,23 @@
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
appState.status = 'booting' as AppStatus
let splashDismissed = $state(false) let splashDismissed = $state(false)
let settingsLoaded = $state(false)
let themeEditorOpen = $state(false) let themeEditorOpen = $state(false)
let themeEditorId = $state<string | null>(null) let themeEditorId = $state<string | null>(null)
const splashVisible = $derived( const splashVisible = $derived(
!splashDismissed ||
appState.status === 'booting' || appState.status === 'booting' ||
appState.status === 'locked' || appState.status === 'locked' ||
appState.status === 'error' || appState.status === 'error' ||
appState.status === 'auth' appState.status === 'auth' ||
(appState.status === 'ready' && !splashDismissed)
)
const splashMode = $derived(
appState.status === 'locked' && settingsLoaded ? 'locked' : 'loading'
) )
const ringFull = $derived(appState.status === 'ready') const ringFull = $derived(appState.status === 'ready')
@@ -62,7 +69,8 @@
const readerContainerized = $derived(settingsState.settings.readerContainerized ?? false) const readerContainerized = $derived(settingsState.settings.readerContainerized ?? false)
const strippedLayout = $derived(isReaderRoute && !readerContainerized) const strippedLayout = $derived(isReaderRoute && !readerContainerized)
onMount(async () => { onMount(() => {
async function init() {
const { detectAdapter } = await import('$lib/platform-adapters') const { detectAdapter } = await import('$lib/platform-adapters')
const { initPlatformService } = await import('$lib/platform-service') const { initPlatformService } = await import('$lib/platform-service')
const { loadSettings } = await import('$lib/core/persistence/persist') const { loadSettings } = await import('$lib/core/persistence/persist')
@@ -85,6 +93,8 @@
appState.authUser = (s.serverAuthUser as string) ?? '' appState.authUser = (s.serverAuthUser as string) ?? ''
appState.authPass = (s.serverAuthPass as string) ?? '' appState.authPass = (s.serverAuthPass as string) ?? ''
settingsLoaded = true
applyTheme( applyTheme(
settingsState.settings.theme ?? 'dark', settingsState.settings.theme ?? 'dark',
settingsState.settings.customThemes ?? [], settingsState.settings.customThemes ?? [],
@@ -107,6 +117,9 @@
polling = true polling = true
pollLoop() pollLoop()
}
init()
return () => { return () => {
polling = false polling = false
@@ -191,7 +204,7 @@
{#if splashVisible} {#if splashVisible}
<SplashScreen <SplashScreen
mode={appState.status === 'locked' ? 'locked' : 'loading'} mode={splashMode}
{ringFull} {ringFull}
failed={appState.status === 'error'} failed={appState.status === 'error'}
notConfigured={boot.notConfigured} notConfigured={boot.notConfigured}