diff --git a/src/lib/core/actions/index.ts b/src/lib/core/actions/index.ts new file mode 100644 index 0000000..99c4245 --- /dev/null +++ b/src/lib/core/actions/index.ts @@ -0,0 +1 @@ +export * from './selectPortal'; diff --git a/src/lib/core/actions/selectPortal.ts b/src/lib/core/actions/selectPortal.ts new file mode 100644 index 0000000..bf0a02e --- /dev/null +++ b/src/lib/core/actions/selectPortal.ts @@ -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(); + }; + }; +} diff --git a/src/lib/core/async/batchRequests.ts b/src/lib/core/async/batchRequests.ts new file mode 100644 index 0000000..6646f36 --- /dev/null +++ b/src/lib/core/async/batchRequests.ts @@ -0,0 +1,39 @@ +export async function runConcurrent( + items: T[], + fn: (item: T) => Promise, + signal: AbortSignal, + concurrency = 6, +): Promise { + 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>(); + +export function dedupeRequest(key: string, factory: () => Promise): Promise; +export function dedupeRequest(fn: (key: string) => Promise): (key: string) => Promise; +export function dedupeRequest( + keyOrFn: string | ((key: string) => Promise), + factory?: () => Promise, +): Promise | ((key: string) => Promise) { + 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; + + const request = factory!().finally(() => inflight.delete(key)); + inflight.set(key, request); + return request; +} diff --git a/src/lib/core/async/createPaginatedQuery.ts b/src/lib/core/async/createPaginatedQuery.ts new file mode 100644 index 0000000..eb245f4 --- /dev/null +++ b/src/lib/core/async/createPaginatedQuery.ts @@ -0,0 +1,27 @@ +export interface PaginatedQuery { + fetchPage(page: number): Promise; + reset(): void; + hasMore(): boolean; +} + +export interface PaginatedQueryConfig { + fetcher: (page: number) => Promise<{items: T[]; hasNextPage: boolean;}>; +} + +export function createPaginatedQuery(config: PaginatedQueryConfig): PaginatedQuery { + let hasMore = true; + + return { + async fetchPage(page) { + const {items, hasNextPage} = await config.fetcher(page); + hasMore = hasNextPage; + return items; + }, + reset() { + hasMore = true; + }, + hasMore() { + return hasMore; + }, + }; +} diff --git a/src/lib/core/async/fetchWithRetry.ts b/src/lib/core/async/fetchWithRetry.ts new file mode 100644 index 0000000..9f6363e --- /dev/null +++ b/src/lib/core/async/fetchWithRetry.ts @@ -0,0 +1,36 @@ +export interface RetryOptions { + maxAttempts?: number; + baseDelayMs?: number; + maxDelayMs?: number; + shouldRetry?: (error: unknown, attempt: number) => boolean; +} + +export async function fetchWithRetry( + fetcher: () => Promise, + options: RetryOptions = {}, +): Promise { + 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; +} diff --git a/src/lib/core/async/index.ts b/src/lib/core/async/index.ts new file mode 100644 index 0000000..a3304b1 --- /dev/null +++ b/src/lib/core/async/index.ts @@ -0,0 +1,3 @@ +export * from './fetchWithRetry'; +export * from './batchRequests'; +export * from './createPaginatedQuery'; diff --git a/src/lib/core/keybinds/keybindEngine.ts b/src/lib/core/keybinds/keybindEngine.ts index ead8c24..685cb90 100644 --- a/src/lib/core/keybinds/keybindEngine.ts +++ b/src/lib/core/keybinds/keybindEngine.ts @@ -13,6 +13,12 @@ export function matchesKeybind(e: KeyboardEvent, bind: string): boolean { 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 { if (typeof window === 'undefined' || !('__TAURI_INTERNALS__' in window)) return; diff --git a/src/lib/request-manager/tracking.ts b/src/lib/request-manager/tracking.ts index 94eedb3..f7ccb85 100644 --- a/src/lib/request-manager/tracking.ts +++ b/src/lib/request-manager/tracking.ts @@ -1,28 +1,48 @@ -import { getAdapter } from '$lib/request-manager' -import { trackingState } from '$lib/state/tracking.svelte' +import {getAdapter} from '$lib/request-manager'; +import {trackingState} from '$lib/state/tracking.svelte'; +import type {TrackRecord} from '$lib/types'; export async function loadTrackers() { - trackingState.loading = true - trackingState.error = null + trackingState.loading = true; + trackingState.error = null; try { - trackingState.trackers = await getAdapter().getTrackers() + trackingState.trackers = await getAdapter().getTrackers(); } catch (e) { - trackingState.error = String(e) + trackingState.error = String(e); } finally { - trackingState.loading = false + trackingState.loading = false; } } +export async function loadTrackerRecords(): Promise { + 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) { - await getAdapter().linkTracker(mangaId, trackerId, remoteId) - await loadTrackers() + await getAdapter().linkTracker(mangaId, trackerId, remoteId); + await loadTrackers(); } export async function syncTracking(mangaId: string) { - trackingState.syncing = true + trackingState.syncing = true; try { - await getAdapter().syncTracking(mangaId) + await getAdapter().syncTracking(mangaId); } finally { - trackingState.syncing = false + trackingState.syncing = false; } } diff --git a/src/lib/server-adapters/moku/index.ts b/src/lib/server-adapters/moku/index.ts index 1172f1f..2c87251 100644 --- a/src/lib/server-adapters/moku/index.ts +++ b/src/lib/server-adapters/moku/index.ts @@ -10,6 +10,7 @@ import type { UpdateResult, } from '$lib/server-adapters/types'; import type {Manga, Chapter, Extension, Source, Tracker} from '$lib/types'; +import type {TrackRecord} from '$lib/types/tracking'; function notImplemented(): never { throw new Error('MokuAdapter: not implemented'); @@ -47,6 +48,10 @@ export class MokuAdapter implements ServerAdapter { async browseSource(_sourceId: string, _page: number): Promise> {return notImplemented();} async getTrackers(): Promise {return notImplemented();} + async getTrackerRecords(): Promise {return notImplemented();} + async loginTrackerOAuth(_trackerId: number, _callbackUrl: string): Promise {notImplemented();} + async loginTrackerCredentials(_trackerId: number, _username: string, _password: string): Promise {notImplemented();} + async logoutTracker(_trackerId: number): Promise {notImplemented();} async linkTracker(_mangaId: string, _trackerId: string, _remoteId: string): Promise {notImplemented();} async syncTracking(_mangaId: string): Promise {notImplemented();} diff --git a/src/lib/server-adapters/suwayomi/index.ts b/src/lib/server-adapters/suwayomi/index.ts index 53743c2..9474d5d 100644 --- a/src/lib/server-adapters/suwayomi/index.ts +++ b/src/lib/server-adapters/suwayomi/index.ts @@ -10,6 +10,7 @@ import type { UpdateResult, } from '$lib/server-adapters/types'; import type {Manga, Chapter, Extension, Source, Tracker} from '$lib/types'; +import type {TrackRecord} from '$lib/types/tracking'; import { GET_LIBRARY, GET_MANGA, @@ -44,6 +45,9 @@ import { GET_TRACKERS, BIND_TRACK, TRACK_PROGRESS, + LOGIN_TRACKER_OAUTH, + LOGIN_TRACKER_CREDENTIALS, + LOGOUT_TRACKER, } from './tracking'; import { GQLResponse, @@ -237,6 +241,31 @@ export class SuwayomiAdapter implements ServerAdapter { return data.trackers.nodes; } + async getTrackerRecords(): Promise { + 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) { await this.gql(BIND_TRACK, { mangaId: Number(mangaId), diff --git a/src/lib/server-adapters/suwayomi/tracking.ts b/src/lib/server-adapters/suwayomi/tracking.ts index 3b08d95..9ddca4e 100644 --- a/src/lib/server-adapters/suwayomi/tracking.ts +++ b/src/lib/server-adapters/suwayomi/tracking.ts @@ -6,10 +6,17 @@ export const GET_TRACKERS = ` supportsPrivateTracking supportsReadingDates supportsTrackDeletion scores 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 = ` query GetMangaTrackRecords($mangaId: Int!) { @@ -22,7 +29,7 @@ export const GET_MANGA_TRACK_RECORDS = ` } } } -` +`; export const SEARCH_TRACKER = ` query SearchTracker($trackerId: Int!, $query: String!) { @@ -33,7 +40,7 @@ export const SEARCH_TRACKER = ` } } } -` +`; export const BIND_TRACK = ` mutation BindTrack($mangaId: Int!, $trackerId: Int!, $remoteId: LongString!) { @@ -41,7 +48,7 @@ export const BIND_TRACK = ` trackRecord { id trackerId remoteId } } } -` +`; export const TRACK_PROGRESS = ` mutation TrackProgress($mangaId: Int!) { @@ -49,7 +56,7 @@ export const TRACK_PROGRESS = ` trackRecords { id trackerId lastChapterRead status } } } -` +`; export const UPDATE_TRACK = ` 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 } } } -` +`; export const UNLINK_TRACK = ` mutation UnlinkTrack($trackRecordId: Int!) { @@ -73,7 +80,7 @@ export const UNLINK_TRACK = ` trackRecord { id } } } -` +`; export const LOGIN_TRACKER_CREDENTIALS = ` mutation LoginTrackerCredentials($trackerId: Int!, $username: String!, $password: String!) { @@ -81,7 +88,15 @@ export const LOGIN_TRACKER_CREDENTIALS = ` isLoggedIn } } -` +`; + +export const LOGIN_TRACKER_OAUTH = ` + mutation LoginTrackerOAuth($trackerId: Int!, $callbackUrl: String!) { + loginTrackerOAuth(input: { trackerId: $trackerId, callbackUrl: $callbackUrl }) { + isLoggedIn + } + } +`; export const LOGOUT_TRACKER = ` mutation LogoutTracker($trackerId: Int!) { @@ -89,4 +104,4 @@ export const LOGOUT_TRACKER = ` isLoggedIn } } -` \ No newline at end of file +`; \ No newline at end of file diff --git a/src/lib/server-adapters/types.ts b/src/lib/server-adapters/types.ts index 00bd74a..9e9c087 100644 --- a/src/lib/server-adapters/types.ts +++ b/src/lib/server-adapters/types.ts @@ -5,6 +5,7 @@ import type { Source, Tracker, } from '$lib/types'; +import type {TrackRecord} from '$lib/types/tracking'; export interface ServerConfig { baseUrl: string; @@ -88,6 +89,10 @@ export interface ServerAdapter { browseSource(sourceId: string, page: number): Promise>; getTrackers(): Promise; + getTrackerRecords(): Promise; + loginTrackerOAuth(trackerId: number, callbackUrl: string): Promise; + loginTrackerCredentials(trackerId: number, username: string, password: string): Promise; + logoutTracker(trackerId: number): Promise; linkTracker(mangaId: string, trackerId: string, remoteId: string): Promise; syncTracking(mangaId: string): Promise; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index bda8848..a16471e 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -3,7 +3,7 @@ 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 { initKeybindEngine, 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' @@ -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() { splashVisible = false } @@ -134,6 +104,9 @@ } onMount(() => { + // Keep shell startup deterministic: keybinds -> visuals -> idle -> platform listeners -> feature loops. + const stopKeybindEngine = initKeybindEngine() + applyTheme(settingsState.theme, settingsState.customThemes) applyZoom(settingsState.uiZoom) 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 let downloadPollId: ReturnType | null = null @@ -206,7 +218,9 @@ appState.idle = false stopZoomKey() stopIdleDetection() + stopKeybindEngine() stopDownloadPolling() + stopDiscordWatch() stopStatusWatch() window.removeEventListener('resize', handleResize) unmountSystemThemeSync() diff --git a/src/routes/settings/tracking/+page.svelte b/src/routes/settings/tracking/+page.svelte index 6cb14cc..161a5a5 100644 --- a/src/routes/settings/tracking/+page.svelte +++ b/src/routes/settings/tracking/+page.svelte @@ -1,63 +1,14 @@