diff --git a/src/lib/core/backup.ts b/src/lib/core/backup.ts new file mode 100644 index 0000000..3e51c89 --- /dev/null +++ b/src/lib/core/backup.ts @@ -0,0 +1,83 @@ +import type {Settings} from '$lib/types/settings'; + +export interface HistoryBackupPayload { + history: unknown[]; + bookmarks: unknown[]; + markers: unknown[]; + readLog: unknown[]; + readingStats: Record; + dailyReadCounts: Record; +} + +export interface AppDataBackup { + version: 1; + exportedAt: string; + settings: Settings; + history: HistoryBackupPayload; +} + +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +export function buildAppDataBackup(settings: Settings, history: HistoryBackupPayload): AppDataBackup { + return { + version: 1, + exportedAt: new Date().toISOString(), + settings, + history, + }; +} + +export function parseAppDataBackup(raw: string): AppDataBackup { + const parsed = JSON.parse(raw) as unknown; + if (!isObject(parsed)) throw new Error('Backup file is not a valid object'); + if (parsed.version !== 1) throw new Error('Unsupported backup format version'); + if (!isObject(parsed.settings)) throw new Error('Backup is missing settings data'); + if (!isObject(parsed.history)) throw new Error('Backup is missing history data'); + + const history = parsed.history; + + return { + version: 1, + exportedAt: typeof parsed.exportedAt === 'string' ? parsed.exportedAt : new Date().toISOString(), + settings: parsed.settings as unknown as Settings, + history: { + history: Array.isArray(history.history) ? history.history : [], + bookmarks: Array.isArray(history.bookmarks) ? history.bookmarks : [], + markers: Array.isArray(history.markers) ? history.markers : [], + readLog: Array.isArray(history.readLog) ? history.readLog : [], + readingStats: isObject(history.readingStats) ? history.readingStats : {}, + dailyReadCounts: isObject(history.dailyReadCounts) ? (history.dailyReadCounts as Record) : {}, + }, + }; +} + +export function downloadAppDataBackup(backup: AppDataBackup, filename = 'moku-app-backup.json'): void { + const blob = new Blob([JSON.stringify(backup, null, 2)], {type: 'application/json'}); + const url = URL.createObjectURL(blob); + + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +export function pickAppDataBackupFile(): Promise { + return new Promise((resolve) => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.json,application/json'; + + input.addEventListener('change', () => { + const file = input.files?.[0] ?? null; + resolve(file); + }, {once: true}); + + input.click(); + }); +} diff --git a/src/lib/core/cache/imageCache.ts b/src/lib/core/cache/imageCache.ts new file mode 100644 index 0000000..5008ac4 --- /dev/null +++ b/src/lib/core/cache/imageCache.ts @@ -0,0 +1,153 @@ +import {fetchAuthenticated, getAuthMode} from '$lib/core/auth'; +import {resolveImageUrl} from '$lib/core/image'; + +interface CacheEntry { + value: string; + revokable: boolean; +} + +interface QueueEntry { + url: string; + priority: number; + resolve: (value: string) => void; + reject: (error: unknown) => void; +} + +const cache = new Map(); +const inflight = new Map>(); +const queue: QueueEntry[] = []; + +const MAX_CONCURRENT = 6; +let active = 0; +let drainScheduled = false; +let clearing = false; + +async function doFetch(url: string): Promise { + const resolved = resolveImageUrl(url) ?? url; + + if (getAuthMode() === 'NONE') { + cache.set(url, {value: resolved, revokable: false}); + return resolved; + } + + const response = await fetchAuthenticated(resolved); + if (!response.ok) throw new Error(String(response.status)); + + const blob = await response.blob(); + if (clearing) throw new DOMException('Cancelled', 'AbortError'); + + const objectUrl = URL.createObjectURL(blob); + cache.set(url, {value: objectUrl, revokable: true}); + return objectUrl; +} + +function insertSorted(entry: QueueEntry) { + let lo = 0; + let 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(); + if (!entry) break; + + active += 1; + void doFetch(entry.url) + .then(entry.resolve, entry.reject) + .finally(() => { + active -= 1; + 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((error) => { + inflight.delete(url); + return Promise.reject(error); + }); + + 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.value); + + const existing = inflight.get(url); + if (existing) { + const queueIndex = queue.findIndex((entry) => entry.url === url); + if (queueIndex !== -1 && priority > queue[queueIndex].priority) { + const [entry] = queue.splice(queueIndex, 1); + if (entry) { + entry.priority = priority; + insertSorted(entry); + } + } + return existing; + } + + return enqueue(url, priority); +} + +export function preloadBlobUrls(urls: string[], basePriority = 0): void { + urls.forEach((url, index) => { + if (!url || cache.has(url) || inflight.has(url)) return; + void enqueue(url, basePriority - index); + }); +} + +export function revokeBlobUrl(url: string): void { + const entry = cache.get(url); + if (!entry) return; + if (entry.revokable) URL.revokeObjectURL(entry.value); + 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(); + + for (const [url, entry] of cache.entries()) { + if (entry.revokable) URL.revokeObjectURL(entry.value); + cache.delete(url); + } + + inflight.clear(); + clearing = false; +} \ No newline at end of file diff --git a/src/lib/core/cache/index.ts b/src/lib/core/cache/index.ts new file mode 100644 index 0000000..0807616 --- /dev/null +++ b/src/lib/core/cache/index.ts @@ -0,0 +1,4 @@ +export * from '$lib/core/cache/memoryCache'; +export * from '$lib/core/cache/pageCache'; +export * from '$lib/core/cache/imageCache'; +export * from '$lib/core/cache/queryCache'; \ No newline at end of file diff --git a/src/lib/core/cache/pageCache.ts b/src/lib/core/cache/pageCache.ts new file mode 100644 index 0000000..a4557b3 --- /dev/null +++ b/src/lib/core/cache/pageCache.ts @@ -0,0 +1,119 @@ +import type {Page} from '$lib/server-adapters/types'; +import {getAdapter} from '$lib/request-manager'; +import {resolveImageUrl} from '$lib/core/image'; +import {getBlobUrl, preloadBlobUrls} from '$lib/core/cache/imageCache'; + +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 { + const absoluteUrl = resolveImageUrl(url) ?? url; + if (!useBlob) return Promise.resolve(absoluteUrl); + + const cached = resolvedUrlCache.get(absoluteUrl); + if (cached) return cached; + + const promise = getBlobUrl(absoluteUrl, priority).catch((error) => { + resolvedUrlCache.delete(absoluteUrl); + return Promise.reject(error); + }); + + resolvedUrlCache.set(absoluteUrl, promise); + return promise; +} + +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 request = getAdapter() + .getChapterPages(String(chapterId)) + .then((pages) => { + const normalized = pages.map((page) => ({ + ...page, + url: resolveImageUrl(page.url) ?? page.url, + })); + + if (useBlob && normalized[priorityPage]?.url) { + void getBlobUrl(normalized[priorityPage].url, 999); + } + + pageCache.set(chapterId, normalized); + return normalized; + }) + .finally(() => inflight.delete(chapterId)); + + inflight.set(chapterId, request); + } + + const base = inflight.get(chapterId); + if (!base) return Promise.resolve([]); + 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 { + const absoluteUrl = resolveImageUrl(url) ?? url; + if (aspectCache.has(absoluteUrl)) return Promise.resolve(aspectCache.get(absoluteUrl) ?? 0.67); + + return resolveUrl(absoluteUrl, useBlob).then( + (src) => + new Promise((resolve) => { + const img = new Image(); + img.onload = () => { + const ratio = img.naturalHeight > 0 ? img.naturalWidth / img.naturalHeight : 0.67; + aspectCache.set(absoluteUrl, ratio); + resolve(ratio); + }; + img.onerror = () => resolve(0.67); + img.src = src; + }), + ); +} + +export function preloadImage(url: string, useBlob: boolean): void { + const absoluteUrl = resolveImageUrl(url) ?? url; + + if (useBlob) { + preloadBlobUrls([absoluteUrl], 0); + return; + } + + void resolveUrl(absoluteUrl, false) + .then((src) => { + const img = new Image(); + img.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); + return; + } + + pageCache.clear(); + inflight.clear(); + resolvedUrlCache.clear(); + aspectCache.clear(); +} \ No newline at end of file diff --git a/src/lib/core/cache/queryCache.ts b/src/lib/core/cache/queryCache.ts index 1baae89..9bba888 100644 --- a/src/lib/core/cache/queryCache.ts +++ b/src/lib/core/cache/queryCache.ts @@ -1,18 +1,18 @@ interface Entry { - promise: Promise; + promise: Promise; fetchedAt: number; - fetcher?: () => Promise; - ttl?: number; + fetcher?: () => Promise; + ttl?: number; } -const store = new Map>(); -const subs = new Map void>>(); +const store = new Map>(); +const subs = new Map void>>(); const keyToGroups = new Map>(); -const groups = new Map>(); +const groups = new Map>(); export const DEFAULT_TTL_MS = 5 * 60 * 1_000; -function notify(key: string) { subs.get(key)?.forEach(cb => cb()); } +function notify(key: string) {subs.get(key)?.forEach(cb => cb());} function registerGroups(key: string, group?: string | string[]) { if (!group) return; @@ -40,7 +40,7 @@ export const cache = { if (err?.name !== "AbortError") store.delete(key); return Promise.reject(err); }) as Promise; - store.set(key, { promise, fetchedAt: Date.now(), fetcher: fetcher as () => Promise, ttl }); + store.set(key, {promise, fetchedAt: Date.now(), fetcher: fetcher as () => Promise, ttl}); registerGroups(key, group); promise.then(() => notify(key)).catch(() => {}); return promise; @@ -62,7 +62,7 @@ export const cache = { const existing = store.get(key) as Entry | undefined; if (!existing) return; const next = existing.promise.then(fn); - store.set(key, { ...existing, promise: next, fetchedAt: Date.now() }); + store.set(key, {...existing, promise: next, fetchedAt: Date.now()}); next.then(() => notify(key)).catch(() => {}); }, @@ -73,7 +73,7 @@ export const cache = { if (err?.name !== "AbortError") store.delete(key); return Promise.reject(err); }); - store.set(key, { ...existing, promise: promise as Promise, fetchedAt: Date.now() }); + store.set(key, {...existing, promise: promise as Promise, fetchedAt: Date.now()}); promise.then(() => notify(key)).catch(() => {}); return promise; }, @@ -88,13 +88,13 @@ export const cache = { if (err?.name !== "AbortError") store.delete(key); return Promise.reject(err); }); - store.set(key, { ...existing, promise, fetchedAt: Date.now() }); + store.set(key, {...existing, promise, fetchedAt: Date.now()}); promise.then(() => notify(key)).catch(() => {}); } } }, - has(key: string): boolean { return store.has(key); }, + has(key: string): boolean {return store.has(key);}, ageOf(key: string): number | undefined { const e = store.get(key); @@ -146,16 +146,16 @@ export const CACHE_GROUPS = { } as const; export const CACHE_KEYS = { - LIBRARY: "library", + LIBRARY: "library", RECENT_UPDATES: "recent_updates", - ALL_MANGA: "all_manga_unfiltered", + 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}`, + SEARCH: "search_all_manga", + SOURCES: "sources", + POPULAR: "popular", + GENRE: (genre: string) => `genre:${genre}`, + MANGA: (id: number) => `manga:${id}`, + CHAPTERS: (id: number) => `chapters:${id}`, sourceMangaPages(sourceId: string, type: "POPULAR" | "LATEST" | "SEARCH", query?: string | string[]): string { const q = Array.isArray(query) ? [...query].sort().join("+") : (query ?? ""); @@ -189,24 +189,24 @@ export interface PageSet { export function getPageSet(sourceId: string, type: "POPULAR" | "LATEST" | "SEARCH", query?: string | string[]): PageSet { const key = CACHE_KEYS.sourceMangaPages(sourceId, type, query); return { - add(page) { if (!_pageSets.has(key)) _pageSets.set(key, new Set()); _pageSets.get(key)!.add(page); }, - pages() { return new Set(_pageSets.get(key) ?? []); }, - next() { const s = _pageSets.get(key); return s?.size ? Math.max(...s) + 1 : 1; }, - clear() { _pageSets.delete(key); }, + add(page) {if (!_pageSets.has(key)) _pageSets.set(key, new Set()); _pageSets.get(key)!.add(page);}, + pages() {return new Set(_pageSets.get(key) ?? []);}, + next() {const s = _pageSets.get(key); return s?.size ? Math.max(...s) + 1 : 1;}, + clear() {_pageSets.delete(key);}, }; } -const FRECENCY_KEY = "moku-source-frecency"; +const FRECENCY_KEY = "moku-source-frecency"; const MAX_FRECENCY_SOURCES = 4; type FrecencyMap = Record; function loadFrecency(): FrecencyMap { - try { const r = localStorage.getItem(FRECENCY_KEY); return r ? JSON.parse(r) : {}; } - catch { return {}; } + try {const r = localStorage.getItem(FRECENCY_KEY); return r ? JSON.parse(r) : {};} + catch {return {};} } function saveFrecency(map: FrecencyMap) { - try { localStorage.setItem(FRECENCY_KEY, JSON.stringify(map)); } catch {} + try {localStorage.setItem(FRECENCY_KEY, JSON.stringify(map));} catch {} } export function recordSourceAccess(sourceId: string) { @@ -216,9 +216,9 @@ export function recordSourceAccess(sourceId: string) { saveFrecency(map); } -export function getTopSources(sources: T[]): T[] { +export function getTopSources(sources: T[]): T[] { const map = loadFrecency(); - const withScore = sources.map(s => ({ s, score: map[s.id] ?? 0 })); + const withScore = sources.map(s => ({s, score: map[s.id] ?? 0})); if (withScore.some(x => x.score > 0)) { return withScore.sort((a, b) => b.score - a.score).slice(0, MAX_FRECENCY_SOURCES).map(x => x.s); } @@ -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/state/extensions.svelte.ts b/src/lib/state/extensions.svelte.ts index 69a619a..a5bd83d 100644 --- a/src/lib/state/extensions.svelte.ts +++ b/src/lib/state/extensions.svelte.ts @@ -1,4 +1,6 @@ -import type { Extension, Source, Manga } from '$lib/types' +import type {Extension, Source, Manga} from '$lib/types'; +import {shouldHideSource} from '$lib/core/util'; +import {settingsState} from '$lib/state/settings.svelte'; export const extensionsState = $state({ items: [] as Extension[], @@ -16,21 +18,21 @@ export const extensionsState = $state({ browseLoading: false, browseError: null as string | null, browseHasMore: false, -}) +}); export const filteredExtensions = $derived.by(() => { - let result = extensionsState.items + let result = extensionsState.items; if (extensionsState.filter.installed) { - result = result.filter(e => e.installed) + result = result.filter(e => e.installed); } if (extensionsState.filter.language !== 'all') { - result = result.filter(e => e.lang === extensionsState.filter.language) + result = result.filter(e => e.lang === extensionsState.filter.language); } if (extensionsState.filter.query) { - const q = extensionsState.filter.query.toLowerCase() - result = result.filter(e => e.name.toLowerCase().includes(q)) + const q = extensionsState.filter.query.toLowerCase(); + result = result.filter(e => e.name.toLowerCase().includes(q)); } - return result -}) + return result; +}); diff --git a/src/lib/state/library.svelte.ts b/src/lib/state/library.svelte.ts index d0ecb4d..19c12b2 100644 --- a/src/lib/state/library.svelte.ts +++ b/src/lib/state/library.svelte.ts @@ -1,7 +1,9 @@ -import type { Manga } from '$lib/types' -import type { MangaStatus } from '$lib/server-adapters/types' +import type {Manga} from '$lib/types'; +import type {MangaStatus} from '$lib/server-adapters/types'; +import {shouldHideNsfw} from '$lib/core/util'; +import {settingsState} from '$lib/state/settings.svelte'; -export type LibrarySortOption = 'alphabetical' | 'unread' | 'lastRead' | 'dateAdded' +export type LibrarySortOption = 'alphabetical' | 'unread' | 'lastRead' | 'dateAdded'; export const libraryState = $state({ items: [] as Manga[], @@ -18,36 +20,38 @@ export const libraryState = $state({ sortDesc: false, view: 'grid' as 'grid' | 'list', selected: new Set(), -}) +}); export const filteredItems = $derived.by(() => { - let result = libraryState.items + let result = libraryState.items; + + result = result.filter(m => !shouldHideNsfw(m, settingsState)); if (libraryState.filter.unread) { - result = result.filter(m => m.unreadCount > 0) + result = result.filter(m => m.unreadCount > 0); } if (libraryState.filter.status !== 'all') { - result = result.filter(m => m.status === libraryState.filter.status) + result = result.filter(m => m.status === libraryState.filter.status); } if (libraryState.filter.tags.length > 0) { result = result.filter(m => libraryState.filter.tags.every(tag => m.tags?.includes(tag)) - ) + ); } if (libraryState.filter.query) { - const q = libraryState.filter.query.toLowerCase() - result = result.filter(m => m.title.toLowerCase().includes(q)) + const q = libraryState.filter.query.toLowerCase(); + result = result.filter(m => m.title.toLowerCase().includes(q)); } const sorted = [...result].sort((a, b) => { switch (libraryState.sort) { - case 'unread': return (b.unreadCount ?? 0) - (a.unreadCount ?? 0) - case 'lastRead': return (b.lastReadAt ?? 0) - (a.lastReadAt ?? 0) - case 'dateAdded': return (b.addedAt ?? 0) - (a.addedAt ?? 0) + case 'unread': return (b.unreadCount ?? 0) - (a.unreadCount ?? 0); + case 'lastRead': return (b.lastReadAt ?? 0) - (a.lastReadAt ?? 0); + case 'dateAdded': return (b.addedAt ?? 0) - (a.addedAt ?? 0); case 'alphabetical': - default: return a.title.localeCompare(b.title) + default: return a.title.localeCompare(b.title); } - }) + }); - return libraryState.sortDesc ? sorted.reverse() : sorted -}) + return libraryState.sortDesc ? sorted.reverse() : sorted; +}); diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 2c50769..1fb2cb2 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,12 +1,16 @@ + + {#if showSplash && splashVisible} { 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 ( diff --git a/src/routes/reader/[mangaId]/[chapterId]/+page.svelte b/src/routes/reader/[mangaId]/[chapterId]/+page.svelte index 8fc8736..a1120c5 100644 --- a/src/routes/reader/[mangaId]/[chapterId]/+page.svelte +++ b/src/routes/reader/[mangaId]/[chapterId]/+page.svelte @@ -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() diff --git a/src/routes/settings/about/+page.svelte b/src/routes/settings/about/+page.svelte index 1df898d..40f7049 100644 --- a/src/routes/settings/about/+page.svelte +++ b/src/routes/settings/about/+page.svelte @@ -1,9 +1,53 @@ @@ -23,8 +67,35 @@
Moku
Version {appVersion}
+ {#if canCheckUpdates} + + {/if} + {#if updateInfo} +
+
+
Update available
+
v{updateInfo.version}
+
+ +
+ {:else if updateChecking === false && updateError === null && updateInfo === null && updateDone === false && canCheckUpdates} + + {/if} + + {#if updateDone} +

Update installed — please restart Moku.

+ {/if} + + {#if updateError} +

{updateError}

+ {/if} +
Server URL
diff --git a/src/routes/settings/storage/+page.svelte b/src/routes/settings/storage/+page.svelte index 325e593..fb0eb92 100644 --- a/src/routes/settings/storage/+page.svelte +++ b/src/routes/settings/storage/+page.svelte @@ -1,5 +1,100 @@ @@ -34,4 +129,29 @@
+ +
+
+
+
App data backup
+
Export or import Moku settings and reading history.
+
+
+ + +
+
+ + {#if backupMsg} +

{backupMsg}

+ {/if} + + {#if backupError} +

{backupError}

+ {/if} +
\ No newline at end of file