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
+16
-16
@@ -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(() => {});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -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,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()
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user