mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Cleanup core utilities and abstractions
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
export * from './selectPortal';
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import type {Attachment} from 'svelte/attachments';
|
||||||
|
|
||||||
|
export function selectPortal(triggerEl: HTMLElement & {__selectMenuEl?: HTMLElement | null;}): Attachment {
|
||||||
|
return (menuEl: HTMLElement) => {
|
||||||
|
function position() {
|
||||||
|
const zoom = parseFloat(document.documentElement.style.zoom) / 100 || 1;
|
||||||
|
const rect = triggerEl.getBoundingClientRect();
|
||||||
|
|
||||||
|
const top = rect.bottom / zoom + 4;
|
||||||
|
const right = rect.right / zoom;
|
||||||
|
const width = menuEl.offsetWidth;
|
||||||
|
const left = Math.max(8, right - width);
|
||||||
|
|
||||||
|
menuEl.style.position = 'fixed';
|
||||||
|
menuEl.style.top = `${top}px`;
|
||||||
|
menuEl.style.left = `${left}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
menuEl.style.visibility = 'hidden';
|
||||||
|
document.body.appendChild(menuEl);
|
||||||
|
triggerEl.__selectMenuEl = menuEl;
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
position();
|
||||||
|
menuEl.style.visibility = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('scroll', position, true);
|
||||||
|
window.addEventListener('resize', position);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', position, true);
|
||||||
|
window.removeEventListener('resize', position);
|
||||||
|
triggerEl.__selectMenuEl = null;
|
||||||
|
menuEl.remove();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
export async function runConcurrent<T>(
|
||||||
|
items: T[],
|
||||||
|
fn: (item: T) => Promise<void>,
|
||||||
|
signal: AbortSignal,
|
||||||
|
concurrency = 6,
|
||||||
|
): Promise<void> {
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
async function worker() {
|
||||||
|
while (index < items.length) {
|
||||||
|
if (signal.aborted) return;
|
||||||
|
const item = items[index++];
|
||||||
|
await fn(item).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(Array.from({length: Math.min(concurrency, items.length)}, worker));
|
||||||
|
}
|
||||||
|
|
||||||
|
const inflight = new Map<string, Promise<unknown>>();
|
||||||
|
|
||||||
|
export function dedupeRequest<T>(key: string, factory: () => Promise<T>): Promise<T>;
|
||||||
|
export function dedupeRequest<T>(fn: (key: string) => Promise<T>): (key: string) => Promise<T>;
|
||||||
|
export function dedupeRequest<T>(
|
||||||
|
keyOrFn: string | ((key: string) => Promise<T>),
|
||||||
|
factory?: () => Promise<T>,
|
||||||
|
): Promise<T> | ((key: string) => Promise<T>) {
|
||||||
|
if (typeof keyOrFn === 'function') {
|
||||||
|
const fn = keyOrFn;
|
||||||
|
return (key: string) => dedupeRequest(key, () => fn(key));
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = keyOrFn;
|
||||||
|
if (inflight.has(key)) return inflight.get(key) as Promise<T>;
|
||||||
|
|
||||||
|
const request = factory!().finally(() => inflight.delete(key));
|
||||||
|
inflight.set(key, request);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
export interface PaginatedQuery<T> {
|
||||||
|
fetchPage(page: number): Promise<T[]>;
|
||||||
|
reset(): void;
|
||||||
|
hasMore(): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaginatedQueryConfig<T> {
|
||||||
|
fetcher: (page: number) => Promise<{items: T[]; hasNextPage: boolean;}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPaginatedQuery<T>(config: PaginatedQueryConfig<T>): PaginatedQuery<T> {
|
||||||
|
let hasMore = true;
|
||||||
|
|
||||||
|
return {
|
||||||
|
async fetchPage(page) {
|
||||||
|
const {items, hasNextPage} = await config.fetcher(page);
|
||||||
|
hasMore = hasNextPage;
|
||||||
|
return items;
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
hasMore = true;
|
||||||
|
},
|
||||||
|
hasMore() {
|
||||||
|
return hasMore;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
export interface RetryOptions {
|
||||||
|
maxAttempts?: number;
|
||||||
|
baseDelayMs?: number;
|
||||||
|
maxDelayMs?: number;
|
||||||
|
shouldRetry?: (error: unknown, attempt: number) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWithRetry<T>(
|
||||||
|
fetcher: () => Promise<T>,
|
||||||
|
options: RetryOptions = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const {
|
||||||
|
maxAttempts = 3,
|
||||||
|
baseDelayMs = 500,
|
||||||
|
maxDelayMs = 10_000,
|
||||||
|
shouldRetry = () => true,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
let lastError: unknown;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
|
try {
|
||||||
|
return await fetcher();
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
if (attempt === maxAttempts || !shouldRetry(error, attempt)) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), maxDelayMs);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './fetchWithRetry';
|
||||||
|
export * from './batchRequests';
|
||||||
|
export * from './createPaginatedQuery';
|
||||||
@@ -13,6 +13,12 @@ export function matchesKeybind(e: KeyboardEvent, bind: string): boolean {
|
|||||||
return eventToKeybind(e) === bind;
|
return eventToKeybind(e) === bind;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function initKeybindEngine(): () => void {
|
||||||
|
// Global matching is event-driven via handleGlobalKeydown in the app shell.
|
||||||
|
// This hook makes boot ordering explicit and reserves a dedicated setup point.
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
|
||||||
export async function toggleFullscreen(): Promise<void> {
|
export async function toggleFullscreen(): Promise<void> {
|
||||||
if (typeof window === 'undefined' || !('__TAURI_INTERNALS__' in window)) return;
|
if (typeof window === 'undefined' || !('__TAURI_INTERNALS__' in window)) return;
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,48 @@
|
|||||||
import { getAdapter } from '$lib/request-manager'
|
import {getAdapter} from '$lib/request-manager';
|
||||||
import { trackingState } from '$lib/state/tracking.svelte'
|
import {trackingState} from '$lib/state/tracking.svelte';
|
||||||
|
import type {TrackRecord} from '$lib/types';
|
||||||
|
|
||||||
export async function loadTrackers() {
|
export async function loadTrackers() {
|
||||||
trackingState.loading = true
|
trackingState.loading = true;
|
||||||
trackingState.error = null
|
trackingState.error = null;
|
||||||
try {
|
try {
|
||||||
trackingState.trackers = await getAdapter().getTrackers()
|
trackingState.trackers = await getAdapter().getTrackers();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
trackingState.error = String(e)
|
trackingState.error = String(e);
|
||||||
} finally {
|
} finally {
|
||||||
trackingState.loading = false
|
trackingState.loading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function loadTrackerRecords(): Promise<TrackRecord[]> {
|
||||||
|
return getAdapter().getTrackerRecords();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginTrackerOAuth(trackerId: number, callbackUrl: string) {
|
||||||
|
await getAdapter().loginTrackerOAuth(trackerId, callbackUrl);
|
||||||
|
await loadTrackers();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loginTrackerCredentials(trackerId: number, username: string, password: string) {
|
||||||
|
await getAdapter().loginTrackerCredentials(trackerId, username, password);
|
||||||
|
await loadTrackers();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logoutTracker(trackerId: number) {
|
||||||
|
await getAdapter().logoutTracker(trackerId);
|
||||||
|
await loadTrackers();
|
||||||
|
}
|
||||||
|
|
||||||
export async function linkTracker(mangaId: string, trackerId: string, remoteId: string) {
|
export async function linkTracker(mangaId: string, trackerId: string, remoteId: string) {
|
||||||
await getAdapter().linkTracker(mangaId, trackerId, remoteId)
|
await getAdapter().linkTracker(mangaId, trackerId, remoteId);
|
||||||
await loadTrackers()
|
await loadTrackers();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function syncTracking(mangaId: string) {
|
export async function syncTracking(mangaId: string) {
|
||||||
trackingState.syncing = true
|
trackingState.syncing = true;
|
||||||
try {
|
try {
|
||||||
await getAdapter().syncTracking(mangaId)
|
await getAdapter().syncTracking(mangaId);
|
||||||
} finally {
|
} finally {
|
||||||
trackingState.syncing = false
|
trackingState.syncing = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
UpdateResult,
|
UpdateResult,
|
||||||
} from '$lib/server-adapters/types';
|
} from '$lib/server-adapters/types';
|
||||||
import type {Manga, Chapter, Extension, Source, Tracker} from '$lib/types';
|
import type {Manga, Chapter, Extension, Source, Tracker} from '$lib/types';
|
||||||
|
import type {TrackRecord} from '$lib/types/tracking';
|
||||||
|
|
||||||
function notImplemented(): never {
|
function notImplemented(): never {
|
||||||
throw new Error('MokuAdapter: not implemented');
|
throw new Error('MokuAdapter: not implemented');
|
||||||
@@ -47,6 +48,10 @@ export class MokuAdapter implements ServerAdapter {
|
|||||||
async browseSource(_sourceId: string, _page: number): Promise<PaginatedResult<Manga>> {return notImplemented();}
|
async browseSource(_sourceId: string, _page: number): Promise<PaginatedResult<Manga>> {return notImplemented();}
|
||||||
|
|
||||||
async getTrackers(): Promise<Tracker[]> {return notImplemented();}
|
async getTrackers(): Promise<Tracker[]> {return notImplemented();}
|
||||||
|
async getTrackerRecords(): Promise<TrackRecord[]> {return notImplemented();}
|
||||||
|
async loginTrackerOAuth(_trackerId: number, _callbackUrl: string): Promise<void> {notImplemented();}
|
||||||
|
async loginTrackerCredentials(_trackerId: number, _username: string, _password: string): Promise<void> {notImplemented();}
|
||||||
|
async logoutTracker(_trackerId: number): Promise<void> {notImplemented();}
|
||||||
async linkTracker(_mangaId: string, _trackerId: string, _remoteId: string): Promise<void> {notImplemented();}
|
async linkTracker(_mangaId: string, _trackerId: string, _remoteId: string): Promise<void> {notImplemented();}
|
||||||
async syncTracking(_mangaId: string): Promise<void> {notImplemented();}
|
async syncTracking(_mangaId: string): Promise<void> {notImplemented();}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
UpdateResult,
|
UpdateResult,
|
||||||
} from '$lib/server-adapters/types';
|
} from '$lib/server-adapters/types';
|
||||||
import type {Manga, Chapter, Extension, Source, Tracker} from '$lib/types';
|
import type {Manga, Chapter, Extension, Source, Tracker} from '$lib/types';
|
||||||
|
import type {TrackRecord} from '$lib/types/tracking';
|
||||||
import {
|
import {
|
||||||
GET_LIBRARY,
|
GET_LIBRARY,
|
||||||
GET_MANGA,
|
GET_MANGA,
|
||||||
@@ -44,6 +45,9 @@ import {
|
|||||||
GET_TRACKERS,
|
GET_TRACKERS,
|
||||||
BIND_TRACK,
|
BIND_TRACK,
|
||||||
TRACK_PROGRESS,
|
TRACK_PROGRESS,
|
||||||
|
LOGIN_TRACKER_OAUTH,
|
||||||
|
LOGIN_TRACKER_CREDENTIALS,
|
||||||
|
LOGOUT_TRACKER,
|
||||||
} from './tracking';
|
} from './tracking';
|
||||||
import {
|
import {
|
||||||
GQLResponse,
|
GQLResponse,
|
||||||
@@ -237,6 +241,31 @@ export class SuwayomiAdapter implements ServerAdapter {
|
|||||||
return data.trackers.nodes;
|
return data.trackers.nodes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getTrackerRecords(): Promise<TrackRecord[]> {
|
||||||
|
const trackers = await this.getTrackers();
|
||||||
|
const records: TrackRecord[] = [];
|
||||||
|
|
||||||
|
for (const tracker of trackers) {
|
||||||
|
for (const record of tracker.trackRecords?.nodes ?? []) {
|
||||||
|
records.push(record);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return records;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loginTrackerOAuth(trackerId: number, callbackUrl: string) {
|
||||||
|
await this.gql(LOGIN_TRACKER_OAUTH, {trackerId, callbackUrl});
|
||||||
|
}
|
||||||
|
|
||||||
|
async loginTrackerCredentials(trackerId: number, username: string, password: string) {
|
||||||
|
await this.gql(LOGIN_TRACKER_CREDENTIALS, {trackerId, username, password});
|
||||||
|
}
|
||||||
|
|
||||||
|
async logoutTracker(trackerId: number) {
|
||||||
|
await this.gql(LOGOUT_TRACKER, {trackerId});
|
||||||
|
}
|
||||||
|
|
||||||
async linkTracker(mangaId: string, trackerId: string, remoteId: string) {
|
async linkTracker(mangaId: string, trackerId: string, remoteId: string) {
|
||||||
await this.gql(BIND_TRACK, {
|
await this.gql(BIND_TRACK, {
|
||||||
mangaId: Number(mangaId),
|
mangaId: Number(mangaId),
|
||||||
|
|||||||
@@ -6,10 +6,17 @@ export const GET_TRACKERS = `
|
|||||||
supportsPrivateTracking supportsReadingDates supportsTrackDeletion
|
supportsPrivateTracking supportsReadingDates supportsTrackDeletion
|
||||||
scores
|
scores
|
||||||
statuses { value name }
|
statuses { value name }
|
||||||
|
trackRecords {
|
||||||
|
nodes {
|
||||||
|
id trackerId remoteId title status score displayScore
|
||||||
|
lastChapterRead totalChapters remoteUrl startDate finishDate private libraryId
|
||||||
|
manga { id title thumbnailUrl inLibrary }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
export const GET_MANGA_TRACK_RECORDS = `
|
export const GET_MANGA_TRACK_RECORDS = `
|
||||||
query GetMangaTrackRecords($mangaId: Int!) {
|
query GetMangaTrackRecords($mangaId: Int!) {
|
||||||
@@ -22,7 +29,7 @@ export const GET_MANGA_TRACK_RECORDS = `
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`;
|
||||||
|
|
||||||
export const SEARCH_TRACKER = `
|
export const SEARCH_TRACKER = `
|
||||||
query SearchTracker($trackerId: Int!, $query: String!) {
|
query SearchTracker($trackerId: Int!, $query: String!) {
|
||||||
@@ -33,7 +40,7 @@ export const SEARCH_TRACKER = `
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`;
|
||||||
|
|
||||||
export const BIND_TRACK = `
|
export const BIND_TRACK = `
|
||||||
mutation BindTrack($mangaId: Int!, $trackerId: Int!, $remoteId: LongString!) {
|
mutation BindTrack($mangaId: Int!, $trackerId: Int!, $remoteId: LongString!) {
|
||||||
@@ -41,7 +48,7 @@ export const BIND_TRACK = `
|
|||||||
trackRecord { id trackerId remoteId }
|
trackRecord { id trackerId remoteId }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`;
|
||||||
|
|
||||||
export const TRACK_PROGRESS = `
|
export const TRACK_PROGRESS = `
|
||||||
mutation TrackProgress($mangaId: Int!) {
|
mutation TrackProgress($mangaId: Int!) {
|
||||||
@@ -49,7 +56,7 @@ export const TRACK_PROGRESS = `
|
|||||||
trackRecords { id trackerId lastChapterRead status }
|
trackRecords { id trackerId lastChapterRead status }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`;
|
||||||
|
|
||||||
export const UPDATE_TRACK = `
|
export const UPDATE_TRACK = `
|
||||||
mutation UpdateTrack($recordId: Int!, $status: Int, $score: Float, $lastChapterRead: Float, $startDate: LongString, $finishDate: LongString, $private: Boolean) {
|
mutation UpdateTrack($recordId: Int!, $status: Int, $score: Float, $lastChapterRead: Float, $startDate: LongString, $finishDate: LongString, $private: Boolean) {
|
||||||
@@ -65,7 +72,7 @@ export const UPDATE_TRACK = `
|
|||||||
trackRecord { id status score lastChapterRead }
|
trackRecord { id status score lastChapterRead }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`;
|
||||||
|
|
||||||
export const UNLINK_TRACK = `
|
export const UNLINK_TRACK = `
|
||||||
mutation UnlinkTrack($trackRecordId: Int!) {
|
mutation UnlinkTrack($trackRecordId: Int!) {
|
||||||
@@ -73,7 +80,7 @@ export const UNLINK_TRACK = `
|
|||||||
trackRecord { id }
|
trackRecord { id }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`;
|
||||||
|
|
||||||
export const LOGIN_TRACKER_CREDENTIALS = `
|
export const LOGIN_TRACKER_CREDENTIALS = `
|
||||||
mutation LoginTrackerCredentials($trackerId: Int!, $username: String!, $password: String!) {
|
mutation LoginTrackerCredentials($trackerId: Int!, $username: String!, $password: String!) {
|
||||||
@@ -81,7 +88,15 @@ export const LOGIN_TRACKER_CREDENTIALS = `
|
|||||||
isLoggedIn
|
isLoggedIn
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`;
|
||||||
|
|
||||||
|
export const LOGIN_TRACKER_OAUTH = `
|
||||||
|
mutation LoginTrackerOAuth($trackerId: Int!, $callbackUrl: String!) {
|
||||||
|
loginTrackerOAuth(input: { trackerId: $trackerId, callbackUrl: $callbackUrl }) {
|
||||||
|
isLoggedIn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
export const LOGOUT_TRACKER = `
|
export const LOGOUT_TRACKER = `
|
||||||
mutation LogoutTracker($trackerId: Int!) {
|
mutation LogoutTracker($trackerId: Int!) {
|
||||||
@@ -89,4 +104,4 @@ export const LOGOUT_TRACKER = `
|
|||||||
isLoggedIn
|
isLoggedIn
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`;
|
||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
Source,
|
Source,
|
||||||
Tracker,
|
Tracker,
|
||||||
} from '$lib/types';
|
} from '$lib/types';
|
||||||
|
import type {TrackRecord} from '$lib/types/tracking';
|
||||||
|
|
||||||
export interface ServerConfig {
|
export interface ServerConfig {
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
@@ -88,6 +89,10 @@ export interface ServerAdapter {
|
|||||||
browseSource(sourceId: string, page: number): Promise<PaginatedResult<Manga>>;
|
browseSource(sourceId: string, page: number): Promise<PaginatedResult<Manga>>;
|
||||||
|
|
||||||
getTrackers(): Promise<Tracker[]>;
|
getTrackers(): Promise<Tracker[]>;
|
||||||
|
getTrackerRecords(): Promise<TrackRecord[]>;
|
||||||
|
loginTrackerOAuth(trackerId: number, callbackUrl: string): Promise<void>;
|
||||||
|
loginTrackerCredentials(trackerId: number, username: string, password: string): Promise<void>;
|
||||||
|
logoutTracker(trackerId: number): Promise<void>;
|
||||||
linkTracker(mangaId: string, trackerId: string, remoteId: string): Promise<void>;
|
linkTracker(mangaId: string, trackerId: string, remoteId: string): Promise<void>;
|
||||||
syncTracking(mangaId: string): Promise<void>;
|
syncTracking(mangaId: string): Promise<void>;
|
||||||
|
|
||||||
|
|||||||
+45
-31
@@ -3,7 +3,7 @@
|
|||||||
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 { initKeybindEngine, 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'
|
||||||
@@ -94,36 +94,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
||||||
}
|
}
|
||||||
@@ -134,6 +104,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
// Keep shell startup deterministic: keybinds -> visuals -> idle -> platform listeners -> feature loops.
|
||||||
|
const stopKeybindEngine = initKeybindEngine()
|
||||||
|
|
||||||
applyTheme(settingsState.theme, settingsState.customThemes)
|
applyTheme(settingsState.theme, settingsState.customThemes)
|
||||||
applyZoom(settingsState.uiZoom)
|
applyZoom(settingsState.uiZoom)
|
||||||
mountSystemThemeSync()
|
mountSystemThemeSync()
|
||||||
@@ -169,6 +142,45 @@
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let lastPresenceKey = ''
|
||||||
|
|
||||||
|
const stopDiscordWatch = $effect.root(() => {
|
||||||
|
$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(() => {})
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (lastPresenceKey) {
|
||||||
|
lastPresenceKey = ''
|
||||||
|
void clearDiscordPresence().catch(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const DOWNLOAD_POLL_MS = 8_000
|
const DOWNLOAD_POLL_MS = 8_000
|
||||||
let downloadPollId: ReturnType<typeof setInterval> | null = null
|
let downloadPollId: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
@@ -206,7 +218,9 @@
|
|||||||
appState.idle = false
|
appState.idle = false
|
||||||
stopZoomKey()
|
stopZoomKey()
|
||||||
stopIdleDetection()
|
stopIdleDetection()
|
||||||
|
stopKeybindEngine()
|
||||||
stopDownloadPolling()
|
stopDownloadPolling()
|
||||||
|
stopDiscordWatch()
|
||||||
stopStatusWatch()
|
stopStatusWatch()
|
||||||
window.removeEventListener('resize', handleResize)
|
window.removeEventListener('resize', handleResize)
|
||||||
unmountSystemThemeSync()
|
unmountSystemThemeSync()
|
||||||
|
|||||||
@@ -1,63 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
import { settingsState } from '$lib/state/settings.svelte'
|
|
||||||
import { trackingState } from '$lib/state/tracking.svelte'
|
import { trackingState } from '$lib/state/tracking.svelte'
|
||||||
import { syncTracking } from '$lib/request-manager/tracking'
|
import {
|
||||||
|
loadTrackers,
|
||||||
interface GqlTracker {
|
loginTrackerOAuth,
|
||||||
id: number
|
loginTrackerCredentials,
|
||||||
name: string
|
logoutTracker,
|
||||||
icon?: string | null
|
syncTracking,
|
||||||
isLoggedIn: boolean
|
} from '$lib/request-manager/tracking'
|
||||||
isTokenExpired: boolean
|
import type { Tracker } from '$lib/types'
|
||||||
authUrl?: string | null
|
|
||||||
trackRecords?: {
|
|
||||||
nodes: Array<{
|
|
||||||
id: number
|
|
||||||
manga?: { id: number; title: string; thumbnailUrl: string; inLibrary: boolean } | null
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const GET_TRACKERS = `
|
|
||||||
query GetTrackers {
|
|
||||||
trackers {
|
|
||||||
nodes {
|
|
||||||
id name icon isLoggedIn isTokenExpired authUrl
|
|
||||||
trackRecords {
|
|
||||||
nodes {
|
|
||||||
id trackerId remoteId title status score displayScore lastChapterRead totalChapters remoteUrl
|
|
||||||
manga { id title thumbnailUrl inLibrary }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
const LOGIN_TRACKER_OAUTH = `
|
|
||||||
mutation LoginTrackerOAuth($trackerId: Int!, $callbackUrl: String!) {
|
|
||||||
loginTrackerOAuth(input: { trackerId: $trackerId, callbackUrl: $callbackUrl }) {
|
|
||||||
isLoggedIn
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
const LOGIN_TRACKER_CREDENTIALS = `
|
|
||||||
mutation LoginTrackerCredentials($trackerId: Int!, $username: String!, $password: String!) {
|
|
||||||
loginTrackerCredentials(input: { trackerId: $trackerId, username: $username, password: $password }) {
|
|
||||||
isLoggedIn
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
const LOGOUT_TRACKER = `
|
|
||||||
mutation LogoutTracker($trackerId: Int!) {
|
|
||||||
logoutTracker(input: { trackerId: $trackerId }) {
|
|
||||||
isLoggedIn
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
let oauthTrackerId = $state<number | null>(null)
|
let oauthTrackerId = $state<number | null>(null)
|
||||||
let oauthCallback = $state('')
|
let oauthCallback = $state('')
|
||||||
@@ -65,84 +16,44 @@
|
|||||||
let credsUsername = $state('')
|
let credsUsername = $state('')
|
||||||
let credsPassword = $state('')
|
let credsPassword = $state('')
|
||||||
|
|
||||||
function endpoint() {
|
|
||||||
return `${settingsState.serverUrl.replace(/\/$/, '')}/api/graphql`
|
|
||||||
}
|
|
||||||
|
|
||||||
function authHeaders() {
|
|
||||||
const headers: Record<string, string> = {'Content-Type': 'application/json'}
|
|
||||||
if (settingsState.serverAuthMode === 'BASIC_AUTH' && settingsState.serverAuthUser) {
|
|
||||||
headers.Authorization = `Basic ${btoa(`${settingsState.serverAuthUser}:${settingsState.serverAuthPass}`)}`
|
|
||||||
}
|
|
||||||
return headers
|
|
||||||
}
|
|
||||||
|
|
||||||
async function gql<T>(query: string, variables?: Record<string, unknown>) {
|
|
||||||
const response = await fetch(endpoint(), {
|
|
||||||
method: 'POST',
|
|
||||||
headers: authHeaders(),
|
|
||||||
body: JSON.stringify({query, variables}),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`HTTP ${response.status}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = await response.json() as { data?: T; errors?: { message: string }[] }
|
|
||||||
if (json.errors?.length) {
|
|
||||||
throw new Error(json.errors[0].message)
|
|
||||||
}
|
|
||||||
|
|
||||||
return json.data as T
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshTrackers() {
|
async function refreshTrackers() {
|
||||||
trackingState.loading = true
|
|
||||||
trackingState.error = null
|
|
||||||
try {
|
try {
|
||||||
const data = await gql<{ trackers: { nodes: GqlTracker[] } }>(GET_TRACKERS)
|
await loadTrackers()
|
||||||
trackingState.trackers = data.trackers.nodes as never
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
trackingState.error = error instanceof Error ? error.message : String(error)
|
trackingState.error = error instanceof Error ? error.message : String(error)
|
||||||
} finally {
|
|
||||||
trackingState.loading = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reconnectOAuth() {
|
async function reconnectOAuth() {
|
||||||
if (!oauthTrackerId || !oauthCallback.trim()) return
|
if (!oauthTrackerId || !oauthCallback.trim()) return
|
||||||
await gql(LOGIN_TRACKER_OAUTH, {trackerId: oauthTrackerId, callbackUrl: oauthCallback.trim()})
|
await loginTrackerOAuth(oauthTrackerId, oauthCallback.trim())
|
||||||
oauthTrackerId = null
|
oauthTrackerId = null
|
||||||
oauthCallback = ''
|
oauthCallback = ''
|
||||||
await refreshTrackers()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function connectCredentials() {
|
async function connectCredentials() {
|
||||||
if (!credsTrackerId || !credsUsername.trim() || !credsPassword.trim()) return
|
if (!credsTrackerId || !credsUsername.trim() || !credsPassword.trim()) return
|
||||||
await gql(LOGIN_TRACKER_CREDENTIALS, {
|
await loginTrackerCredentials(credsTrackerId, credsUsername.trim(), credsPassword)
|
||||||
trackerId: credsTrackerId,
|
|
||||||
username: credsUsername.trim(),
|
|
||||||
password: credsPassword,
|
|
||||||
})
|
|
||||||
credsTrackerId = null
|
credsTrackerId = null
|
||||||
credsUsername = ''
|
credsUsername = ''
|
||||||
credsPassword = ''
|
credsPassword = ''
|
||||||
await refreshTrackers()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function disconnectTracker(trackerId: number) {
|
async function disconnectTracker(trackerId: number) {
|
||||||
await gql(LOGOUT_TRACKER, {trackerId})
|
await logoutTracker(trackerId)
|
||||||
await refreshTrackers()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncAllTrackers() {
|
async function syncAllTrackers() {
|
||||||
trackingState.syncing = true
|
trackingState.syncing = true
|
||||||
try {
|
try {
|
||||||
const mangaIds = new Set<number>()
|
const mangaIds: number[] = []
|
||||||
|
|
||||||
for (const tracker of trackingState.trackers) {
|
for (const tracker of trackingState.trackers) {
|
||||||
for (const record of tracker.trackRecords?.nodes ?? []) {
|
for (const record of tracker.trackRecords?.nodes ?? []) {
|
||||||
if (record.manga?.id) mangaIds.add(record.manga.id)
|
const mangaId = record.manga?.id
|
||||||
|
if (mangaId && !mangaIds.includes(mangaId)) {
|
||||||
|
mangaIds.push(mangaId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,14 +65,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openOAuth(tracker: GqlTracker) {
|
function openOAuth(tracker: Tracker) {
|
||||||
if (tracker.authUrl) window.open(tracker.authUrl, '_blank', 'noopener')
|
if (tracker.authUrl) window.open(tracker.authUrl, '_blank', 'noopener')
|
||||||
oauthTrackerId = tracker.id
|
oauthTrackerId = tracker.id
|
||||||
oauthCallback = ''
|
oauthCallback = ''
|
||||||
credsTrackerId = null
|
credsTrackerId = null
|
||||||
}
|
}
|
||||||
|
|
||||||
function openCredentials(tracker: GqlTracker) {
|
function openCredentials(tracker: Tracker) {
|
||||||
credsTrackerId = tracker.id
|
credsTrackerId = tracker.id
|
||||||
credsUsername = ''
|
credsUsername = ''
|
||||||
credsPassword = ''
|
credsPassword = ''
|
||||||
@@ -193,7 +104,7 @@
|
|||||||
<button class="settings-button" type="button" onclick={() => void refreshTrackers()}>Refresh</button>
|
<button class="settings-button" type="button" onclick={() => void refreshTrackers()}>Refresh</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#each trackingState.trackers as tracker}
|
{#each trackingState.trackers as tracker (tracker.id)}
|
||||||
<div class="settings-row settings-tracker-row">
|
<div class="settings-row settings-tracker-row">
|
||||||
<div>
|
<div>
|
||||||
<div class="settings-label">{tracker.name}</div>
|
<div class="settings-label">{tracker.name}</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user