mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Polish the migration
This commit is contained in:
@@ -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,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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user