Polish the migration

This commit is contained in:
Zerebos
2026-05-23 21:03:22 -04:00
parent b3fca70f27
commit 5e2114810e
12 changed files with 767 additions and 60 deletions
+70
View File
@@ -1,12 +1,16 @@
<script lang="ts">
import { goto } from '$app/navigation'
import { onMount } from 'svelte'
import { page } from '$app/stores'
import { applyTheme, mountSystemThemeSync, unmountSystemThemeSync } from '$lib/core/theme'
import { matchesKeybind, toggleFullscreen } from '$lib/core/keybinds/keybindEngine'
import { mountIdleDetection } from '$lib/core/ui/idle'
import { applyZoom, mountZoomKey } from '$lib/core/ui/zoom'
import { appState } from '$lib/state/app.svelte'
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
import { notificationsState } from '$lib/state/notifications.svelte'
import { readerState } from '$lib/state/reader.svelte'
import { clearDiscordPresence, isSupported, setDiscordPresence } from '$lib/platform-service'
import SplashScreen from '$lib/ui/chrome/SplashScreen.svelte'
import AuthGate from '$lib/ui/chrome/AuthGate.svelte'
import Sidebar from '$lib/ui/chrome/Sidebar.svelte'
@@ -28,6 +32,70 @@
const showShell = $derived(appState.status === 'ready' || bypassed)
const splashCards = $derived(settingsState.splashCards ?? true)
function canUseDiscordRpc(): boolean {
try {
return isSupported('discord-rpc')
} catch {
return false
}
}
function hasEditableTarget(target: EventTarget | null): boolean {
const element = target as HTMLElement | null
if (!element) return false
const tag = element.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true
return element.isContentEditable
}
function handleGlobalKeydown(event: KeyboardEvent) {
if (!showShell || hasEditableTarget(event.target)) return
if (matchesKeybind(event, settingsState.keybinds.openSettings)) {
event.preventDefault()
if (!pathname.startsWith('/settings')) {
void goto('/settings/general')
}
return
}
if (matchesKeybind(event, settingsState.keybinds.toggleFullscreen)) {
event.preventDefault()
void toggleFullscreen()
}
}
let lastPresenceKey = ''
$effect(() => {
const enabled = settingsState.discordRpc && appState.status === 'ready' && !appState.idle && canUseDiscordRpc()
if (!enabled) {
if (lastPresenceKey) {
lastPresenceKey = ''
void clearDiscordPresence().catch(() => {})
}
return
}
const isReaderRoute = pathname === '/reader' || pathname.startsWith('/reader/')
const title = isReaderRoute ? (readerState.manga?.title ?? 'Moku') : 'Moku'
const chapter = isReaderRoute && readerState.chapter
? `Chapter ${readerState.chapter.chapterNumber}`
: 'Browsing library'
const nextKey = `${title}|${chapter}`
if (nextKey === lastPresenceKey) return
lastPresenceKey = nextKey
void setDiscordPresence({
title,
chapter,
startTimestamp: Date.now(),
}).catch(() => {})
})
function onSplashReady() {
splashVisible = false
}
@@ -84,6 +152,8 @@
})
</script>
<svelte:window onkeydown={handleGlobalKeydown} />
{#if showSplash && splashVisible}
<SplashScreen
mode="loading"
+3 -1
View File
@@ -3,6 +3,8 @@
import { MagnifyingGlass, ArrowSquareOut } from 'phosphor-svelte'
import { loadSources } from '$lib/request-manager/extensions'
import { extensionsState } from '$lib/state/extensions.svelte'
import { settingsState } from '$lib/state/settings.svelte'
import { shouldHideSource } from '$lib/core/util'
let query = $state('')
let language = $state('all')
@@ -21,7 +23,7 @@
return extensionsState.sources.filter(source => {
if (language !== 'all' && source.lang !== language) return false
if (!includeNsfw && source.isNsfw) return false
if (!includeNsfw && shouldHideSource(source, settingsState)) return false
if (!q) return true
return (
@@ -4,6 +4,9 @@
import { ArrowArcLeft, CaretLeft, CaretRight, Columns, List, SpinnerGap, TextAlignRight } from 'phosphor-svelte'
import { currentPageData, progress, readerState } from '$lib/state/reader.svelte'
import { ensureReaderSession, getAdjacentChapters, goToNextReaderPage, goToPreviousReaderPage, setCurrentReaderPage } from '$lib/core/reader/session'
import { settingsState } from '$lib/state/settings.svelte'
import { matchesKeybind, toggleFullscreen } from '$lib/core/keybinds/keybindEngine'
import { addBookmark, getBookmark, removeBookmark } from '$lib/state/history.svelte'
import Button from '$lib/ui/primitives/Button.svelte'
let initializing = $state(true)
@@ -86,18 +89,94 @@
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'ArrowRight') {
const binds = settingsState.keybinds
if (matchesKeybind(event, binds.turnPageRight)) {
event.preventDefault()
void (readerState.direction === 'rtl' ? stepBackward() : stepForward())
return
}
if (event.key === 'ArrowLeft') {
if (matchesKeybind(event, binds.turnPageLeft)) {
event.preventDefault()
void (readerState.direction === 'rtl' ? stepForward() : stepBackward())
return
}
if (matchesKeybind(event, binds.firstPage)) {
event.preventDefault()
void setCurrentReaderPage(0)
return
}
if (matchesKeybind(event, binds.lastPage)) {
event.preventDefault()
void setCurrentReaderPage(readerState.pages.length - 1)
return
}
if (matchesKeybind(event, binds.turnChapterRight)) {
event.preventDefault()
const neighbors = getAdjacentChapters()
if (readerState.manga && neighbors.next) {
void goto(`/reader/${readerState.manga.id}/${neighbors.next.id}`)
}
return
}
if (matchesKeybind(event, binds.turnChapterLeft)) {
event.preventDefault()
const neighbors = getAdjacentChapters()
if (readerState.manga && neighbors.previous) {
void goto(`/reader/${readerState.manga.id}/${neighbors.previous.id}`)
}
return
}
if (matchesKeybind(event, binds.exitReader)) {
event.preventDefault()
void returnToSeries()
return
}
if (matchesKeybind(event, binds.toggleReadingDirection)) {
event.preventDefault()
readerState.direction = readerState.direction === 'ltr' ? 'rtl' : 'ltr'
return
}
if (matchesKeybind(event, binds.togglePageStyle)) {
event.preventDefault()
readerState.mode = readerState.mode === 'single' ? 'strip' : 'single'
return
}
if (matchesKeybind(event, binds.toggleFullscreen)) {
event.preventDefault()
void toggleFullscreen()
return
}
if (matchesKeybind(event, binds.toggleBookmark)) {
event.preventDefault()
if (!readerState.chapter || !readerState.manga) return
const chapterId = readerState.chapter.id
if (getBookmark(chapterId)) {
removeBookmark(chapterId)
} else {
addBookmark({
mangaId: readerState.manga.id,
chapterId,
pageNumber: readerState.currentPage,
mangaTitle: readerState.manga.title,
chapterName: readerState.chapter.name,
thumbnailUrl: readerState.manga.thumbnailUrl,
})
}
return
}
// legacy Escape key fallback
if (event.key === 'Escape') {
event.preventDefault()
void returnToSeries()
+71
View File
@@ -1,9 +1,53 @@
<script lang="ts">
import pkg from '../../../../package.json'
import { appState } from '$lib/state/app.svelte'
import { settingsState } from '$lib/state/settings.svelte'
import { trackingState } from '$lib/state/tracking.svelte'
import { checkForAppUpdate, installAppUpdate, isSupported } from '$lib/platform-service'
import type { AppUpdateInfo } from '$lib/platform-adapters/types'
const appVersion = pkg.version as string
let updateInfo = $state<AppUpdateInfo | null>(null)
let updateChecking = $state(false)
let updateInstalling = $state(false)
let updateError = $state<string | null>(null)
let updateDone = $state(false)
const canCheckUpdates = typeof window !== 'undefined' && (() => {
try { return isSupported('app-updates') } catch { return false }
})()
async function handleCheckUpdate() {
updateChecking = true
updateError = null
updateInfo = null
updateDone = false
try {
updateInfo = await checkForAppUpdate()
} catch (error: unknown) {
updateError = error instanceof Error ? error.message : String(error)
} finally {
updateChecking = false
}
}
async function handleInstallUpdate() {
if (!updateInfo) return
updateInstalling = true
updateError = null
try {
await installAppUpdate()
updateDone = true
} catch (error: unknown) {
updateError = error instanceof Error ? error.message : String(error)
} finally {
updateInstalling = false
}
}
</script>
<svelte:head>
@@ -23,8 +67,35 @@
<div class="settings-label">Moku</div>
<div class="settings-desc">Version {appVersion}</div>
</div>
{#if canCheckUpdates}
<button class="settings-button" type="button" onclick={handleCheckUpdate} disabled={updateChecking}>
{updateChecking ? 'Checking…' : 'Check for updates'}
</button>
{/if}
</div>
{#if updateInfo}
<div class="settings-row">
<div>
<div class="settings-label">Update available</div>
<div class="settings-desc">v{updateInfo.version}</div>
</div>
<button class="settings-button" type="button" onclick={handleInstallUpdate} disabled={updateInstalling}>
{updateInstalling ? 'Installing…' : 'Install now'}
</button>
</div>
{:else if updateChecking === false && updateError === null && updateInfo === null && updateDone === false && canCheckUpdates}
<!-- idle, no explicit "up to date" message unless user just clicked -->
{/if}
{#if updateDone}
<p class="settings-feedback-ok">Update installed — please restart Moku.</p>
{/if}
{#if updateError}
<p class="settings-feedback-error">{updateError}</p>
{/if}
<div class="settings-row settings-grid-2">
<div>
<div class="settings-label">Server URL</div>
+120
View File
@@ -1,5 +1,100 @@
<script lang="ts">
import { appState } from '$lib/state/app.svelte'
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
import { historyState } from '$lib/state/history.svelte'
import {
buildAppDataBackup,
downloadAppDataBackup,
parseAppDataBackup,
pickAppDataBackupFile,
} from '$lib/core/backup'
import { isSupported } from '$lib/platform-service'
import { savePersistentState } from '$lib/core/persistence/persist'
let exportBusy = $state(false)
let importBusy = $state(false)
let backupError = $state<string | null>(null)
let backupMsg = $state<string | null>(null)
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
async function handleExport() {
exportBusy = true
backupError = null
backupMsg = null
try {
if (isTauri && isSupported('filesystem')) {
// Tauri-native export via invoke not yet wired — fall through to web path
const backup = buildAppDataBackup(settingsState, {
history: historyState.history,
bookmarks: historyState.bookmarks,
markers: historyState.markers,
readLog: historyState.readLog,
readingStats: historyState.readingStats as unknown as Record<string, unknown>,
dailyReadCounts: historyState.dailyReadCounts,
})
downloadAppDataBackup(backup)
backupMsg = 'Backup downloaded.'
} else {
const backup = buildAppDataBackup(settingsState, {
history: historyState.history,
bookmarks: historyState.bookmarks,
markers: historyState.markers,
readLog: historyState.readLog,
readingStats: historyState.readingStats as unknown as Record<string, unknown>,
dailyReadCounts: historyState.dailyReadCounts,
})
downloadAppDataBackup(backup)
backupMsg = 'Backup downloaded.'
}
setTimeout(() => (backupMsg = null), 3000)
} catch (error: unknown) {
if (String(error).includes('Cancelled') || String(error).includes('AbortError')) {
// user cancelled
} else {
backupError = error instanceof Error ? error.message : String(error)
}
} finally {
exportBusy = false
}
}
async function handleImport() {
importBusy = true
backupError = null
backupMsg = null
try {
// Tauri-native import handled below — same web path works
const file = await pickAppDataBackupFile()
if (!file) return
const text = await file.text()
const backup = parseAppDataBackup(text)
await Promise.all([
savePersistentState('settings', {
settings: backup.settings,
storeVersion: 1,
}),
savePersistentState('history', backup.history),
])
backupMsg = 'Import complete — reloading in 3 seconds…'
setTimeout(() => window.location.reload(), 3000)
} catch (error: unknown) {
if (String(error).includes('Cancelled') || String(error).includes('AbortError')) {
// user cancelled
} else {
backupError = error instanceof Error ? error.message : String(error)
}
} finally {
importBusy = false
}
}
</script>
<svelte:head>
@@ -34,4 +129,29 @@
</label>
</div>
</div>
<div class="settings-card">
<div class="settings-row">
<div>
<div class="settings-label">App data backup</div>
<div class="settings-desc">Export or import Moku settings and reading history.</div>
</div>
<div class="settings-inline-control">
<button class="settings-button" type="button" onclick={handleExport} disabled={exportBusy}>
{exportBusy ? 'Exporting…' : 'Export backup'}
</button>
<button class="settings-button" type="button" onclick={handleImport} disabled={importBusy}>
{importBusy ? 'Importing…' : 'Import backup'}
</button>
</div>
</div>
{#if backupMsg}
<p class="settings-feedback-ok">{backupMsg}</p>
{/if}
{#if backupError}
<p class="settings-feedback-error">{backupError}</p>
{/if}
</div>
</section>