mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 01:09: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;
|
||||
}
|
||||
|
||||
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> {
|
||||
if (typeof window === 'undefined' || !('__TAURI_INTERNALS__' in window)) return;
|
||||
|
||||
|
||||
@@ -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<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) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PaginatedResult<Manga>> {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 syncTracking(_mangaId: string): Promise<void> {notImplemented();}
|
||||
|
||||
|
||||
@@ -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<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) {
|
||||
await this.gql(BIND_TRACK, {
|
||||
mangaId: Number(mangaId),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
`
|
||||
`;
|
||||
@@ -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<PaginatedResult<Manga>>;
|
||||
|
||||
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>;
|
||||
syncTracking(mangaId: string): Promise<void>;
|
||||
|
||||
|
||||
+45
-31
@@ -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<typeof setInterval> | null = null
|
||||
|
||||
@@ -206,7 +218,9 @@
|
||||
appState.idle = false
|
||||
stopZoomKey()
|
||||
stopIdleDetection()
|
||||
stopKeybindEngine()
|
||||
stopDownloadPolling()
|
||||
stopDiscordWatch()
|
||||
stopStatusWatch()
|
||||
window.removeEventListener('resize', handleResize)
|
||||
unmountSystemThemeSync()
|
||||
|
||||
@@ -1,63 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { settingsState } from '$lib/state/settings.svelte'
|
||||
import { trackingState } from '$lib/state/tracking.svelte'
|
||||
import { syncTracking } from '$lib/request-manager/tracking'
|
||||
|
||||
interface GqlTracker {
|
||||
id: number
|
||||
name: string
|
||||
icon?: string | null
|
||||
isLoggedIn: boolean
|
||||
isTokenExpired: boolean
|
||||
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
|
||||
}
|
||||
}
|
||||
`
|
||||
import {
|
||||
loadTrackers,
|
||||
loginTrackerOAuth,
|
||||
loginTrackerCredentials,
|
||||
logoutTracker,
|
||||
syncTracking,
|
||||
} from '$lib/request-manager/tracking'
|
||||
import type { Tracker } from '$lib/types'
|
||||
|
||||
let oauthTrackerId = $state<number | null>(null)
|
||||
let oauthCallback = $state('')
|
||||
@@ -65,84 +16,44 @@
|
||||
let credsUsername = $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() {
|
||||
trackingState.loading = true
|
||||
trackingState.error = null
|
||||
try {
|
||||
const data = await gql<{ trackers: { nodes: GqlTracker[] } }>(GET_TRACKERS)
|
||||
trackingState.trackers = data.trackers.nodes as never
|
||||
await loadTrackers()
|
||||
} catch (error) {
|
||||
trackingState.error = error instanceof Error ? error.message : String(error)
|
||||
} finally {
|
||||
trackingState.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async function reconnectOAuth() {
|
||||
if (!oauthTrackerId || !oauthCallback.trim()) return
|
||||
await gql(LOGIN_TRACKER_OAUTH, {trackerId: oauthTrackerId, callbackUrl: oauthCallback.trim()})
|
||||
await loginTrackerOAuth(oauthTrackerId, oauthCallback.trim())
|
||||
oauthTrackerId = null
|
||||
oauthCallback = ''
|
||||
await refreshTrackers()
|
||||
}
|
||||
|
||||
async function connectCredentials() {
|
||||
if (!credsTrackerId || !credsUsername.trim() || !credsPassword.trim()) return
|
||||
await gql(LOGIN_TRACKER_CREDENTIALS, {
|
||||
trackerId: credsTrackerId,
|
||||
username: credsUsername.trim(),
|
||||
password: credsPassword,
|
||||
})
|
||||
await loginTrackerCredentials(credsTrackerId, credsUsername.trim(), credsPassword)
|
||||
credsTrackerId = null
|
||||
credsUsername = ''
|
||||
credsPassword = ''
|
||||
await refreshTrackers()
|
||||
}
|
||||
|
||||
async function disconnectTracker(trackerId: number) {
|
||||
await gql(LOGOUT_TRACKER, {trackerId})
|
||||
await refreshTrackers()
|
||||
await logoutTracker(trackerId)
|
||||
}
|
||||
|
||||
async function syncAllTrackers() {
|
||||
trackingState.syncing = true
|
||||
try {
|
||||
const mangaIds = new Set<number>()
|
||||
const mangaIds: number[] = []
|
||||
|
||||
for (const tracker of trackingState.trackers) {
|
||||
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')
|
||||
oauthTrackerId = tracker.id
|
||||
oauthCallback = ''
|
||||
credsTrackerId = null
|
||||
}
|
||||
|
||||
function openCredentials(tracker: GqlTracker) {
|
||||
function openCredentials(tracker: Tracker) {
|
||||
credsTrackerId = tracker.id
|
||||
credsUsername = ''
|
||||
credsPassword = ''
|
||||
@@ -193,7 +104,7 @@
|
||||
<button class="settings-button" type="button" onclick={() => void refreshTrackers()}>Refresh</button>
|
||||
</div>
|
||||
|
||||
{#each trackingState.trackers as tracker}
|
||||
{#each trackingState.trackers as tracker (tracker.id)}
|
||||
<div class="settings-row settings-tracker-row">
|
||||
<div>
|
||||
<div class="settings-label">{tracker.name}</div>
|
||||
|
||||
Reference in New Issue
Block a user