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
+83
View File
@@ -0,0 +1,83 @@
import type {Settings} from '$lib/types/settings';
export interface HistoryBackupPayload {
history: unknown[];
bookmarks: unknown[];
markers: unknown[];
readLog: unknown[];
readingStats: Record<string, unknown>;
dailyReadCounts: Record<string, number>;
}
export interface AppDataBackup {
version: 1;
exportedAt: string;
settings: Settings;
history: HistoryBackupPayload;
}
function isObject(value: unknown): value is Record<string, unknown> {
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<string, number>) : {},
},
};
}
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<File | null> {
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();
});
}
+153
View File
@@ -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<string, CacheEntry>();
const inflight = new Map<string, Promise<string>>();
const queue: QueueEntry[] = [];
const MAX_CONCURRENT = 6;
let active = 0;
let drainScheduled = false;
let clearing = false;
async function doFetch(url: string): Promise<string> {
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<string> {
const promise = new Promise<string>((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<string> {
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;
}
+4
View File
@@ -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';
+119
View File
@@ -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<number, Page[]>();
const inflight = new Map<number, Promise<Page[]>>();
const resolvedUrlCache = new Map<string, Promise<string>>();
const aspectCache = new Map<string, number>();
export function resolveUrl(url: string, useBlob: boolean, priority = 0): Promise<string> {
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<Page[]> {
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<Page[]>((resolve, reject) => {
signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), {once: true});
base.then(resolve, reject);
});
}
export function measureAspect(url: string, useBlob: boolean): Promise<number> {
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();
}
+16 -16
View File
@@ -12,7 +12,7 @@ const groups = new Map<string, Set<string>>();
export const DEFAULT_TTL_MS = 5 * 60 * 1_000; 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[]) { function registerGroups(key: string, group?: string | string[]) {
if (!group) return; if (!group) return;
@@ -40,7 +40,7 @@ export const cache = {
if (err?.name !== "AbortError") store.delete(key); if (err?.name !== "AbortError") store.delete(key);
return Promise.reject(err); return Promise.reject(err);
}) as Promise<T>; }) as Promise<T>;
store.set(key, { promise, fetchedAt: Date.now(), fetcher: fetcher as () => Promise<unknown>, ttl }); store.set(key, {promise, fetchedAt: Date.now(), fetcher: fetcher as () => Promise<unknown>, ttl});
registerGroups(key, group); registerGroups(key, group);
promise.then(() => notify(key)).catch(() => {}); promise.then(() => notify(key)).catch(() => {});
return promise; return promise;
@@ -62,7 +62,7 @@ export const cache = {
const existing = store.get(key) as Entry<T> | undefined; const existing = store.get(key) as Entry<T> | undefined;
if (!existing) return; if (!existing) return;
const next = existing.promise.then(fn); 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(() => {}); next.then(() => notify(key)).catch(() => {});
}, },
@@ -73,7 +73,7 @@ export const cache = {
if (err?.name !== "AbortError") store.delete(key); if (err?.name !== "AbortError") store.delete(key);
return Promise.reject(err); return Promise.reject(err);
}); });
store.set(key, { ...existing, promise: promise as Promise<unknown>, fetchedAt: Date.now() }); store.set(key, {...existing, promise: promise as Promise<unknown>, fetchedAt: Date.now()});
promise.then(() => notify(key)).catch(() => {}); promise.then(() => notify(key)).catch(() => {});
return promise; return promise;
}, },
@@ -88,13 +88,13 @@ export const cache = {
if (err?.name !== "AbortError") store.delete(key); if (err?.name !== "AbortError") store.delete(key);
return Promise.reject(err); 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(() => {}); 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 { ageOf(key: string): number | undefined {
const e = store.get(key); const e = store.get(key);
@@ -189,10 +189,10 @@ export interface PageSet {
export function getPageSet(sourceId: string, type: "POPULAR" | "LATEST" | "SEARCH", query?: string | string[]): PageSet { export function getPageSet(sourceId: string, type: "POPULAR" | "LATEST" | "SEARCH", query?: string | string[]): PageSet {
const key = CACHE_KEYS.sourceMangaPages(sourceId, type, query); const key = CACHE_KEYS.sourceMangaPages(sourceId, type, query);
return { return {
add(page) { if (!_pageSets.has(key)) _pageSets.set(key, new Set()); _pageSets.get(key)!.add(page); }, add(page) {if (!_pageSets.has(key)) _pageSets.set(key, new Set()); _pageSets.get(key)!.add(page);},
pages() { return new Set(_pageSets.get(key) ?? []); }, pages() {return new Set(_pageSets.get(key) ?? []);},
next() { const s = _pageSets.get(key); return s?.size ? Math.max(...s) + 1 : 1; }, next() {const s = _pageSets.get(key); return s?.size ? Math.max(...s) + 1 : 1;},
clear() { _pageSets.delete(key); }, clear() {_pageSets.delete(key);},
}; };
} }
@@ -201,12 +201,12 @@ const MAX_FRECENCY_SOURCES = 4;
type FrecencyMap = Record<string, number>; type FrecencyMap = Record<string, number>;
function loadFrecency(): FrecencyMap { function loadFrecency(): FrecencyMap {
try { const r = localStorage.getItem(FRECENCY_KEY); return r ? JSON.parse(r) : {}; } try {const r = localStorage.getItem(FRECENCY_KEY); return r ? JSON.parse(r) : {};}
catch { return {}; } catch {return {};}
} }
function saveFrecency(map: FrecencyMap) { 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) { export function recordSourceAccess(sourceId: string) {
@@ -216,9 +216,9 @@ export function recordSourceAccess(sourceId: string) {
saveFrecency(map); saveFrecency(map);
} }
export function getTopSources<T extends { id: string }>(sources: T[]): T[] { export function getTopSources<T extends {id: string;}>(sources: T[]): T[] {
const map = loadFrecency(); 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)) { 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); 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); cache.clear(CACHE_KEYS.ALL_MANGA);
if (thumbnailUrl) { if (thumbnailUrl) {
const { revokeBlobUrl, getBlobUrl } = await import("@core/cache/imageCache"); const {revokeBlobUrl, getBlobUrl} = await import('$lib/core/cache/imageCache');
revokeBlobUrl(thumbnailUrl); revokeBlobUrl(thumbnailUrl);
getBlobUrl(thumbnailUrl, 999).catch(() => {}); getBlobUrl(thumbnailUrl, 999).catch(() => {});
} }
+11 -9
View File
@@ -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({ export const extensionsState = $state({
items: [] as Extension[], items: [] as Extension[],
@@ -16,21 +18,21 @@ export const extensionsState = $state({
browseLoading: false, browseLoading: false,
browseError: null as string | null, browseError: null as string | null,
browseHasMore: false, browseHasMore: false,
}) });
export const filteredExtensions = $derived.by(() => { export const filteredExtensions = $derived.by(() => {
let result = extensionsState.items let result = extensionsState.items;
if (extensionsState.filter.installed) { if (extensionsState.filter.installed) {
result = result.filter(e => e.installed) result = result.filter(e => e.installed);
} }
if (extensionsState.filter.language !== 'all') { 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) { if (extensionsState.filter.query) {
const q = extensionsState.filter.query.toLowerCase() const q = extensionsState.filter.query.toLowerCase();
result = result.filter(e => e.name.toLowerCase().includes(q)) result = result.filter(e => e.name.toLowerCase().includes(q));
} }
return result return result;
}) });
+21 -17
View File
@@ -1,7 +1,9 @@
import type { Manga } from '$lib/types' import type {Manga} from '$lib/types';
import type { MangaStatus } from '$lib/server-adapters/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({ export const libraryState = $state({
items: [] as Manga[], items: [] as Manga[],
@@ -18,36 +20,38 @@ export const libraryState = $state({
sortDesc: false, sortDesc: false,
view: 'grid' as 'grid' | 'list', view: 'grid' as 'grid' | 'list',
selected: new Set<string>(), selected: new Set<string>(),
}) });
export const filteredItems = $derived.by(() => { export const filteredItems = $derived.by(() => {
let result = libraryState.items let result = libraryState.items;
result = result.filter(m => !shouldHideNsfw(m, settingsState));
if (libraryState.filter.unread) { if (libraryState.filter.unread) {
result = result.filter(m => m.unreadCount > 0) result = result.filter(m => m.unreadCount > 0);
} }
if (libraryState.filter.status !== 'all') { 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) { if (libraryState.filter.tags.length > 0) {
result = result.filter(m => result = result.filter(m =>
libraryState.filter.tags.every(tag => m.tags?.includes(tag)) libraryState.filter.tags.every(tag => m.tags?.includes(tag))
) );
} }
if (libraryState.filter.query) { if (libraryState.filter.query) {
const q = libraryState.filter.query.toLowerCase() const q = libraryState.filter.query.toLowerCase();
result = result.filter(m => m.title.toLowerCase().includes(q)) result = result.filter(m => m.title.toLowerCase().includes(q));
} }
const sorted = [...result].sort((a, b) => { const sorted = [...result].sort((a, b) => {
switch (libraryState.sort) { switch (libraryState.sort) {
case 'unread': return (b.unreadCount ?? 0) - (a.unreadCount ?? 0) case 'unread': return (b.unreadCount ?? 0) - (a.unreadCount ?? 0);
case 'lastRead': return (b.lastReadAt ?? 0) - (a.lastReadAt ?? 0) case 'lastRead': return (b.lastReadAt ?? 0) - (a.lastReadAt ?? 0);
case 'dateAdded': return (b.addedAt ?? 0) - (a.addedAt ?? 0) case 'dateAdded': return (b.addedAt ?? 0) - (a.addedAt ?? 0);
case 'alphabetical': 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;
}) });
+70
View File
@@ -1,12 +1,16 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { page } from '$app/stores' import { page } from '$app/stores'
import { applyTheme, mountSystemThemeSync, unmountSystemThemeSync } from '$lib/core/theme' import { applyTheme, mountSystemThemeSync, unmountSystemThemeSync } from '$lib/core/theme'
import { matchesKeybind, toggleFullscreen } from '$lib/core/keybinds/keybindEngine'
import { mountIdleDetection } from '$lib/core/ui/idle' import { mountIdleDetection } from '$lib/core/ui/idle'
import { applyZoom, mountZoomKey } from '$lib/core/ui/zoom' import { applyZoom, mountZoomKey } from '$lib/core/ui/zoom'
import { appState } from '$lib/state/app.svelte' import { appState } from '$lib/state/app.svelte'
import { settingsState, updateSettings } from '$lib/state/settings.svelte' import { settingsState, updateSettings } from '$lib/state/settings.svelte'
import { notificationsState } from '$lib/state/notifications.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 SplashScreen from '$lib/ui/chrome/SplashScreen.svelte'
import AuthGate from '$lib/ui/chrome/AuthGate.svelte' import AuthGate from '$lib/ui/chrome/AuthGate.svelte'
import Sidebar from '$lib/ui/chrome/Sidebar.svelte' import Sidebar from '$lib/ui/chrome/Sidebar.svelte'
@@ -28,6 +32,70 @@
const showShell = $derived(appState.status === 'ready' || bypassed) const showShell = $derived(appState.status === 'ready' || bypassed)
const splashCards = $derived(settingsState.splashCards ?? true) 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() { function onSplashReady() {
splashVisible = false splashVisible = false
} }
@@ -84,6 +152,8 @@
}) })
</script> </script>
<svelte:window onkeydown={handleGlobalKeydown} />
{#if showSplash && splashVisible} {#if showSplash && splashVisible}
<SplashScreen <SplashScreen
mode="loading" mode="loading"
+3 -1
View File
@@ -3,6 +3,8 @@
import { MagnifyingGlass, ArrowSquareOut } from 'phosphor-svelte' import { MagnifyingGlass, ArrowSquareOut } from 'phosphor-svelte'
import { loadSources } from '$lib/request-manager/extensions' import { loadSources } from '$lib/request-manager/extensions'
import { extensionsState } from '$lib/state/extensions.svelte' import { extensionsState } from '$lib/state/extensions.svelte'
import { settingsState } from '$lib/state/settings.svelte'
import { shouldHideSource } from '$lib/core/util'
let query = $state('') let query = $state('')
let language = $state('all') let language = $state('all')
@@ -21,7 +23,7 @@
return extensionsState.sources.filter(source => { return extensionsState.sources.filter(source => {
if (language !== 'all' && source.lang !== language) return false 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 if (!q) return true
return ( return (
@@ -4,6 +4,9 @@
import { ArrowArcLeft, CaretLeft, CaretRight, Columns, List, SpinnerGap, TextAlignRight } from 'phosphor-svelte' import { ArrowArcLeft, CaretLeft, CaretRight, Columns, List, SpinnerGap, TextAlignRight } from 'phosphor-svelte'
import { currentPageData, progress, readerState } from '$lib/state/reader.svelte' import { currentPageData, progress, readerState } from '$lib/state/reader.svelte'
import { ensureReaderSession, getAdjacentChapters, goToNextReaderPage, goToPreviousReaderPage, setCurrentReaderPage } from '$lib/core/reader/session' 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' import Button from '$lib/ui/primitives/Button.svelte'
let initializing = $state(true) let initializing = $state(true)
@@ -86,18 +89,94 @@
} }
function handleKeydown(event: KeyboardEvent) { function handleKeydown(event: KeyboardEvent) {
if (event.key === 'ArrowRight') { const binds = settingsState.keybinds
if (matchesKeybind(event, binds.turnPageRight)) {
event.preventDefault() event.preventDefault()
void (readerState.direction === 'rtl' ? stepBackward() : stepForward()) void (readerState.direction === 'rtl' ? stepBackward() : stepForward())
return return
} }
if (event.key === 'ArrowLeft') { if (matchesKeybind(event, binds.turnPageLeft)) {
event.preventDefault() event.preventDefault()
void (readerState.direction === 'rtl' ? stepForward() : stepBackward()) void (readerState.direction === 'rtl' ? stepForward() : stepBackward())
return 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') { if (event.key === 'Escape') {
event.preventDefault() event.preventDefault()
void returnToSeries() void returnToSeries()
+71
View File
@@ -1,9 +1,53 @@
<script lang="ts"> <script lang="ts">
import pkg from '../../../../package.json' import pkg from '../../../../package.json'
import { appState } from '$lib/state/app.svelte'
import { settingsState } from '$lib/state/settings.svelte' import { settingsState } from '$lib/state/settings.svelte'
import { trackingState } from '$lib/state/tracking.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 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> </script>
<svelte:head> <svelte:head>
@@ -23,8 +67,35 @@
<div class="settings-label">Moku</div> <div class="settings-label">Moku</div>
<div class="settings-desc">Version {appVersion}</div> <div class="settings-desc">Version {appVersion}</div>
</div> </div>
{#if canCheckUpdates}
<button class="settings-button" type="button" onclick={handleCheckUpdate} disabled={updateChecking}>
{updateChecking ? 'Checking…' : 'Check for updates'}
</button>
{/if}
</div> </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 class="settings-row settings-grid-2">
<div> <div>
<div class="settings-label">Server URL</div> <div class="settings-label">Server URL</div>
+120
View File
@@ -1,5 +1,100 @@
<script lang="ts"> <script lang="ts">
import { appState } from '$lib/state/app.svelte'
import { settingsState, updateSettings } from '$lib/state/settings.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> </script>
<svelte:head> <svelte:head>
@@ -34,4 +129,29 @@
</label> </label>
</div> </div>
</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> </section>