Cleanup core utilities and abstractions

This commit is contained in:
Zerebos
2026-05-23 21:47:54 -04:00
parent f91b46cfa5
commit 074147f64f
14 changed files with 310 additions and 161 deletions
+1
View File
@@ -0,0 +1 @@
export * from './selectPortal';
+38
View File
@@ -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();
};
};
}
+39
View File
@@ -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;
},
};
}
+36
View File
@@ -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;
}
+3
View File
@@ -0,0 +1,3 @@
export * from './fetchWithRetry';
export * from './batchRequests';
export * from './createPaginatedQuery';
+6
View File
@@ -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;
+32 -12
View File
@@ -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;
} }
} }
+5
View File
@@ -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();}
+29
View File
@@ -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),
+24 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+20 -109
View File
@@ -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>