diff --git a/src/lib/core/theme.ts b/src/lib/core/theme.ts index 746d462..74cdcbb 100644 --- a/src/lib/core/theme.ts +++ b/src/lib/core/theme.ts @@ -1,3 +1,4 @@ +import {settingsState, updateSettings} from '$lib/state/settings.svelte'; import type {CustomTheme, Theme} from '$lib/types/settings'; let themeStyleEl: HTMLStyleElement | null = null; @@ -38,4 +39,41 @@ export function applyTheme(theme: Theme, customThemes: CustomTheme[] = []) { ensureThemeStyleEl().textContent = `[data-theme="custom"] {\n${css}\n}`; document.documentElement.setAttribute('data-theme', 'custom'); +} + +let systemThemeMedia: MediaQueryList | null = null; +let systemThemeHandler: ((event: MediaQueryListEvent) => void) | null = null; + +function applySystemTheme(isDark: boolean) { + const themeId = isDark + ? (settingsState.systemThemeDark ?? 'dark') + : (settingsState.systemThemeLight ?? 'light'); + + updateSettings({theme: themeId}); +} + +export function mountSystemThemeSync() { + if (typeof window === 'undefined') return; + + if (systemThemeMedia && systemThemeHandler) { + systemThemeMedia.removeEventListener('change', systemThemeHandler); + systemThemeMedia = null; + systemThemeHandler = null; + } + + if (!settingsState.systemThemeSync) return; + + systemThemeMedia = window.matchMedia('(prefers-color-scheme: dark)'); + systemThemeHandler = (event) => applySystemTheme(event.matches); + systemThemeMedia.addEventListener('change', systemThemeHandler); + applySystemTheme(systemThemeMedia.matches); +} + +export function unmountSystemThemeSync() { + if (systemThemeMedia && systemThemeHandler) { + systemThemeMedia.removeEventListener('change', systemThemeHandler); + } + + systemThemeMedia = null; + systemThemeHandler = null; } \ No newline at end of file diff --git a/src/lib/core/util.ts b/src/lib/core/util.ts index 35b9ab2..1b2276f 100644 --- a/src/lib/core/util.ts +++ b/src/lib/core/util.ts @@ -1,17 +1,17 @@ -import type { Manga, Source } from "$lib/types"; -import type { Settings } from "$lib/types"; +import type {Manga, Source} from "$lib/types"; +import type {Settings} from "$lib/types/settings"; -export { clsx as cn } from "clsx"; +export {clsx as cn} from "clsx"; export function timeAgo(ts: number): string { const diff = Date.now() - ts, m = Math.floor(diff / 60000); - if (m < 1) return "Just now"; + if (m < 1) return "Just now"; if (m < 60) return `${m}m ago`; const h = Math.floor(m / 60); if (h < 24) return `${h}h ago`; const d = Math.floor(h / 24); - if (d < 7) return `${d}d ago`; - return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" }); + if (d < 7) return `${d}d ago`; + return new Date(ts).toLocaleDateString("en-US", {month: "short", day: "numeric"}); } export function dayLabel(ts: number): string { @@ -19,11 +19,11 @@ export function dayLabel(ts: number): string { if (d.toDateString() === now.toDateString()) return "Today"; const yest = new Date(now); yest.setDate(now.getDate() - 1); if (d.toDateString() === yest.toDateString()) return "Yesterday"; - return d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" }); + return d.toLocaleDateString("en-US", {weekday: "long", month: "long", day: "numeric"}); } export function formatReadTime(m: number): string { - if (m < 1) return "< 1 min"; + if (m < 1) return "< 1 min"; if (m < 60) return `${m} min`; const h = Math.floor(m / 60), r = m % 60; return r === 0 ? `${h}h` : `${h}h ${r}m`; @@ -46,7 +46,7 @@ type ContentFilterSettings = Pick< >; function blockedTagsForSettings(settings: ContentFilterSettings): string[] { - if (settings.contentLevel === "strict") return STRICT_TAGS; + if (settings.contentLevel === "strict") return STRICT_TAGS; if (settings.contentLevel === "moderate") return MODERATE_TAGS; return []; } @@ -59,7 +59,7 @@ function genreMatchesBlocklist(genre: string[], blockedTags: string[]): boolean const idx = norm.indexOf(tag); if (idx === -1) return false; const before = idx === 0 || /\W/.test(norm[idx - 1]); - const after = idx + tag.length === norm.length || /\W/.test(norm[idx + tag.length]); + const after = idx + tag.length === norm.length || /\W/.test(norm[idx + tag.length]); return before && after; }); }); @@ -71,7 +71,7 @@ export function shouldHideNsfw( ): boolean { if (settings.contentLevel === "unrestricted") return false; - const srcId = manga.source?.id; + const srcId = manga.source?.id; const blocked = settings.sourceOverridesEnabled ? (settings.nsfwBlockedSourceIds ?? []) : []; const allowed = settings.sourceOverridesEnabled ? (settings.nsfwAllowedSourceIds ?? []) : []; @@ -99,19 +99,19 @@ export function shouldHideSource( } export function dedupeSourcesByLang( - sources: Source[], + sources: Source[], preferredLang: string, - settings: ContentFilterSettings, - applyHide = false, + settings: ContentFilterSettings, + applyHide = false, ): Source[] { const map = new Map(); for (const s of sources) { if (s.id === "0") continue; if (applyHide && shouldHideSource(s, settings)) continue; const existing = map.get(s.name); - if (!existing) { map.set(s.name, s); continue; } + if (!existing) {map.set(s.name, s); continue;} const existingPref = existing.lang === preferredLang; - const newPref = s.lang === preferredLang; + const newPref = s.lang === preferredLang; if (newPref && !existingPref) map.set(s.name, s); else if (!existingPref && !newPref && s.lang < existing.lang) map.set(s.name, s); } @@ -159,36 +159,36 @@ function authorFingerprint(author?: string | null, artist?: string | null): stri } export function dedupeMangaByTitle(items: T[], links: Record = {}): T[] { - const byTitle = new Map(); - const byDesc = new Map(); + const byTitle = new Map(); + const byDesc = new Map(); const byAuthorDesc = new Map(); - const byId = new Map(); - const out: T[] = []; + const byId = new Map(); + const out: T[] = []; for (const m of items) { const tk = normalizeTitle(m.title); const dk = descFingerprint(m.description); const ak = (dk && m.author) ? `${authorFingerprint(m.author, m.artist)}||${dk}` : null; - const linkedIds = links[m.id] ?? []; - const linkedIdx = linkedIds.map(lid => byId.get(lid)).find(i => i !== undefined); + const linkedIds = links[m.id] ?? []; + const linkedIdx = linkedIds.map(lid => byId.get(lid)).find(i => i !== undefined); const existingIdx = linkedIdx ?? byTitle.get(tk) ?? - (dk ? byDesc.get(dk) : undefined) ?? + (dk ? byDesc.get(dk) : undefined) ?? (ak ? byAuthorDesc.get(ak) : undefined); if (existingIdx !== undefined) { const existing = out[existingIdx]; - const mBetter = + const mBetter = (m.inLibrary && !existing.inLibrary) || (!existing.inLibrary && (m.downloadCount ?? 0) > (existing.downloadCount ?? 0)); @@ -213,11 +213,11 @@ export function dedupeMangaByTitle(items: T[]): T[] { +export function dedupeMangaById(items: T[]): T[] { const seen = new Set(); const out: T[] = []; for (const m of items) { - if (!seen.has(m.id)) { seen.add(m.id); out.push(m); } + if (!seen.has(m.id)) {seen.add(m.id); out.push(m);} } return out; } \ No newline at end of file diff --git a/src/lib/state/settings.svelte.ts b/src/lib/state/settings.svelte.ts index e46dd86..8ecd4e6 100644 --- a/src/lib/state/settings.svelte.ts +++ b/src/lib/state/settings.svelte.ts @@ -3,7 +3,7 @@ import {DEFAULT_KEYBINDS} from '$lib/core/keybinds/defaultBinds'; import {savePersistentState, loadPersistentState} from '$lib/core/persistence/persist'; import {applyTheme} from '$lib/core/theme'; import {applyZoom} from '$lib/core/ui/zoom'; -import {DEFAULT_SETTINGS, DEFAULT_MANGA_PREFS, type MangaPrefs, type Settings} from '$lib/types/settings'; +import {DEFAULT_AUTOMATION_DEFAULTS, DEFAULT_SETTINGS, DEFAULT_MANGA_PREFS, type MangaPrefs, type Settings} from '$lib/types/settings'; const SETTINGS_STORAGE_KEY = 'settings'; const SETTINGS_STORE_VERSION = 1; @@ -34,6 +34,7 @@ function mergeSettings(saved: Partial | null | undefined): Settings { mangaReaderSettings: saved?.mangaReaderSettings ?? {}, hiddenLibraryTabs: saved?.hiddenLibraryTabs ?? [], libraryPinnedTabOrder: saved?.libraryPinnedTabOrder ?? [], + automationDefaults: saved?.automationDefaults ?? DEFAULT_AUTOMATION_DEFAULTS, }; } diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts index 2b550d6..47ffcaa 100644 --- a/src/lib/types/index.ts +++ b/src/lib/types/index.ts @@ -8,14 +8,15 @@ import type { Page, DownloadItem, UpdateResult, -} from '$lib/server-adapters/types' -import type { Manga, Chapter, Extension, Source, Tracker } from '$lib/types' +} from '$lib/server-adapters/types'; +import type {Manga, Chapter, Extension, Source, Tracker} from '$lib/types'; +export type {Settings} from './settings'; // ─── GQL client ──────────────────────────────────────────────────────────── interface GQLResponse { - data: T - errors?: { message: string }[] + data: T; + errors?: {message: string;}[]; } // ─── Queries ──────────────────────────────────────────────────────────────── @@ -33,7 +34,7 @@ const GET_LIBRARY = ` } } } -` +`; const GET_MANGA = ` query GetManga($id: Int!) { @@ -46,7 +47,7 @@ const GET_MANGA = ` highestNumberedChapter { id chapterNumber } } } -` +`; const GET_CHAPTERS = ` query GetChapters($mangaId: Int!) { @@ -57,7 +58,7 @@ const GET_CHAPTERS = ` } } } -` +`; const GET_DOWNLOAD_STATUS = ` query GetDownloadStatus { @@ -72,7 +73,7 @@ const GET_DOWNLOAD_STATUS = ` } } } -` +`; const GET_EXTENSIONS = ` query GetExtensions { @@ -83,7 +84,7 @@ const GET_EXTENSIONS = ` } } } -` +`; const GET_SOURCES = ` query GetSources { @@ -94,7 +95,7 @@ const GET_SOURCES = ` } } } -` +`; const GET_TRACKERS = ` query GetTrackers { @@ -107,7 +108,7 @@ const GET_TRACKERS = ` } } } -` +`; // ─── Mutations ────────────────────────────────────────────────────────────── @@ -120,7 +121,7 @@ const FETCH_MANGA = ` } } } -` +`; const FETCH_SOURCE_MANGA = ` mutation FetchSourceManga($source: LongString!, $type: FetchSourceMangaType!, $page: Int!, $query: String) { @@ -129,7 +130,7 @@ const FETCH_SOURCE_MANGA = ` hasNextPage } } -` +`; const UPDATE_MANGA = ` mutation UpdateManga($id: Int!, $inLibrary: Boolean) { @@ -137,7 +138,7 @@ const UPDATE_MANGA = ` manga { id inLibrary } } } -` +`; const SET_MANGA_META = ` mutation SetMangaMeta($mangaId: Int!, $key: String!, $value: String!) { @@ -145,7 +146,7 @@ const SET_MANGA_META = ` meta { key value } } } -` +`; const FETCH_CHAPTERS = ` mutation FetchChapters($mangaId: Int!) { @@ -156,13 +157,13 @@ const FETCH_CHAPTERS = ` } } } -` +`; const FETCH_CHAPTER_PAGES = ` mutation FetchChapterPages($chapterId: Int!) { fetchChapterPages(input: { chapterId: $chapterId }) { pages } } -` +`; const MARK_CHAPTER_READ = ` mutation MarkChapterRead($id: Int!, $isRead: Boolean!) { @@ -170,7 +171,7 @@ const MARK_CHAPTER_READ = ` chapter { id isRead } } } -` +`; const MARK_CHAPTERS_READ = ` mutation MarkChaptersRead($ids: [Int!]!, $isRead: Boolean!) { @@ -178,7 +179,7 @@ const MARK_CHAPTERS_READ = ` chapters { id isRead } } } -` +`; const ENQUEUE_DOWNLOAD = ` mutation EnqueueDownload($chapterId: Int!) { @@ -186,7 +187,7 @@ const ENQUEUE_DOWNLOAD = ` downloadStatus { state } } } -` +`; const DEQUEUE_DOWNLOAD = ` mutation DequeueDownload($chapterId: Int!) { @@ -194,7 +195,7 @@ const DEQUEUE_DOWNLOAD = ` downloadStatus { state } } } -` +`; const CLEAR_DOWNLOADER = ` mutation ClearDownloader { @@ -202,7 +203,7 @@ const CLEAR_DOWNLOADER = ` downloadStatus { state } } } -` +`; const FETCH_EXTENSIONS = ` mutation FetchExtensions { @@ -213,7 +214,7 @@ const FETCH_EXTENSIONS = ` } } } -` +`; const UPDATE_EXTENSION = ` mutation UpdateExtension($id: String!, $install: Boolean, $uninstall: Boolean, $update: Boolean) { @@ -221,7 +222,7 @@ const UPDATE_EXTENSION = ` extension { apkName pkgName name isInstalled hasUpdate } } } -` +`; const BIND_TRACK = ` mutation BindTrack($mangaId: Int!, $trackerId: Int!, $remoteId: LongString!) { @@ -229,7 +230,7 @@ const BIND_TRACK = ` trackRecord { id trackerId remoteId } } } -` +`; const TRACK_PROGRESS = ` mutation TrackProgress($mangaId: Int!) { @@ -237,7 +238,7 @@ const TRACK_PROGRESS = ` trackRecords { id trackerId lastChapterRead status } } } -` +`; const UPDATE_LIBRARY = ` mutation UpdateLibrary { @@ -245,7 +246,7 @@ const UPDATE_LIBRARY = ` updateStatus { jobsInfo { isRunning finishedJobs totalJobs } } } } -` +`; // ─── Mappers ──────────────────────────────────────────────────────────────── @@ -267,11 +268,11 @@ function mapChapter(raw: Record): Chapter { lastReadAt: raw.lastReadAt as string | undefined, scanlator: raw.scanlator as string | null | undefined, manga: raw.manga as Chapter['manga'], - } + }; } function mapManga(raw: Record): Manga { - const inLibraryAt = raw.inLibraryAt as string | null | undefined + const inLibraryAt = raw.inLibraryAt as string | null | undefined; return { ...(raw as unknown as Manga), tags: raw.genre as string[] | undefined, @@ -279,19 +280,19 @@ function mapManga(raw: Record): Manga { lastReadAt: raw.lastReadChapter ? Date.now() : undefined, - } + }; } function mapExtension(raw: Record): Extension { return { ...(raw as unknown as Extension), id: raw.pkgName as string, - } + }; } function mapDownloadItem(raw: Record): DownloadItem { - const chapter = raw.chapter as Record - const manga = chapter?.manga as Record + const chapter = raw.chapter as Record; + const manga = chapter?.manga as Record; return { chapterId: String(chapter?.id), mangaId: String(chapter?.mangaId ?? manga?.id), @@ -299,29 +300,29 @@ function mapDownloadItem(raw: Record): DownloadItem { mangaTitle: manga?.title as string, progress: (raw.progress as number) ?? 0, state: mapDownloadState(raw.state as string), - } + }; } function mapDownloadState(state: string): DownloadItem['state'] { switch (state) { - case 'DOWNLOADING': return 'downloading' - case 'FINISHED': return 'finished' - case 'ERROR': return 'error' - default: return 'queued' + case 'DOWNLOADING': return 'downloading'; + case 'FINISHED': return 'finished'; + case 'ERROR': return 'error'; + default: return 'queued'; } } // ─── Adapter ──────────────────────────────────────────────────────────────── export class SuwayomiAdapter implements ServerAdapter { - private baseUrl = 'http://127.0.0.1:4567' - private authHeader: string | null = null + private baseUrl = 'http://127.0.0.1:4567'; + private authHeader: string | null = null; async connect(config: ServerConfig) { - this.baseUrl = config.baseUrl.replace(/\/$/, '') + this.baseUrl = config.baseUrl.replace(/\/$/, ''); if (config.credentials) { - const { username, password } = config.credentials - this.authHeader = 'Basic ' + btoa(`${username}:${password}`) + const {username, password} = config.credentials; + this.authHeader = 'Basic ' + btoa(`${username}:${password}`); } } @@ -330,182 +331,182 @@ export class SuwayomiAdapter implements ServerAdapter { const res = await fetch(`${this.baseUrl}/api/graphql`, { method: 'POST', headers: this.headers(), - body: JSON.stringify({ query: '{ aboutServer { name } }' }), - }) - return res.ok ? 'connected' : 'error' + body: JSON.stringify({query: '{ aboutServer { name } }'}), + }); + return res.ok ? 'connected' : 'error'; } catch { - return 'disconnected' + return 'disconnected'; } } private headers(): Record { - const h: Record = { 'Content-Type': 'application/json' } - if (this.authHeader) h['Authorization'] = this.authHeader - return h + const h: Record = {'Content-Type': 'application/json'}; + if (this.authHeader) h['Authorization'] = this.authHeader; + return h; } private async gql(query: string, variables?: Record): Promise { const res = await fetch(`${this.baseUrl}/api/graphql`, { method: 'POST', headers: this.headers(), - body: JSON.stringify({ query, variables }), - }) - if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`) - const json: GQLResponse = await res.json() - if (json.errors?.length) throw new Error(json.errors[0].message) - return json.data + body: JSON.stringify({query, variables}), + }); + if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`); + const json: GQLResponse = await res.json(); + if (json.errors?.length) throw new Error(json.errors[0].message); + return json.data; } // ── Manga ────────────────────────────────────────────────────────────── async getManga(id: string): Promise { - const data = await this.gql<{ manga: Record }>( - GET_MANGA, { id: Number(id) } - ) - return mapManga(data.manga) + const data = await this.gql<{manga: Record;}>( + GET_MANGA, {id: Number(id)} + ); + return mapManga(data.manga); } async getMangaList(filters: MangaFilters): Promise> { if (filters.inLibrary) { - const data = await this.gql<{ mangas: { nodes: Record[] } }>(GET_LIBRARY) - return { items: data.mangas.nodes.map(mapManga), hasNextPage: false } + const data = await this.gql<{mangas: {nodes: Record[];};}>(GET_LIBRARY); + return {items: data.mangas.nodes.map(mapManga), hasNextPage: false}; } - const data = await this.gql<{ mangas: { nodes: Record[] } }>(GET_LIBRARY) - return { items: data.mangas.nodes.map(mapManga), hasNextPage: false } + const data = await this.gql<{mangas: {nodes: Record[];};}>(GET_LIBRARY); + return {items: data.mangas.nodes.map(mapManga), hasNextPage: false}; } async searchManga(query: string, sourceId?: string): Promise { - if (!sourceId) return [] + if (!sourceId) return []; const data = await this.gql<{ - fetchSourceManga: { mangas: Record[] } + fetchSourceManga: {mangas: Record[];}; }>(FETCH_SOURCE_MANGA, { source: sourceId, type: 'SEARCH', page: 1, query, - }) - return data.fetchSourceManga.mangas.map(mapManga) + }); + return data.fetchSourceManga.mangas.map(mapManga); } async addToLibrary(mangaId: string) { - await this.gql(UPDATE_MANGA, { id: Number(mangaId), inLibrary: true }) + await this.gql(UPDATE_MANGA, {id: Number(mangaId), inLibrary: true}); } async removeFromLibrary(mangaId: string) { - await this.gql(UPDATE_MANGA, { id: Number(mangaId), inLibrary: false }) + await this.gql(UPDATE_MANGA, {id: Number(mangaId), inLibrary: false}); } async updateMangaMeta(id: string, meta: Partial) { for (const [key, value] of Object.entries(meta)) { - if (value === undefined) continue + if (value === undefined) continue; await this.gql(SET_MANGA_META, { mangaId: Number(id), key, value: String(value), - }) + }); } } // ── Chapters ─────────────────────────────────────────────────────────── async getChapters(mangaId: string): Promise { - const data = await this.gql<{ chapters: { nodes: Record[] } }>( - GET_CHAPTERS, { mangaId: Number(mangaId) } - ) - return data.chapters.nodes.map(mapChapter) + const data = await this.gql<{chapters: {nodes: Record[];};}>( + GET_CHAPTERS, {mangaId: Number(mangaId)} + ); + return data.chapters.nodes.map(mapChapter); } async getChapter(id: string): Promise { - const chapters = await this.gql<{ chapters: { nodes: Record[] } }>( - GET_CHAPTERS, { mangaId: 0 } - ) - const found = chapters.chapters.nodes.find(c => String(c.id) === id) - if (!found) throw new Error(`Chapter ${id} not found`) - return mapChapter(found) + const chapters = await this.gql<{chapters: {nodes: Record[];};}>( + GET_CHAPTERS, {mangaId: 0} + ); + const found = chapters.chapters.nodes.find(c => String(c.id) === id); + if (!found) throw new Error(`Chapter ${id} not found`); + return mapChapter(found); } async getChapterPages(id: string): Promise { - const data = await this.gql<{ fetchChapterPages: { pages: string[] } }>( - FETCH_CHAPTER_PAGES, { chapterId: Number(id) } - ) - return data.fetchChapterPages.pages.map((url, index) => ({ index, url })) + const data = await this.gql<{fetchChapterPages: {pages: string[];};}>( + FETCH_CHAPTER_PAGES, {chapterId: Number(id)} + ); + return data.fetchChapterPages.pages.map((url, index) => ({index, url})); } async markChapterRead(id: string, read: boolean) { - await this.gql(MARK_CHAPTER_READ, { id: Number(id), isRead: read }) + await this.gql(MARK_CHAPTER_READ, {id: Number(id), isRead: read}); } async markChaptersRead(ids: string[], read: boolean) { - await this.gql(MARK_CHAPTERS_READ, { ids: ids.map(Number), isRead: read }) + await this.gql(MARK_CHAPTERS_READ, {ids: ids.map(Number), isRead: read}); } // ── Downloads ────────────────────────────────────────────────────────── async getDownloads(): Promise { const data = await this.gql<{ - downloadStatus: { queue: Record[] } - }>(GET_DOWNLOAD_STATUS) - return data.downloadStatus.queue.map(mapDownloadItem) + downloadStatus: {queue: Record[];}; + }>(GET_DOWNLOAD_STATUS); + return data.downloadStatus.queue.map(mapDownloadItem); } async enqueueDownload(chapterId: string) { - await this.gql(ENQUEUE_DOWNLOAD, { chapterId: Number(chapterId) }) + await this.gql(ENQUEUE_DOWNLOAD, {chapterId: Number(chapterId)}); } async dequeueDownload(chapterId: string) { - await this.gql(DEQUEUE_DOWNLOAD, { chapterId: Number(chapterId) }) + await this.gql(DEQUEUE_DOWNLOAD, {chapterId: Number(chapterId)}); } async clearDownloads() { - await this.gql(CLEAR_DOWNLOADER) + await this.gql(CLEAR_DOWNLOADER); } // ── Extensions ───────────────────────────────────────────────────────── async getExtensions(): Promise { - await this.gql(FETCH_EXTENSIONS) - const data = await this.gql<{ extensions: { nodes: Record[] } }>( + await this.gql(FETCH_EXTENSIONS); + const data = await this.gql<{extensions: {nodes: Record[];};}>( GET_EXTENSIONS - ) - return data.extensions.nodes.map(mapExtension) + ); + return data.extensions.nodes.map(mapExtension); } async installExtension(id: string) { - await this.gql(UPDATE_EXTENSION, { id, install: true }) + await this.gql(UPDATE_EXTENSION, {id, install: true}); } async uninstallExtension(id: string) { - await this.gql(UPDATE_EXTENSION, { id, uninstall: true }) + await this.gql(UPDATE_EXTENSION, {id, uninstall: true}); } async updateExtension(id: string) { - await this.gql(UPDATE_EXTENSION, { id, update: true }) + await this.gql(UPDATE_EXTENSION, {id, update: true}); } async getSources(): Promise { - const data = await this.gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) - return data.sources.nodes + const data = await this.gql<{sources: {nodes: Source[];};}>(GET_SOURCES); + return data.sources.nodes; } async browseSource(sourceId: string, page: number): Promise> { const data = await this.gql<{ - fetchSourceManga: { mangas: Record[]; hasNextPage: boolean } + fetchSourceManga: {mangas: Record[]; hasNextPage: boolean;}; }>(FETCH_SOURCE_MANGA, { source: sourceId, type: 'LATEST', page, - }) + }); return { items: data.fetchSourceManga.mangas.map(mapManga), hasNextPage: data.fetchSourceManga.hasNextPage, - } + }; } // ── Tracking ─────────────────────────────────────────────────────────── async getTrackers(): Promise { - const data = await this.gql<{ trackers: { nodes: Tracker[] } }>(GET_TRACKERS) - return data.trackers.nodes + const data = await this.gql<{trackers: {nodes: Tracker[];};}>(GET_TRACKERS); + return data.trackers.nodes; } async linkTracker(mangaId: string, trackerId: string, remoteId: string) { @@ -513,27 +514,27 @@ export class SuwayomiAdapter implements ServerAdapter { mangaId: Number(mangaId), trackerId: Number(trackerId), remoteId, - }) + }); } async syncTracking(mangaId: string) { - await this.gql(TRACK_PROGRESS, { mangaId: Number(mangaId) }) + await this.gql(TRACK_PROGRESS, {mangaId: Number(mangaId)}); } // ── Updates ──────────────────────────────────────────────────────────── async checkForUpdates(mangaIds?: string[]): Promise { if (mangaIds?.length) { - const results: UpdateResult[] = [] + const results: UpdateResult[] = []; for (const id of mangaIds) { - const before = await this.getChapters(id) - await this.gql(FETCH_CHAPTERS, { mangaId: Number(id) }) - const after = await this.getChapters(id) - results.push({ mangaId: id, newChapters: after.length - before.length }) + const before = await this.getChapters(id); + await this.gql(FETCH_CHAPTERS, {mangaId: Number(id)}); + const after = await this.getChapters(id); + results.push({mangaId: id, newChapters: after.length - before.length}); } - return results + return results; } - await this.gql(UPDATE_LIBRARY) - return [] + await this.gql(UPDATE_LIBRARY); + return []; } } diff --git a/src/lib/types/settings.ts b/src/lib/types/settings.ts index 2016bd7..94d5176 100644 --- a/src/lib/types/settings.ts +++ b/src/lib/types/settings.ts @@ -1,12 +1,13 @@ -import type { Keybinds } from "$lib/core/keybinds/defaultBinds"; +import type {Keybinds} from "$lib/core/keybinds/defaultBinds"; -export type PageStyle = "single" | "double" | "longstrip"; -export type FitMode = "width" | "height" | "screen" | "original"; -export type LibraryFilter = "all" | "library" | "downloaded" | string; +export type PageStyle = "single" | "double" | "longstrip"; +export type FitMode = "width" | "height" | "screen" | "original"; +export type LibraryFilter = "all" | "library" | "downloaded" | string; export type ReadingDirection = "ltr" | "rtl"; -export type ChapterSortDir = "desc" | "asc"; -export type ChapterSortMode = "source" | "chapterNumber" | "uploadDate"; -export type ContentLevel = "strict" | "moderate" | "unrestricted"; +export type ChapterSortDir = "desc" | "asc"; +export type ChapterSortMode = "source" | "chapterNumber" | "uploadDate"; +export type ContentLevel = "strict" | "moderate" | "unrestricted"; +export type CloseAction = "ask" | "tray" | "quit"; export type LibrarySortMode = | "az" | "unreadCount" | "totalChapters" @@ -14,11 +15,11 @@ export type LibrarySortMode = export type LibrarySortDir = "asc" | "desc"; -export type LibraryStatusFilter = "ALL" | "ONGOING" | "COMPLETED" | "CANCELLED" | "HIATUS" | "UNKNOWN"; +export type LibraryStatusFilter = "ALL" | "ONGOING" | "COMPLETED" | "CANCELLED" | "HIATUS" | "UNKNOWN"; export type LibraryContentFilter = "unread" | "started" | "downloaded" | "bookmarked" | "marked"; export type BuiltinTheme = "original" | "dark" | "light" | "light-contrast" | "midnight" | "warm"; -export type Theme = BuiltinTheme | string; +export type Theme = BuiltinTheme | string; export interface ThemeTokens { "bg-void": string; @@ -98,6 +99,16 @@ export interface MangaPrefs { coverUrl?: string; } +export interface AutomationDefaults { + autoDownload: boolean; + downloadAhead: number; + deleteOnRead: boolean; + deleteDelayHours: number; + maxKeepChapters: number; + pauseUpdates: boolean; + refreshInterval: "daily" | "weekly" | "manual"; +} + export const DEFAULT_MANGA_PREFS: MangaPrefs = { autoDownload: false, downloadAhead: 0, @@ -113,20 +124,30 @@ export const DEFAULT_MANGA_PREFS: MangaPrefs = { autoDownloadScanlators: [], }; +export const DEFAULT_AUTOMATION_DEFAULTS: AutomationDefaults = { + autoDownload: false, + downloadAhead: 0, + deleteOnRead: false, + deleteDelayHours: 0, + maxKeepChapters: 0, + pauseUpdates: false, + refreshInterval: "weekly", +}; + export interface ReaderSettings { - pageStyle: PageStyle; - fitMode: FitMode; - readingDirection: ReadingDirection; - readerZoom: number; - pageGap: boolean; - optimizeContrast: boolean; + pageStyle: PageStyle; + fitMode: FitMode; + readingDirection: ReadingDirection; + readerZoom: number; + pageGap: boolean; + optimizeContrast: boolean; offsetDoubleSpreads: boolean; - barPosition?: "top" | "left" | "right"; + barPosition?: "top" | "left" | "right"; } export interface ReaderPreset { - id: string; - name: string; + id: string; + name: string; settings: ReaderSettings; } @@ -135,6 +156,8 @@ export interface Settings { readingDirection: ReadingDirection; fitMode: FitMode; readerZoom: number; + overlayBars: boolean; + tapToToggleBar: boolean; pageGap: boolean; optimizeContrast: boolean; offsetDoubleSpreads: boolean; @@ -147,6 +170,8 @@ export interface Settings { sourceOverridesEnabled: boolean; nsfwAllowedSourceIds: string[]; nsfwBlockedSourceIds: string[]; + libraryShowAllInSaved: boolean; + libraryHideCompletedInSaved: boolean; discordRpc: boolean; chapterSortDir: ChapterSortDir; chapterSortMode: ChapterSortMode; @@ -154,6 +179,7 @@ export interface Settings { uiZoom: number; compactSidebar: boolean; gpuAcceleration: boolean; + closeAction: CloseAction; serverUrl: string; serverBinary: string; serverBinaryArgs: string; @@ -168,6 +194,9 @@ export interface Settings { readerDebounceMs: number; autoBookmark: boolean; theme: Theme; + systemThemeSync: boolean; + systemThemeDark: Theme; + systemThemeLight: Theme; libraryBranches: boolean; renderLimit: number; heroSlots: (number | null)[]; @@ -194,7 +223,7 @@ export interface Settings { hiddenCategoryIds: number[]; defaultLibraryCategoryId: number | null; savedIsDefaultCategory: boolean; - libraryTabSort: Record; + libraryTabSort: Record; libraryTabStatus: Record; libraryTabFilters: Record>>; maxPageWidth?: number; @@ -220,6 +249,9 @@ export interface Settings { autoScroll?: boolean; autoScrollSpeed?: number; disableAutoComplete: boolean; + automationEnabled: boolean; + automationEnforceGlobal: boolean; + automationDefaults: AutomationDefaults; } export const DEFAULT_SETTINGS: Settings = { @@ -227,6 +259,8 @@ export const DEFAULT_SETTINGS: Settings = { readingDirection: "ltr", fitMode: "width", readerZoom: 1.0, + overlayBars: false, + tapToToggleBar: false, pageGap: true, optimizeContrast: false, offsetDoubleSpreads: false, @@ -239,6 +273,8 @@ export const DEFAULT_SETTINGS: Settings = { sourceOverridesEnabled: false, nsfwAllowedSourceIds: [], nsfwBlockedSourceIds: [], + libraryShowAllInSaved: true, + libraryHideCompletedInSaved: false, discordRpc: false, chapterSortDir: "desc", chapterSortMode: "source", @@ -246,6 +282,7 @@ export const DEFAULT_SETTINGS: Settings = { uiZoom: 1.0, compactSidebar: false, gpuAcceleration: true, + closeAction: "ask", serverUrl: "http://localhost:4567", serverBinary: "", serverBinaryArgs: "", @@ -260,6 +297,9 @@ export const DEFAULT_SETTINGS: Settings = { readerDebounceMs: 120, autoBookmark: true, theme: "dark", + systemThemeSync: false, + systemThemeDark: "dark", + systemThemeLight: "light", libraryBranches: true, renderLimit: 48, heroSlots: [null, null, null, null], @@ -309,4 +349,7 @@ export const DEFAULT_SETTINGS: Settings = { autoScroll: false, autoScrollSpeed: 5, disableAutoComplete: false, + automationEnabled: false, + automationEnforceGlobal: false, + automationDefaults: DEFAULT_AUTOMATION_DEFAULTS, }; diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 28f776e..2c50769 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -1,7 +1,7 @@ + +
+ + +
+ {@render children()} +
+
+ + \ No newline at end of file diff --git a/src/routes/settings/+page.ts b/src/routes/settings/+page.ts new file mode 100644 index 0000000..1ad823c --- /dev/null +++ b/src/routes/settings/+page.ts @@ -0,0 +1,5 @@ +import {redirect} from '@sveltejs/kit'; + +export function load() { + throw redirect(302, '/settings/general'); +} \ No newline at end of file diff --git a/src/routes/settings/about/+page.svelte b/src/routes/settings/about/+page.svelte new file mode 100644 index 0000000..1df898d --- /dev/null +++ b/src/routes/settings/about/+page.svelte @@ -0,0 +1,46 @@ + + + + Settings - About + + +
+
+

About

+

Build and app information

+

Static app details and a quick summary of the connected server.

+
+ +
+
+
+
Moku
+
Version {appVersion}
+
+
+ +
+
+
Server URL
+
{settingsState.serverUrl}
+
+
+
Tracker count
+
{trackingState.trackers.length} trackers loaded
+
+
+ +
+
+
Project
+
A manga reader frontend for Suwayomi / Tachidesk.
+
+
+
+
\ No newline at end of file diff --git a/src/routes/settings/appearance/+page.svelte b/src/routes/settings/appearance/+page.svelte new file mode 100644 index 0000000..a3d2541 --- /dev/null +++ b/src/routes/settings/appearance/+page.svelte @@ -0,0 +1,91 @@ + + + + Settings - Appearance + + +
+
+

Appearance

+

Theme and color behavior

+

Choose the app theme and optional system theme sync.

+
+ +
+ + + {#if settingsState.systemThemeSync} +
+ + +
+ {/if} + +
+ {#each builtinThemes as [id, label, bg, surface, accent]} + + {/each} + + {#each settingsState.customThemes as theme} + + {/each} +
+
+
\ No newline at end of file diff --git a/src/routes/settings/automation/+page.svelte b/src/routes/settings/automation/+page.svelte new file mode 100644 index 0000000..9a9c9bd --- /dev/null +++ b/src/routes/settings/automation/+page.svelte @@ -0,0 +1,127 @@ + + + + Settings - Automation + + +
+
+

Automation

+

Series automation defaults

+

These values are used when a manga has no per-series override.

+
+ +
+ + + + + {#if settingsState.automationEnforceGlobal} +
+
+
Per-series overrides paused
+
Disable enforce to allow individual manga preferences again.
+
+
+ {/if} + +
+ + + +
+ +
+ + + +
+ + + + + +
+
+
Refresh interval
+
How often a series is checked for new chapters.
+
+ +
+ +
+
+
Stored custom manga preferences
+
{Object.keys(settingsState.mangaPrefs).length} manga records currently have custom prefs.
+
+
+
+
\ No newline at end of file diff --git a/src/routes/settings/content/+page.svelte b/src/routes/settings/content/+page.svelte new file mode 100644 index 0000000..9ae12f3 --- /dev/null +++ b/src/routes/settings/content/+page.svelte @@ -0,0 +1,64 @@ + + + + Settings - Content + + +
+
+

Content

+

Content filtering and source overrides

+

Control the overall content level and any per-source exceptions.

+
+ +
+
+ + + +
+ + {#if settingsState.sourceOverridesEnabled} + + + + {/if} +
+
\ No newline at end of file diff --git a/src/routes/settings/devtools/+page.svelte b/src/routes/settings/devtools/+page.svelte new file mode 100644 index 0000000..e7589c5 --- /dev/null +++ b/src/routes/settings/devtools/+page.svelte @@ -0,0 +1,39 @@ + + + + Settings - Devtools + + +
+
+

Devtools

+

Diagnostics and reset tools

+

Basic internal state summaries and a safe settings reset button.

+
+ +
+
+
+
Custom themes
+
{themeCount} stored theme definitions
+
+
+
Custom manga prefs
+
{prefsCount} manga entries have overrides
+
+
+ +
+
+
Reset all settings
+
Restore the entire settings object to defaults.
+
+ +
+
+
\ No newline at end of file diff --git a/src/routes/settings/folders/+page.svelte b/src/routes/settings/folders/+page.svelte new file mode 100644 index 0000000..9642849 --- /dev/null +++ b/src/routes/settings/folders/+page.svelte @@ -0,0 +1,57 @@ + + + + Settings - Folders + + +
+
+

Folders

+

Library folder organization

+

Use simple comma-separated controls to keep tab order and visibility direct.

+
+ +
+ + +
+ + + +
+ + + + +
+
\ No newline at end of file diff --git a/src/routes/settings/general/+page.svelte b/src/routes/settings/general/+page.svelte new file mode 100644 index 0000000..6897c8e --- /dev/null +++ b/src/routes/settings/general/+page.svelte @@ -0,0 +1,163 @@ + + + + Settings - General + + +
+
+

General

+

Application basics

+

Core behavior, server connection, and desktop shell preferences.

+
+ +
+
+
+
Interface scale
+
Scale the whole app UI.
+
+
+ updateSettings({uiZoom: Number((event.currentTarget as HTMLInputElement).value) / 100})} + /> + updateSettings({uiZoom: Number((event.currentTarget as HTMLInputElement).value) / 100})} + /> + +
+
+ +
+
+
Server URL
+
Base URL for the Suwayomi server.
+
+ updateSettings({serverUrl: (event.currentTarget as HTMLInputElement).value})} + /> +
+ + + + + +
+
+
+
Advanced server options
+
Custom binary path and launch args.
+
+ +
+ {#if advancedOpen} +
+ + +
+ {/if} +
+ +
+
+
Idle screen timeout
+
Show the splash screen after inactivity.
+
+ +
+ +
+
+
Close button behavior
+
Choose what the window close button does.
+
+ +
+ + + + + +
+
+
Preferred source language
+
Used for search defaults and source sorting.
+
+ updateSettings({preferredExtensionLang: (event.currentTarget as HTMLInputElement).value.trim().toLowerCase()})} + /> +
+
+
\ No newline at end of file diff --git a/src/routes/settings/keybinds/+page.svelte b/src/routes/settings/keybinds/+page.svelte new file mode 100644 index 0000000..79a0450 --- /dev/null +++ b/src/routes/settings/keybinds/+page.svelte @@ -0,0 +1,87 @@ + + + + Settings - Keybinds + + +
+
+

Keybinds

+

Keyboard shortcuts

+

Click a binding and press the shortcut you want to use.

+
+ +
+
+
+
Shortcut bindings
+
Reset any binding individually or all at once.
+
+ +
+ + {#each Object.keys(KEYBIND_LABELS) as key} + {@const bindKey = key as keyof Keybinds} + {@const isListening = listeningKey === bindKey} + {@const isDefault = settingsState.keybinds[bindKey] === DEFAULT_KEYBINDS[bindKey]} +
+
+
{KEYBIND_LABELS[bindKey]}
+
+
+ + +
+
+ {/each} +
+
+ + \ No newline at end of file diff --git a/src/routes/settings/library/+page.svelte b/src/routes/settings/library/+page.svelte new file mode 100644 index 0000000..9ef54be --- /dev/null +++ b/src/routes/settings/library/+page.svelte @@ -0,0 +1,114 @@ + + + + Settings - Library + + +
+
+

Library

+

Library display and sorting

+

How manga cards and chapter lists are shown in the library.

+
+ +
+ + + + + + + {#if settingsState.libraryShowAllInSaved} + + {/if} + +
+ + + +
+ +
+ + + +
+ + + + +
+
\ No newline at end of file diff --git a/src/routes/settings/performance/+page.svelte b/src/routes/settings/performance/+page.svelte new file mode 100644 index 0000000..998c35d --- /dev/null +++ b/src/routes/settings/performance/+page.svelte @@ -0,0 +1,63 @@ + + + + Settings - Performance + + +
+
+

Performance

+

Render and behavior tuning

+

Keep the app light or turn up quality-of-life options.

+
+ +
+
+ + + +
+ +
+ + + +
+ + + + +
+
\ No newline at end of file diff --git a/src/routes/settings/reader/+page.svelte b/src/routes/settings/reader/+page.svelte new file mode 100644 index 0000000..36f1113 --- /dev/null +++ b/src/routes/settings/reader/+page.svelte @@ -0,0 +1,160 @@ + + + + Settings - Reader + + +
+
+

Reader

+

Reading defaults

+

Behavior and layout for the full-screen reader.

+
+ +
+
+ + + +
+ +
+ + + +
+ + + + + + + + + + + + + + {#if !settingsState.autoNextChapter} + + {/if} + + + +
+
+
Pages to preload
+
How many pages ahead to fetch in the background.
+
+ updateSettings({preloadPages: Math.max(0, Math.min(10, Number((event.currentTarget as HTMLInputElement).value) || 0))})} + /> +
+
+
\ No newline at end of file diff --git a/src/routes/settings/security/+page.svelte b/src/routes/settings/security/+page.svelte new file mode 100644 index 0000000..69fac97 --- /dev/null +++ b/src/routes/settings/security/+page.svelte @@ -0,0 +1,149 @@ + + + + Settings - Security + + +
+
+

Security

+

Server access and proxy settings

+

Authentication, SOCKS proxy, FlareSolverr, and app lock options.

+
+ +
+
+ + + +
+ +
+ + + +
+ + + + + + {#if settingsState.socksProxyEnabled} +
+ + +
+ +
+ + +
+ + + {/if} + + + + {#if settingsState.flareSolverrEnabled} +
+ + +
+ +
+ + +
+ + + {/if} +
+
\ No newline at end of file diff --git a/src/routes/settings/storage/+page.svelte b/src/routes/settings/storage/+page.svelte new file mode 100644 index 0000000..325e593 --- /dev/null +++ b/src/routes/settings/storage/+page.svelte @@ -0,0 +1,37 @@ + + + + Settings - Storage + + +
+
+

Storage

+

Paths and limits

+

Control where Moku stores downloads and local sources.

+
+ +
+
+
+
Storage limit
+
Maximum local storage in gigabytes. Leave blank for no limit.
+
+ { const value = (event.currentTarget as HTMLInputElement).value; updateSettings({storageLimitGb: value === '' ? null : Number(value)}); }} /> +
+ +
+ + + +
+
+
\ No newline at end of file diff --git a/src/routes/settings/tracking/+page.svelte b/src/routes/settings/tracking/+page.svelte new file mode 100644 index 0000000..6cb14cc --- /dev/null +++ b/src/routes/settings/tracking/+page.svelte @@ -0,0 +1,264 @@ + + + + Settings - Tracking + + +
+
+

Tracking

+

Tracker connections

+

Connect trackers and sync progress back to your library.

+
+ +
+
+
+
Connected trackers
+
{trackingState.loading ? 'Loading…' : `${trackingState.trackers.length} trackers found`}
+
+ +
+ + {#each trackingState.trackers as tracker} +
+
+
{tracker.name}
+
{tracker.isLoggedIn ? 'Connected' : 'Not connected'}{tracker.isTokenExpired ? ' · token expired' : ''}
+
+
+ {#if tracker.isLoggedIn} + + {:else} + + {/if} +
+
+ + {#if oauthTrackerId === tracker.id} +
+
+
OAuth callback URL
+
Paste the callback URL after authorizing in the browser.
+
+ +
+ + +
+
+ {/if} + + {#if credsTrackerId === tracker.id} +
+
+
Tracker login
+
Use a username and password to connect.
+
+
+ + +
+
+ + +
+
+ {/if} + {/each} + +
+
+
Sync back now
+
Apply tracker progress to all linked manga in your library.
+
+ +
+
+
+ + \ No newline at end of file