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:
@@ -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();
|
||||
});
|
||||
}
|
||||
Vendored
+153
@@ -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;
|
||||
}
|
||||
Vendored
+4
@@ -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';
|
||||
Vendored
+119
@@ -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();
|
||||
}
|
||||
Vendored
+31
-31
@@ -1,18 +1,18 @@
|
||||
interface Entry<T> {
|
||||
promise: Promise<T>;
|
||||
promise: Promise<T>;
|
||||
fetchedAt: number;
|
||||
fetcher?: () => Promise<T>;
|
||||
ttl?: number;
|
||||
fetcher?: () => Promise<T>;
|
||||
ttl?: number;
|
||||
}
|
||||
|
||||
const store = new Map<string, Entry<unknown>>();
|
||||
const subs = new Map<string, Set<() => void>>();
|
||||
const store = new Map<string, Entry<unknown>>();
|
||||
const subs = new Map<string, Set<() => void>>();
|
||||
const keyToGroups = new Map<string, Set<string>>();
|
||||
const groups = new Map<string, Set<string>>();
|
||||
const groups = new Map<string, Set<string>>();
|
||||
|
||||
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<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);
|
||||
promise.then(() => notify(key)).catch(() => {});
|
||||
return promise;
|
||||
@@ -62,7 +62,7 @@ export const cache = {
|
||||
const existing = store.get(key) as Entry<T> | 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<unknown>, fetchedAt: Date.now() });
|
||||
store.set(key, {...existing, promise: promise as Promise<unknown>, 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<string, number>;
|
||||
|
||||
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<T extends { id: string }>(sources: T[]): T[] {
|
||||
export function getTopSources<T extends {id: string;}>(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(() => {});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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<string>(),
|
||||
})
|
||||
});
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
@@ -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