{#if showFps}
@@ -386,26 +338,7 @@
{/if}
{/if}
- {#if mode === "idle" && lockEnabled}
-
@@ -414,60 +347,53 @@
press any key to continue
+ {:else if mode === 'locked'}
+
+
+
+
+
+
Enter PIN
+
+ {#each Array(pinLen) as _, i}
+
+ {/each}
+
+
+
{:else}
{#if !failed && !notConfigured}
-
+
+ style="transition:stroke-dasharray 0.4s cubic-bezier(0.4,0,0.2,1)" />
{/if}
-
- {#if failed || notConfigured}
-
-
{failed ? "Could not reach server" : "Server not configured"}
-
- onRetry?.()}>Retry
- onBypass?.()}>Enter app
-
-
- {:else}
-
{ringFull ? "" : `Initializing server${dots}`}
- {/if}
-
-
- {#if lockEnabled}
-
-
-
Enter PIN
-
-
- {#each Array(settingsState.settings.appLockPin?.length ?? 4) as _, i}
-
- {/each}
-
-
Unlock
-
+ {#if failed || notConfigured}
+
+
{failed ? 'Could not reach server' : 'Server not configured'}
+
+ onRetry?.()}>Retry
+ onBypass?.()}>Enter app
+ {:else}
+
{ringFull ? '' : `Initializing server${dots}`}
{/if}
{/if}
\ No newline at end of file
diff --git a/src/lib/components/library/Library.svelte b/src/lib/components/library/Library.svelte
index 87a751e..26bbece 100644
--- a/src/lib/components/library/Library.svelte
+++ b/src/lib/components/library/Library.svelte
@@ -6,7 +6,6 @@
import { addToast } from '$lib/state/notifications.svelte'
import { updateSettings, settingsState } from '$lib/state/settings.svelte'
import { goto } from '$app/navigation'
- import { invoke } from '@tauri-apps/api/core'
import LibraryToolbar from '$lib/components/library/LibraryToolbar.svelte'
import LibraryGrid from '$lib/components/library/LibraryGrid.svelte'
import ContextMenu from '$lib/components/shared/ui/ContextMenu.svelte'
@@ -16,6 +15,7 @@
Books, Folder, FolderSimple, FolderSimplePlus,
Trash, CheckSquare, ArrowSquareOut, ArrowsClockwise,
} from 'phosphor-svelte'
+ import { openMangaFolder, openDownloadsFolder } from '$lib/core/filesystem'
const SIDEBAR_W = 52
const TITLEBAR_H = 36
@@ -115,26 +115,6 @@
} catch (e) { console.error(e) }
}
- async function openMangaFolder(m: Manga) {
- let base: string | undefined
- try { base = await invoke
('get_default_downloads_path') } catch {}
- if (!base) { addToast({ kind: 'error', title: 'No downloads path set', body: 'Configure it in Settings → Storage' }); return }
- const sanitize = (s: string) => s.replace(/[\/\\?%*:|"<>]/g, '_')
- const source = (m as any).source?.displayName ?? (m as any).source?.name ?? ''
- const path = source
- ? `${base}/mangas/${sanitize(source)}/${sanitize(m.title)}`
- : `${base}/mangas/${sanitize(m.title)}`
- try { await invoke('open_path', { path }) }
- catch (e: any) { addToast({ kind: 'error', title: 'Could not open folder', body: e?.toString?.() ?? path }) }
- }
-
- async function openDownloadsFolder() {
- let path: string | undefined
- try { path = await invoke('get_default_downloads_path') } catch {}
- if (!path) { addToast({ kind: 'error', title: 'No downloads path set', body: 'Configure it in Settings → Storage' }); return }
- try { await invoke('open_path', { path }) }
- catch (e: any) { addToast({ kind: 'error', title: 'Could not open folder', body: e?.toString?.() ?? path }) }
- }
async function refreshSingleManga(m: Manga) {
if (libraryState.refreshingMangaId !== null) return
diff --git a/src/lib/components/settings/sections/SecuritySettings.svelte b/src/lib/components/settings/sections/SecuritySettings.svelte
index ed285be..994b513 100644
--- a/src/lib/components/settings/sections/SecuritySettings.svelte
+++ b/src/lib/components/settings/sections/SecuritySettings.svelte
@@ -31,6 +31,35 @@
let flareTtl = $state(settingsState.settings.flareSolverrSessionTtl ?? 15)
let flareFallback = $state(settingsState.settings.flareSolverrAsResponseFallback ?? false)
+ let lockEnabled = $state(settingsState.settings.appLockEnabled ?? false)
+ let lockPin = $state(settingsState.settings.appLockEnabled ? (settingsState.settings.appLockPin ?? '') : '')
+ let lockPinVis = $state(false)
+ let lockError = $state(null)
+ let lockSaved = $state(false)
+
+ function onLockToggle() {
+ lockEnabled = !lockEnabled
+ lockError = null
+ lockSaved = false
+ if (!lockEnabled) {
+ lockPin = ''
+ updateSettings({ appLockEnabled: false, appLockPin: '' })
+ }
+ }
+
+ function onLockPinInput() {
+ lockPin = lockPin.replace(/\D/g, '')
+ lockError = null
+ lockSaved = false
+ }
+
+ function saveLockPin() {
+ if (lockPin.length < 4) { lockError = 'PIN must be at least 4 digits'; return }
+ updateSettings({ appLockEnabled: true, appLockPin: lockPin })
+ lockSaved = true
+ setTimeout(() => lockSaved = false, 2000)
+ }
+
function normalizeAuthMode(mode: string): 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' {
if (mode === 'BASIC_AUTH' || mode === 'UI_LOGIN' || mode === 'NONE') return mode
return 'NONE'
@@ -283,6 +312,41 @@
FlareSolverr
@@ -337,4 +401,5 @@
.s-ghost-btn { display: inline-flex; align-items: center; gap: 5px; background: none; border: none; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); cursor: pointer; padding: 2px 0; transition: color 0.15s; }
.s-ghost-btn:hover:not(:disabled) { color: var(--color-error); }
.s-ghost-btn:disabled { opacity: 0.35; cursor: default; }
+ .s-pin-row { display: flex; align-items: center; gap: 8px; }
\ No newline at end of file
diff --git a/src/lib/components/settings/sections/StorageSettings.svelte b/src/lib/components/settings/sections/StorageSettings.svelte
index 8ff9d58..ecac1fe 100644
--- a/src/lib/components/settings/sections/StorageSettings.svelte
+++ b/src/lib/components/settings/sections/StorageSettings.svelte
@@ -168,11 +168,11 @@
if (!supportsFilesystem) return
storageLoading = true; storageError = null
try {
- const pathData = await gql<{ downloadsPath: string | null; localSourcePath: string | null }>(
- `{ downloadsPath localSourcePath }`
+ const pathData = await gql<{ settings: { downloadsPath: string | null; localSourcePath: string | null } }>(
+ `{ settings { downloadsPath localSourcePath } }`
)
- const dl = pathData.downloadsPath ?? ''
- const loc = pathData.localSourcePath ?? ''
+ const dl = pathData.settings.downloadsPath ?? ''
+ const loc = pathData.settings.localSourcePath ?? ''
downloadsPathInput = dl; localSourcePathInput = loc
confirmedDownloadsPath = dl; confirmedLocalSourcePath = loc
updateSettings({ serverDownloadsPath: dl, serverLocalSourcePath: loc })
@@ -218,8 +218,8 @@
if (dlErr || locErr) { pathsFieldError = { ...(dlErr ? { dl: dlErr } : {}), ...(locErr ? { loc: locErr } : {}) }; return }
pathsSaving = true
try {
- await gql(`mutation($path: String!) { setDownloadsPath(input: { location: $path }) { location } }`, { path: dl })
- if (loc) await gql(`mutation($path: String!) { setLocalSourcePath(input: { location: $path }) { location } }`, { path: loc })
+ await gql(`mutation($path: String!) { setSettings(input: { settings: { downloadsPath: $path } }) { settings { downloadsPath } } }`, { path: dl })
+ if (loc) await gql(`mutation($path: String!) { setSettings(input: { settings: { localSourcePath: $path } }) { settings { localSourcePath } } }`, { path: loc })
updateSettings({ serverDownloadsPath: dl, serverLocalSourcePath: loc })
if (supportsFilesystem && !isExternalServer) {
const oldDl = confirmedDownloadsPath || defaultDownloadsPath
diff --git a/src/lib/core/filesystem.ts b/src/lib/core/filesystem.ts
index a1c2852..28d546f 100644
--- a/src/lib/core/filesystem.ts
+++ b/src/lib/core/filesystem.ts
@@ -1,16 +1,14 @@
import { platformService } from '$lib/platform-service'
-import { seriesState } from '$lib/state/series.svelte'
+import { settingsState } from '$lib/state/settings.svelte'
import { addToast } from '$lib/state/notifications.svelte'
import type { Manga } from '$lib/types'
-function sanitizeTitle(title: string): string {
- return title.replace(/[\\/:*?"<>|]/g, '').replace(/\s+/g, ' ').trim()
+function sanitize(s: string): string {
+ return s.replace(/[\\/:*?"<>|]/g, '').replace(/\s+/g, ' ').trim()
}
-async function getDownloadsRoot(): Promise {
- let root = (seriesState.settings as any).downloadsPath?.trim() ?? ''
- if (!root) root = await platformService.getDefaultDownloadsPath().catch(() => '')
- return root
+function getDownloadsRoot(): string {
+ return settingsState.settings?.serverDownloadsPath?.trim() ?? ''
}
function join(root: string, ...parts: string[]): string {
@@ -18,31 +16,42 @@ function join(root: string, ...parts: string[]): string {
return [root.replace(/[/\\]$/, ''), ...parts].join(sep)
}
-export async function openMangaFolder(manga: Manga): Promise {
+function checkSupported(): boolean {
if (!platformService.isSupported('filesystem')) {
addToast({ kind: 'info', title: 'Desktop only', body: 'Opening folders requires the desktop app.' })
- return
+ return false
}
- const root = await getDownloadsRoot()
- if (!root) return
- await platformService.openPath(join(root, 'mangas', sanitizeTitle(manga.title))).catch(console.error)
+ return true
}
-export async function openCustomFolder(path: string): Promise {
- if (!platformService.isSupported('filesystem')) {
- addToast({ kind: 'info', title: 'Desktop only', body: 'Opening folders requires the desktop app.' })
- return
+function checkRoot(root: string): boolean {
+ if (!root) {
+ addToast({ kind: 'error', title: 'No downloads path set', body: 'Configure it in Settings → Storage' })
+ return false
}
- if (!path?.trim()) return
+ return true
+}
+
+export async function openMangaFolder(manga: Manga): Promise {
+ if (!checkSupported()) return
+ const root = getDownloadsRoot()
+ if (!checkRoot(root)) return
+ const source = (manga as any).source?.displayName ?? (manga as any).source?.name ?? ''
+ const path = source
+ ? join(root, 'mangas', sanitize(source), sanitize(manga.title))
+ : join(root, 'mangas', sanitize(manga.title))
await platformService.openPath(path).catch(console.error)
}
export async function openDownloadsFolder(): Promise {
- if (!platformService.isSupported('filesystem')) {
- addToast({ kind: 'info', title: 'Desktop only', body: 'Opening folders requires the desktop app.' })
- return
- }
- const root = await getDownloadsRoot()
- if (!root) return
- await platformService.openPath(join(root, 'mangas')).catch(console.error)
+ if (!checkSupported()) return
+ const root = getDownloadsRoot()
+ if (!checkRoot(root)) return
+ await platformService.openPath(root).catch(console.error)
+}
+
+export async function openCustomFolder(path: string): Promise {
+ if (!checkSupported()) return
+ if (!path?.trim()) return
+ await platformService.openPath(path).catch(console.error)
}
\ No newline at end of file
diff --git a/src/lib/platform-adapters/tauri/adapter.ts b/src/lib/platform-adapters/tauri/adapter.ts
index f45d004..65efcb1 100644
--- a/src/lib/platform-adapters/tauri/adapter.ts
+++ b/src/lib/platform-adapters/tauri/adapter.ts
@@ -40,7 +40,11 @@ export class TauriAdapter implements PlatformAdapter {
async loadStore(key: string): Promise {
try {
- return await invoke('load_store', { key })
+ const raw = await invoke('load_store', { key })
+ if (typeof raw === 'string') {
+ try { return JSON.parse(raw) } catch { return null }
+ }
+ return raw
} catch {
return null
}
diff --git a/src/lib/state/app.svelte.ts b/src/lib/state/app.svelte.ts
index 41547e4..114f272 100644
--- a/src/lib/state/app.svelte.ts
+++ b/src/lib/state/app.svelte.ts
@@ -1,11 +1,11 @@
import type { Platform } from '$lib/platform-adapters/types'
-export type AppStatus = 'booting' | 'not-configured' | 'auth' | 'ready' | 'error'
+export type AppStatus = 'booting' | 'not-configured' | 'auth' | 'locked' | 'ready' | 'error'
class AppStore {
- settingsOpen: boolean = $state(false)
- navPage: string = $state('')
- scrollPositions: Map = $state(new Map())
+ settingsOpen: boolean = $state(false)
+ navPage: string = $state('')
+ scrollPositions: Map = $state(new Map())
setSettingsOpen(next: boolean) { this.settingsOpen = next }
setNavPage(next: string) { this.navPage = next }
diff --git a/src/lib/state/boot.svelte.ts b/src/lib/state/boot.svelte.ts
index 5d231e6..c34bdeb 100644
--- a/src/lib/state/boot.svelte.ts
+++ b/src/lib/state/boot.svelte.ts
@@ -3,6 +3,7 @@ import { initPlatformService } from '$lib/platform-service'
import { platformService } from '$lib/platform-service'
import { probeServer, loginBasic, loginUI } from '$lib/core/auth'
import { appState } from '$lib/state/app.svelte'
+import { settingsState } from '$lib/state/settings.svelte'
const MAX_ATTEMPTS = 40
const BG_MAX_ATTEMPTS = 120
@@ -31,13 +32,21 @@ export async function initPlatform(): Promise {
appState.appDir = await platformService.getAppDir()
}
+function pinLockEnabled(): boolean {
+ return (
+ settingsState.settings.appLockEnabled === true &&
+ typeof settingsState.settings.appLockPin === 'string' &&
+ settingsState.settings.appLockPin.length >= 4
+ )
+}
+
function handleProbeSuccess(gen: number) {
if (gen !== probeGeneration) return
boot.failed = false
boot.skipped = false
boot.serverProbeOk = true
appState.authenticated = true
- appState.status = 'ready'
+ appState.status = pinLockEnabled() ? 'locked' : 'ready'
}
function handleAuthRequired(
@@ -144,7 +153,7 @@ export async function submitLogin(): Promise {
boot.loginError = null
boot.serverProbeOk = true
appState.authenticated = true
- appState.status = 'ready'
+ appState.status = pinLockEnabled() ? 'locked' : 'ready'
} catch (e: unknown) {
boot.loginError = e instanceof Error ? e.message : 'Login failed'
} finally {
diff --git a/src/lib/state/settings.svelte.ts b/src/lib/state/settings.svelte.ts
index b4fef17..0394ae8 100644
--- a/src/lib/state/settings.svelte.ts
+++ b/src/lib/state/settings.svelte.ts
@@ -2,12 +2,16 @@ import type { Settings } from '$lib/types/settings'
import { DEFAULT_SETTINGS } from '$lib/types/settings'
import { saveSettings } from '$lib/core/persistence/persist'
-export const settingsState = $state({ settings: { ...DEFAULT_SETTINGS } as Settings })
+export const settingsState = $state({
+ settings: { ...DEFAULT_SETTINGS } as Settings,
+ loaded: false,
+})
export async function loadSettingsIntoState(raw: unknown) {
if (raw && typeof raw === 'object') {
Object.assign(settingsState.settings, raw)
}
+ settingsState.loaded = true
if (typeof document !== 'undefined') {
document.documentElement.style.zoom = String(settingsState.settings.uiZoom ?? 1.0)
}
@@ -16,7 +20,6 @@ export async function loadSettingsIntoState(raw: unknown) {
export function updateSettings(patch: Partial) {
Object.assign(settingsState.settings, patch)
void saveSettings({ storeVersion: 2, settings: settingsState.settings })
-
if (typeof document !== 'undefined' && patch.uiZoom !== undefined) {
document.documentElement.style.zoom = String(patch.uiZoom)
}
@@ -24,5 +27,6 @@ export function updateSettings(patch: Partial) {
export function resetSettings() {
settingsState.settings = { ...DEFAULT_SETTINGS }
+ settingsState.loaded = true
void saveSettings({ storeVersion: 2, settings: settingsState.settings })
}
\ No newline at end of file
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 9959683..4df4102 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -1,22 +1,22 @@