Migrate remaining routes

This commit is contained in:
Zerebos
2026-05-23 17:15:02 -04:00
parent 68f25a2ea7
commit b3fca70f27
22 changed files with 2092 additions and 171 deletions
+38
View File
@@ -1,3 +1,4 @@
import {settingsState, updateSettings} from '$lib/state/settings.svelte';
import type {CustomTheme, Theme} from '$lib/types/settings'; import type {CustomTheme, Theme} from '$lib/types/settings';
let themeStyleEl: HTMLStyleElement | null = null; let themeStyleEl: HTMLStyleElement | null = null;
@@ -39,3 +40,40 @@ export function applyTheme(theme: Theme, customThemes: CustomTheme[] = []) {
ensureThemeStyleEl().textContent = `[data-theme="custom"] {\n${css}\n}`; ensureThemeStyleEl().textContent = `[data-theme="custom"] {\n${css}\n}`;
document.documentElement.setAttribute('data-theme', 'custom'); 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;
}
+2 -2
View File
@@ -1,5 +1,5 @@
import type {Manga, Source} from "$lib/types"; import type {Manga, Source} from "$lib/types";
import type { Settings } from "$lib/types"; import type {Settings} from "$lib/types/settings";
export {clsx as cn} from "clsx"; export {clsx as cn} from "clsx";
@@ -213,7 +213,7 @@ export function dedupeMangaByTitle<T extends {
return out; return out;
} }
export function dedupeMangaById<T extends { id: number }>(items: T[]): T[] { export function dedupeMangaById<T extends {id: number;}>(items: T[]): T[] {
const seen = new Set<number>(); const seen = new Set<number>();
const out: T[] = []; const out: T[] = [];
for (const m of items) { for (const m of items) {
+2 -1
View File
@@ -3,7 +3,7 @@ import {DEFAULT_KEYBINDS} from '$lib/core/keybinds/defaultBinds';
import {savePersistentState, loadPersistentState} from '$lib/core/persistence/persist'; import {savePersistentState, loadPersistentState} from '$lib/core/persistence/persist';
import {applyTheme} from '$lib/core/theme'; import {applyTheme} from '$lib/core/theme';
import {applyZoom} from '$lib/core/ui/zoom'; 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_STORAGE_KEY = 'settings';
const SETTINGS_STORE_VERSION = 1; const SETTINGS_STORE_VERSION = 1;
@@ -34,6 +34,7 @@ function mergeSettings(saved: Partial<Settings> | null | undefined): Settings {
mangaReaderSettings: saved?.mangaReaderSettings ?? {}, mangaReaderSettings: saved?.mangaReaderSettings ?? {},
hiddenLibraryTabs: saved?.hiddenLibraryTabs ?? [], hiddenLibraryTabs: saved?.hiddenLibraryTabs ?? [],
libraryPinnedTabOrder: saved?.libraryPinnedTabOrder ?? [], libraryPinnedTabOrder: saved?.libraryPinnedTabOrder ?? [],
automationDefaults: saved?.automationDefaults ?? DEFAULT_AUTOMATION_DEFAULTS,
}; };
} }
+113 -112
View File
@@ -8,14 +8,15 @@ import type {
Page, Page,
DownloadItem, DownloadItem,
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';
export type {Settings} from './settings';
// ─── GQL client ──────────────────────────────────────────────────────────── // ─── GQL client ────────────────────────────────────────────────────────────
interface GQLResponse<T> { interface GQLResponse<T> {
data: T data: T;
errors?: { message: string }[] errors?: {message: string;}[];
} }
// ─── Queries ──────────────────────────────────────────────────────────────── // ─── Queries ────────────────────────────────────────────────────────────────
@@ -33,7 +34,7 @@ const GET_LIBRARY = `
} }
} }
} }
` `;
const GET_MANGA = ` const GET_MANGA = `
query GetManga($id: Int!) { query GetManga($id: Int!) {
@@ -46,7 +47,7 @@ const GET_MANGA = `
highestNumberedChapter { id chapterNumber } highestNumberedChapter { id chapterNumber }
} }
} }
` `;
const GET_CHAPTERS = ` const GET_CHAPTERS = `
query GetChapters($mangaId: Int!) { query GetChapters($mangaId: Int!) {
@@ -57,7 +58,7 @@ const GET_CHAPTERS = `
} }
} }
} }
` `;
const GET_DOWNLOAD_STATUS = ` const GET_DOWNLOAD_STATUS = `
query GetDownloadStatus { query GetDownloadStatus {
@@ -72,7 +73,7 @@ const GET_DOWNLOAD_STATUS = `
} }
} }
} }
` `;
const GET_EXTENSIONS = ` const GET_EXTENSIONS = `
query GetExtensions { query GetExtensions {
@@ -83,7 +84,7 @@ const GET_EXTENSIONS = `
} }
} }
} }
` `;
const GET_SOURCES = ` const GET_SOURCES = `
query GetSources { query GetSources {
@@ -94,7 +95,7 @@ const GET_SOURCES = `
} }
} }
} }
` `;
const GET_TRACKERS = ` const GET_TRACKERS = `
query GetTrackers { query GetTrackers {
@@ -107,7 +108,7 @@ const GET_TRACKERS = `
} }
} }
} }
` `;
// ─── Mutations ────────────────────────────────────────────────────────────── // ─── Mutations ──────────────────────────────────────────────────────────────
@@ -120,7 +121,7 @@ const FETCH_MANGA = `
} }
} }
} }
` `;
const FETCH_SOURCE_MANGA = ` const FETCH_SOURCE_MANGA = `
mutation FetchSourceManga($source: LongString!, $type: FetchSourceMangaType!, $page: Int!, $query: String) { mutation FetchSourceManga($source: LongString!, $type: FetchSourceMangaType!, $page: Int!, $query: String) {
@@ -129,7 +130,7 @@ const FETCH_SOURCE_MANGA = `
hasNextPage hasNextPage
} }
} }
` `;
const UPDATE_MANGA = ` const UPDATE_MANGA = `
mutation UpdateManga($id: Int!, $inLibrary: Boolean) { mutation UpdateManga($id: Int!, $inLibrary: Boolean) {
@@ -137,7 +138,7 @@ const UPDATE_MANGA = `
manga { id inLibrary } manga { id inLibrary }
} }
} }
` `;
const SET_MANGA_META = ` const SET_MANGA_META = `
mutation SetMangaMeta($mangaId: Int!, $key: String!, $value: String!) { mutation SetMangaMeta($mangaId: Int!, $key: String!, $value: String!) {
@@ -145,7 +146,7 @@ const SET_MANGA_META = `
meta { key value } meta { key value }
} }
} }
` `;
const FETCH_CHAPTERS = ` const FETCH_CHAPTERS = `
mutation FetchChapters($mangaId: Int!) { mutation FetchChapters($mangaId: Int!) {
@@ -156,13 +157,13 @@ const FETCH_CHAPTERS = `
} }
} }
} }
` `;
const FETCH_CHAPTER_PAGES = ` const FETCH_CHAPTER_PAGES = `
mutation FetchChapterPages($chapterId: Int!) { mutation FetchChapterPages($chapterId: Int!) {
fetchChapterPages(input: { chapterId: $chapterId }) { pages } fetchChapterPages(input: { chapterId: $chapterId }) { pages }
} }
` `;
const MARK_CHAPTER_READ = ` const MARK_CHAPTER_READ = `
mutation MarkChapterRead($id: Int!, $isRead: Boolean!) { mutation MarkChapterRead($id: Int!, $isRead: Boolean!) {
@@ -170,7 +171,7 @@ const MARK_CHAPTER_READ = `
chapter { id isRead } chapter { id isRead }
} }
} }
` `;
const MARK_CHAPTERS_READ = ` const MARK_CHAPTERS_READ = `
mutation MarkChaptersRead($ids: [Int!]!, $isRead: Boolean!) { mutation MarkChaptersRead($ids: [Int!]!, $isRead: Boolean!) {
@@ -178,7 +179,7 @@ const MARK_CHAPTERS_READ = `
chapters { id isRead } chapters { id isRead }
} }
} }
` `;
const ENQUEUE_DOWNLOAD = ` const ENQUEUE_DOWNLOAD = `
mutation EnqueueDownload($chapterId: Int!) { mutation EnqueueDownload($chapterId: Int!) {
@@ -186,7 +187,7 @@ const ENQUEUE_DOWNLOAD = `
downloadStatus { state } downloadStatus { state }
} }
} }
` `;
const DEQUEUE_DOWNLOAD = ` const DEQUEUE_DOWNLOAD = `
mutation DequeueDownload($chapterId: Int!) { mutation DequeueDownload($chapterId: Int!) {
@@ -194,7 +195,7 @@ const DEQUEUE_DOWNLOAD = `
downloadStatus { state } downloadStatus { state }
} }
} }
` `;
const CLEAR_DOWNLOADER = ` const CLEAR_DOWNLOADER = `
mutation ClearDownloader { mutation ClearDownloader {
@@ -202,7 +203,7 @@ const CLEAR_DOWNLOADER = `
downloadStatus { state } downloadStatus { state }
} }
} }
` `;
const FETCH_EXTENSIONS = ` const FETCH_EXTENSIONS = `
mutation FetchExtensions { mutation FetchExtensions {
@@ -213,7 +214,7 @@ const FETCH_EXTENSIONS = `
} }
} }
} }
` `;
const UPDATE_EXTENSION = ` const UPDATE_EXTENSION = `
mutation UpdateExtension($id: String!, $install: Boolean, $uninstall: Boolean, $update: Boolean) { mutation UpdateExtension($id: String!, $install: Boolean, $uninstall: Boolean, $update: Boolean) {
@@ -221,7 +222,7 @@ const UPDATE_EXTENSION = `
extension { apkName pkgName name isInstalled hasUpdate } extension { apkName pkgName name isInstalled hasUpdate }
} }
} }
` `;
const BIND_TRACK = ` const BIND_TRACK = `
mutation BindTrack($mangaId: Int!, $trackerId: Int!, $remoteId: LongString!) { mutation BindTrack($mangaId: Int!, $trackerId: Int!, $remoteId: LongString!) {
@@ -229,7 +230,7 @@ const BIND_TRACK = `
trackRecord { id trackerId remoteId } trackRecord { id trackerId remoteId }
} }
} }
` `;
const TRACK_PROGRESS = ` const TRACK_PROGRESS = `
mutation TrackProgress($mangaId: Int!) { mutation TrackProgress($mangaId: Int!) {
@@ -237,7 +238,7 @@ const TRACK_PROGRESS = `
trackRecords { id trackerId lastChapterRead status } trackRecords { id trackerId lastChapterRead status }
} }
} }
` `;
const UPDATE_LIBRARY = ` const UPDATE_LIBRARY = `
mutation UpdateLibrary { mutation UpdateLibrary {
@@ -245,7 +246,7 @@ const UPDATE_LIBRARY = `
updateStatus { jobsInfo { isRunning finishedJobs totalJobs } } updateStatus { jobsInfo { isRunning finishedJobs totalJobs } }
} }
} }
` `;
// ─── Mappers ──────────────────────────────────────────────────────────────── // ─── Mappers ────────────────────────────────────────────────────────────────
@@ -267,11 +268,11 @@ function mapChapter(raw: Record<string, unknown>): Chapter {
lastReadAt: raw.lastReadAt as string | undefined, lastReadAt: raw.lastReadAt as string | undefined,
scanlator: raw.scanlator as string | null | undefined, scanlator: raw.scanlator as string | null | undefined,
manga: raw.manga as Chapter['manga'], manga: raw.manga as Chapter['manga'],
} };
} }
function mapManga(raw: Record<string, unknown>): Manga { function mapManga(raw: Record<string, unknown>): Manga {
const inLibraryAt = raw.inLibraryAt as string | null | undefined const inLibraryAt = raw.inLibraryAt as string | null | undefined;
return { return {
...(raw as unknown as Manga), ...(raw as unknown as Manga),
tags: raw.genre as string[] | undefined, tags: raw.genre as string[] | undefined,
@@ -279,19 +280,19 @@ function mapManga(raw: Record<string, unknown>): Manga {
lastReadAt: raw.lastReadChapter lastReadAt: raw.lastReadChapter
? Date.now() ? Date.now()
: undefined, : undefined,
} };
} }
function mapExtension(raw: Record<string, unknown>): Extension { function mapExtension(raw: Record<string, unknown>): Extension {
return { return {
...(raw as unknown as Extension), ...(raw as unknown as Extension),
id: raw.pkgName as string, id: raw.pkgName as string,
} };
} }
function mapDownloadItem(raw: Record<string, unknown>): DownloadItem { function mapDownloadItem(raw: Record<string, unknown>): DownloadItem {
const chapter = raw.chapter as Record<string, unknown> const chapter = raw.chapter as Record<string, unknown>;
const manga = chapter?.manga as Record<string, unknown> const manga = chapter?.manga as Record<string, unknown>;
return { return {
chapterId: String(chapter?.id), chapterId: String(chapter?.id),
mangaId: String(chapter?.mangaId ?? manga?.id), mangaId: String(chapter?.mangaId ?? manga?.id),
@@ -299,29 +300,29 @@ function mapDownloadItem(raw: Record<string, unknown>): DownloadItem {
mangaTitle: manga?.title as string, mangaTitle: manga?.title as string,
progress: (raw.progress as number) ?? 0, progress: (raw.progress as number) ?? 0,
state: mapDownloadState(raw.state as string), state: mapDownloadState(raw.state as string),
} };
} }
function mapDownloadState(state: string): DownloadItem['state'] { function mapDownloadState(state: string): DownloadItem['state'] {
switch (state) { switch (state) {
case 'DOWNLOADING': return 'downloading' case 'DOWNLOADING': return 'downloading';
case 'FINISHED': return 'finished' case 'FINISHED': return 'finished';
case 'ERROR': return 'error' case 'ERROR': return 'error';
default: return 'queued' default: return 'queued';
} }
} }
// ─── Adapter ──────────────────────────────────────────────────────────────── // ─── Adapter ────────────────────────────────────────────────────────────────
export class SuwayomiAdapter implements ServerAdapter { export class SuwayomiAdapter implements ServerAdapter {
private baseUrl = 'http://127.0.0.1:4567' private baseUrl = 'http://127.0.0.1:4567';
private authHeader: string | null = null private authHeader: string | null = null;
async connect(config: ServerConfig) { async connect(config: ServerConfig) {
this.baseUrl = config.baseUrl.replace(/\/$/, '') this.baseUrl = config.baseUrl.replace(/\/$/, '');
if (config.credentials) { if (config.credentials) {
const { username, password } = config.credentials const {username, password} = config.credentials;
this.authHeader = 'Basic ' + btoa(`${username}:${password}`) this.authHeader = 'Basic ' + btoa(`${username}:${password}`);
} }
} }
@@ -331,17 +332,17 @@ export class SuwayomiAdapter implements ServerAdapter {
method: 'POST', method: 'POST',
headers: this.headers(), headers: this.headers(),
body: JSON.stringify({query: '{ aboutServer { name } }'}), body: JSON.stringify({query: '{ aboutServer { name } }'}),
}) });
return res.ok ? 'connected' : 'error' return res.ok ? 'connected' : 'error';
} catch { } catch {
return 'disconnected' return 'disconnected';
} }
} }
private headers(): Record<string, string> { private headers(): Record<string, string> {
const h: Record<string, string> = { 'Content-Type': 'application/json' } const h: Record<string, string> = {'Content-Type': 'application/json'};
if (this.authHeader) h['Authorization'] = this.authHeader if (this.authHeader) h['Authorization'] = this.authHeader;
return h return h;
} }
private async gql<T>(query: string, variables?: Record<string, unknown>): Promise<T> { private async gql<T>(query: string, variables?: Record<string, unknown>): Promise<T> {
@@ -349,163 +350,163 @@ export class SuwayomiAdapter implements ServerAdapter {
method: 'POST', method: 'POST',
headers: this.headers(), headers: this.headers(),
body: JSON.stringify({query, variables}), body: JSON.stringify({query, variables}),
}) });
if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`) if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`);
const json: GQLResponse<T> = await res.json() const json: GQLResponse<T> = await res.json();
if (json.errors?.length) throw new Error(json.errors[0].message) if (json.errors?.length) throw new Error(json.errors[0].message);
return json.data return json.data;
} }
// ── Manga ────────────────────────────────────────────────────────────── // ── Manga ──────────────────────────────────────────────────────────────
async getManga(id: string): Promise<Manga> { async getManga(id: string): Promise<Manga> {
const data = await this.gql<{ manga: Record<string, unknown> }>( const data = await this.gql<{manga: Record<string, unknown>;}>(
GET_MANGA, {id: Number(id)} GET_MANGA, {id: Number(id)}
) );
return mapManga(data.manga) return mapManga(data.manga);
} }
async getMangaList(filters: MangaFilters): Promise<PaginatedResult<Manga>> { async getMangaList(filters: MangaFilters): Promise<PaginatedResult<Manga>> {
if (filters.inLibrary) { if (filters.inLibrary) {
const data = await this.gql<{ mangas: { nodes: Record<string, unknown>[] } }>(GET_LIBRARY) const data = await this.gql<{mangas: {nodes: Record<string, unknown>[];};}>(GET_LIBRARY);
return { items: data.mangas.nodes.map(mapManga), hasNextPage: false } return {items: data.mangas.nodes.map(mapManga), hasNextPage: false};
} }
const data = await this.gql<{ mangas: { nodes: Record<string, unknown>[] } }>(GET_LIBRARY) const data = await this.gql<{mangas: {nodes: Record<string, unknown>[];};}>(GET_LIBRARY);
return { items: data.mangas.nodes.map(mapManga), hasNextPage: false } return {items: data.mangas.nodes.map(mapManga), hasNextPage: false};
} }
async searchManga(query: string, sourceId?: string): Promise<Manga[]> { async searchManga(query: string, sourceId?: string): Promise<Manga[]> {
if (!sourceId) return [] if (!sourceId) return [];
const data = await this.gql<{ const data = await this.gql<{
fetchSourceManga: { mangas: Record<string, unknown>[] } fetchSourceManga: {mangas: Record<string, unknown>[];};
}>(FETCH_SOURCE_MANGA, { }>(FETCH_SOURCE_MANGA, {
source: sourceId, source: sourceId,
type: 'SEARCH', type: 'SEARCH',
page: 1, page: 1,
query, query,
}) });
return data.fetchSourceManga.mangas.map(mapManga) return data.fetchSourceManga.mangas.map(mapManga);
} }
async addToLibrary(mangaId: string) { 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) { 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<MangaMeta>) { async updateMangaMeta(id: string, meta: Partial<MangaMeta>) {
for (const [key, value] of Object.entries(meta)) { for (const [key, value] of Object.entries(meta)) {
if (value === undefined) continue if (value === undefined) continue;
await this.gql(SET_MANGA_META, { await this.gql(SET_MANGA_META, {
mangaId: Number(id), mangaId: Number(id),
key, key,
value: String(value), value: String(value),
}) });
} }
} }
// ── Chapters ─────────────────────────────────────────────────────────── // ── Chapters ───────────────────────────────────────────────────────────
async getChapters(mangaId: string): Promise<Chapter[]> { async getChapters(mangaId: string): Promise<Chapter[]> {
const data = await this.gql<{ chapters: { nodes: Record<string, unknown>[] } }>( const data = await this.gql<{chapters: {nodes: Record<string, unknown>[];};}>(
GET_CHAPTERS, {mangaId: Number(mangaId)} GET_CHAPTERS, {mangaId: Number(mangaId)}
) );
return data.chapters.nodes.map(mapChapter) return data.chapters.nodes.map(mapChapter);
} }
async getChapter(id: string): Promise<Chapter> { async getChapter(id: string): Promise<Chapter> {
const chapters = await this.gql<{ chapters: { nodes: Record<string, unknown>[] } }>( const chapters = await this.gql<{chapters: {nodes: Record<string, unknown>[];};}>(
GET_CHAPTERS, {mangaId: 0} GET_CHAPTERS, {mangaId: 0}
) );
const found = chapters.chapters.nodes.find(c => String(c.id) === id) const found = chapters.chapters.nodes.find(c => String(c.id) === id);
if (!found) throw new Error(`Chapter ${id} not found`) if (!found) throw new Error(`Chapter ${id} not found`);
return mapChapter(found) return mapChapter(found);
} }
async getChapterPages(id: string): Promise<Page[]> { async getChapterPages(id: string): Promise<Page[]> {
const data = await this.gql<{ fetchChapterPages: { pages: string[] } }>( const data = await this.gql<{fetchChapterPages: {pages: string[];};}>(
FETCH_CHAPTER_PAGES, {chapterId: Number(id)} FETCH_CHAPTER_PAGES, {chapterId: Number(id)}
) );
return data.fetchChapterPages.pages.map((url, index) => ({ index, url })) return data.fetchChapterPages.pages.map((url, index) => ({index, url}));
} }
async markChapterRead(id: string, read: boolean) { 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) { 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 ────────────────────────────────────────────────────────── // ── Downloads ──────────────────────────────────────────────────────────
async getDownloads(): Promise<DownloadItem[]> { async getDownloads(): Promise<DownloadItem[]> {
const data = await this.gql<{ const data = await this.gql<{
downloadStatus: { queue: Record<string, unknown>[] } downloadStatus: {queue: Record<string, unknown>[];};
}>(GET_DOWNLOAD_STATUS) }>(GET_DOWNLOAD_STATUS);
return data.downloadStatus.queue.map(mapDownloadItem) return data.downloadStatus.queue.map(mapDownloadItem);
} }
async enqueueDownload(chapterId: string) { async enqueueDownload(chapterId: string) {
await this.gql(ENQUEUE_DOWNLOAD, { chapterId: Number(chapterId) }) await this.gql(ENQUEUE_DOWNLOAD, {chapterId: Number(chapterId)});
} }
async dequeueDownload(chapterId: string) { async dequeueDownload(chapterId: string) {
await this.gql(DEQUEUE_DOWNLOAD, { chapterId: Number(chapterId) }) await this.gql(DEQUEUE_DOWNLOAD, {chapterId: Number(chapterId)});
} }
async clearDownloads() { async clearDownloads() {
await this.gql(CLEAR_DOWNLOADER) await this.gql(CLEAR_DOWNLOADER);
} }
// ── Extensions ───────────────────────────────────────────────────────── // ── Extensions ─────────────────────────────────────────────────────────
async getExtensions(): Promise<Extension[]> { async getExtensions(): Promise<Extension[]> {
await this.gql(FETCH_EXTENSIONS) await this.gql(FETCH_EXTENSIONS);
const data = await this.gql<{ extensions: { nodes: Record<string, unknown>[] } }>( const data = await this.gql<{extensions: {nodes: Record<string, unknown>[];};}>(
GET_EXTENSIONS GET_EXTENSIONS
) );
return data.extensions.nodes.map(mapExtension) return data.extensions.nodes.map(mapExtension);
} }
async installExtension(id: string) { async installExtension(id: string) {
await this.gql(UPDATE_EXTENSION, { id, install: true }) await this.gql(UPDATE_EXTENSION, {id, install: true});
} }
async uninstallExtension(id: string) { async uninstallExtension(id: string) {
await this.gql(UPDATE_EXTENSION, { id, uninstall: true }) await this.gql(UPDATE_EXTENSION, {id, uninstall: true});
} }
async updateExtension(id: string) { async updateExtension(id: string) {
await this.gql(UPDATE_EXTENSION, { id, update: true }) await this.gql(UPDATE_EXTENSION, {id, update: true});
} }
async getSources(): Promise<Source[]> { async getSources(): Promise<Source[]> {
const data = await this.gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) const data = await this.gql<{sources: {nodes: Source[];};}>(GET_SOURCES);
return data.sources.nodes return data.sources.nodes;
} }
async browseSource(sourceId: string, page: number): Promise<PaginatedResult<Manga>> { async browseSource(sourceId: string, page: number): Promise<PaginatedResult<Manga>> {
const data = await this.gql<{ const data = await this.gql<{
fetchSourceManga: { mangas: Record<string, unknown>[]; hasNextPage: boolean } fetchSourceManga: {mangas: Record<string, unknown>[]; hasNextPage: boolean;};
}>(FETCH_SOURCE_MANGA, { }>(FETCH_SOURCE_MANGA, {
source: sourceId, source: sourceId,
type: 'LATEST', type: 'LATEST',
page, page,
}) });
return { return {
items: data.fetchSourceManga.mangas.map(mapManga), items: data.fetchSourceManga.mangas.map(mapManga),
hasNextPage: data.fetchSourceManga.hasNextPage, hasNextPage: data.fetchSourceManga.hasNextPage,
} };
} }
// ── Tracking ─────────────────────────────────────────────────────────── // ── Tracking ───────────────────────────────────────────────────────────
async getTrackers(): Promise<Tracker[]> { async getTrackers(): Promise<Tracker[]> {
const data = await this.gql<{ trackers: { nodes: Tracker[] } }>(GET_TRACKERS) const data = await this.gql<{trackers: {nodes: Tracker[];};}>(GET_TRACKERS);
return data.trackers.nodes return data.trackers.nodes;
} }
async linkTracker(mangaId: string, trackerId: string, remoteId: string) { async linkTracker(mangaId: string, trackerId: string, remoteId: string) {
@@ -513,27 +514,27 @@ export class SuwayomiAdapter implements ServerAdapter {
mangaId: Number(mangaId), mangaId: Number(mangaId),
trackerId: Number(trackerId), trackerId: Number(trackerId),
remoteId, remoteId,
}) });
} }
async syncTracking(mangaId: string) { async syncTracking(mangaId: string) {
await this.gql(TRACK_PROGRESS, { mangaId: Number(mangaId) }) await this.gql(TRACK_PROGRESS, {mangaId: Number(mangaId)});
} }
// ── Updates ──────────────────────────────────────────────────────────── // ── Updates ────────────────────────────────────────────────────────────
async checkForUpdates(mangaIds?: string[]): Promise<UpdateResult[]> { async checkForUpdates(mangaIds?: string[]): Promise<UpdateResult[]> {
if (mangaIds?.length) { if (mangaIds?.length) {
const results: UpdateResult[] = [] const results: UpdateResult[] = [];
for (const id of mangaIds) { for (const id of mangaIds) {
const before = await this.getChapters(id) const before = await this.getChapters(id);
await this.gql(FETCH_CHAPTERS, { mangaId: Number(id) }) await this.gql(FETCH_CHAPTERS, {mangaId: Number(id)});
const after = await this.getChapters(id) const after = await this.getChapters(id);
results.push({ mangaId: id, newChapters: after.length - before.length }) results.push({mangaId: id, newChapters: after.length - before.length});
} }
return results return results;
} }
await this.gql(UPDATE_LIBRARY) await this.gql(UPDATE_LIBRARY);
return [] return [];
} }
} }
+44 -1
View File
@@ -7,6 +7,7 @@ export type ReadingDirection = "ltr" | "rtl";
export type ChapterSortDir = "desc" | "asc"; export type ChapterSortDir = "desc" | "asc";
export type ChapterSortMode = "source" | "chapterNumber" | "uploadDate"; export type ChapterSortMode = "source" | "chapterNumber" | "uploadDate";
export type ContentLevel = "strict" | "moderate" | "unrestricted"; export type ContentLevel = "strict" | "moderate" | "unrestricted";
export type CloseAction = "ask" | "tray" | "quit";
export type LibrarySortMode = export type LibrarySortMode =
| "az" | "unreadCount" | "totalChapters" | "az" | "unreadCount" | "totalChapters"
@@ -98,6 +99,16 @@ export interface MangaPrefs {
coverUrl?: string; 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 = { export const DEFAULT_MANGA_PREFS: MangaPrefs = {
autoDownload: false, autoDownload: false,
downloadAhead: 0, downloadAhead: 0,
@@ -113,6 +124,16 @@ export const DEFAULT_MANGA_PREFS: MangaPrefs = {
autoDownloadScanlators: [], autoDownloadScanlators: [],
}; };
export const DEFAULT_AUTOMATION_DEFAULTS: AutomationDefaults = {
autoDownload: false,
downloadAhead: 0,
deleteOnRead: false,
deleteDelayHours: 0,
maxKeepChapters: 0,
pauseUpdates: false,
refreshInterval: "weekly",
};
export interface ReaderSettings { export interface ReaderSettings {
pageStyle: PageStyle; pageStyle: PageStyle;
fitMode: FitMode; fitMode: FitMode;
@@ -135,6 +156,8 @@ export interface Settings {
readingDirection: ReadingDirection; readingDirection: ReadingDirection;
fitMode: FitMode; fitMode: FitMode;
readerZoom: number; readerZoom: number;
overlayBars: boolean;
tapToToggleBar: boolean;
pageGap: boolean; pageGap: boolean;
optimizeContrast: boolean; optimizeContrast: boolean;
offsetDoubleSpreads: boolean; offsetDoubleSpreads: boolean;
@@ -147,6 +170,8 @@ export interface Settings {
sourceOverridesEnabled: boolean; sourceOverridesEnabled: boolean;
nsfwAllowedSourceIds: string[]; nsfwAllowedSourceIds: string[];
nsfwBlockedSourceIds: string[]; nsfwBlockedSourceIds: string[];
libraryShowAllInSaved: boolean;
libraryHideCompletedInSaved: boolean;
discordRpc: boolean; discordRpc: boolean;
chapterSortDir: ChapterSortDir; chapterSortDir: ChapterSortDir;
chapterSortMode: ChapterSortMode; chapterSortMode: ChapterSortMode;
@@ -154,6 +179,7 @@ export interface Settings {
uiZoom: number; uiZoom: number;
compactSidebar: boolean; compactSidebar: boolean;
gpuAcceleration: boolean; gpuAcceleration: boolean;
closeAction: CloseAction;
serverUrl: string; serverUrl: string;
serverBinary: string; serverBinary: string;
serverBinaryArgs: string; serverBinaryArgs: string;
@@ -168,6 +194,9 @@ export interface Settings {
readerDebounceMs: number; readerDebounceMs: number;
autoBookmark: boolean; autoBookmark: boolean;
theme: Theme; theme: Theme;
systemThemeSync: boolean;
systemThemeDark: Theme;
systemThemeLight: Theme;
libraryBranches: boolean; libraryBranches: boolean;
renderLimit: number; renderLimit: number;
heroSlots: (number | null)[]; heroSlots: (number | null)[];
@@ -194,7 +223,7 @@ export interface Settings {
hiddenCategoryIds: number[]; hiddenCategoryIds: number[];
defaultLibraryCategoryId: number | null; defaultLibraryCategoryId: number | null;
savedIsDefaultCategory: boolean; savedIsDefaultCategory: boolean;
libraryTabSort: Record<string, { mode: LibrarySortMode; dir: LibrarySortDir }>; libraryTabSort: Record<string, {mode: LibrarySortMode; dir: LibrarySortDir;}>;
libraryTabStatus: Record<string, LibraryStatusFilter>; libraryTabStatus: Record<string, LibraryStatusFilter>;
libraryTabFilters: Record<string, Partial<Record<LibraryContentFilter, boolean>>>; libraryTabFilters: Record<string, Partial<Record<LibraryContentFilter, boolean>>>;
maxPageWidth?: number; maxPageWidth?: number;
@@ -220,6 +249,9 @@ export interface Settings {
autoScroll?: boolean; autoScroll?: boolean;
autoScrollSpeed?: number; autoScrollSpeed?: number;
disableAutoComplete: boolean; disableAutoComplete: boolean;
automationEnabled: boolean;
automationEnforceGlobal: boolean;
automationDefaults: AutomationDefaults;
} }
export const DEFAULT_SETTINGS: Settings = { export const DEFAULT_SETTINGS: Settings = {
@@ -227,6 +259,8 @@ export const DEFAULT_SETTINGS: Settings = {
readingDirection: "ltr", readingDirection: "ltr",
fitMode: "width", fitMode: "width",
readerZoom: 1.0, readerZoom: 1.0,
overlayBars: false,
tapToToggleBar: false,
pageGap: true, pageGap: true,
optimizeContrast: false, optimizeContrast: false,
offsetDoubleSpreads: false, offsetDoubleSpreads: false,
@@ -239,6 +273,8 @@ export const DEFAULT_SETTINGS: Settings = {
sourceOverridesEnabled: false, sourceOverridesEnabled: false,
nsfwAllowedSourceIds: [], nsfwAllowedSourceIds: [],
nsfwBlockedSourceIds: [], nsfwBlockedSourceIds: [],
libraryShowAllInSaved: true,
libraryHideCompletedInSaved: false,
discordRpc: false, discordRpc: false,
chapterSortDir: "desc", chapterSortDir: "desc",
chapterSortMode: "source", chapterSortMode: "source",
@@ -246,6 +282,7 @@ export const DEFAULT_SETTINGS: Settings = {
uiZoom: 1.0, uiZoom: 1.0,
compactSidebar: false, compactSidebar: false,
gpuAcceleration: true, gpuAcceleration: true,
closeAction: "ask",
serverUrl: "http://localhost:4567", serverUrl: "http://localhost:4567",
serverBinary: "", serverBinary: "",
serverBinaryArgs: "", serverBinaryArgs: "",
@@ -260,6 +297,9 @@ export const DEFAULT_SETTINGS: Settings = {
readerDebounceMs: 120, readerDebounceMs: 120,
autoBookmark: true, autoBookmark: true,
theme: "dark", theme: "dark",
systemThemeSync: false,
systemThemeDark: "dark",
systemThemeLight: "light",
libraryBranches: true, libraryBranches: true,
renderLimit: 48, renderLimit: 48,
heroSlots: [null, null, null, null], heroSlots: [null, null, null, null],
@@ -309,4 +349,7 @@ export const DEFAULT_SETTINGS: Settings = {
autoScroll: false, autoScroll: false,
autoScrollSpeed: 5, autoScrollSpeed: 5,
disableAutoComplete: false, disableAutoComplete: false,
automationEnabled: false,
automationEnforceGlobal: false,
automationDefaults: DEFAULT_AUTOMATION_DEFAULTS,
}; };
+3 -1
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { page } from '$app/stores' import { page } from '$app/stores'
import { applyTheme } from '$lib/core/theme' import { applyTheme, mountSystemThemeSync, unmountSystemThemeSync } from '$lib/core/theme'
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'
@@ -40,6 +40,7 @@
onMount(() => { onMount(() => {
applyTheme(settingsState.theme, settingsState.customThemes) applyTheme(settingsState.theme, settingsState.customThemes)
applyZoom(settingsState.uiZoom) applyZoom(settingsState.uiZoom)
mountSystemThemeSync()
const stopZoomKey = mountZoomKey( const stopZoomKey = mountZoomKey(
() => settingsState.uiZoom, () => settingsState.uiZoom,
@@ -77,6 +78,7 @@
stopZoomKey() stopZoomKey()
stopIdleDetection() stopIdleDetection()
window.removeEventListener('resize', handleResize) window.removeEventListener('resize', handleResize)
unmountSystemThemeSync()
stopTauriScale?.() stopTauriScale?.()
} }
}) })
+370
View File
@@ -0,0 +1,370 @@
<script lang="ts">
import { page } from '$app/stores'
const sections = [
['general', 'General'],
['appearance', 'Appearance'],
['reader', 'Reader'],
['library', 'Library'],
['automation', 'Automation'],
['performance', 'Performance'],
['keybinds', 'Keybinds'],
['storage', 'Storage'],
['folders', 'Folders'],
['tracking', 'Tracking'],
['security', 'Security'],
['content', 'Content'],
['about', 'About'],
['devtools', 'Devtools'],
] as const
let { children } = $props()
const activeSection = $derived(
sections.find(([section]) => $page.url.pathname === `/settings/${section}`)?.[0] ?? 'general'
)
</script>
<div class="settings-shell">
<aside class="settings-nav-panel">
<div class="settings-nav-header">
<p class="settings-kicker">Preferences</p>
<h1>Settings</h1>
<p class="settings-nav-copy">Route-driven sections backed by shared state.</p>
</div>
<nav class="settings-nav" aria-label="Settings sections">
{#each sections as [section, label]}
<a
class="settings-nav-link"
class:active={activeSection === section}
href={`/settings/${section}`}
aria-current={activeSection === section ? 'page' : undefined}
>
{label}
</a>
{/each}
</nav>
</aside>
<main class="settings-main">
{@render children()}
</main>
</div>
<style>
:global(.settings-shell) {
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
width: 100%;
height: 100%;
overflow: hidden;
background: linear-gradient(180deg, color-mix(in srgb, var(--bg-base) 82%, transparent), var(--bg-surface));
}
:global(.settings-nav-panel) {
display: flex;
flex-direction: column;
min-width: 0;
border-right: 1px solid var(--border-dim);
background: color-mix(in srgb, var(--bg-base) 94%, black);
padding: var(--sp-5) var(--sp-4);
overflow: hidden;
}
:global(.settings-nav-header) {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-bottom: var(--sp-4);
}
:global(.settings-kicker) {
margin: 0;
font-size: 0.7rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--text-faint);
}
:global(.settings-nav-header h1) {
margin: 0;
font-size: 1.45rem;
color: var(--text-primary);
}
:global(.settings-nav-copy) {
margin: 0;
color: var(--text-muted);
font-size: 0.9rem;
line-height: 1.45;
}
:global(.settings-nav) {
display: grid;
gap: 0.35rem;
overflow: auto;
padding-right: 0.25rem;
}
:global(.settings-nav-link) {
display: flex;
align-items: center;
min-height: 38px;
padding: 0.55rem 0.8rem;
border-radius: var(--radius-md);
color: var(--text-muted);
text-decoration: none;
transition: background var(--t-base), color var(--t-base), transform var(--t-base);
}
:global(.settings-nav-link:hover) {
background: var(--bg-raised);
color: var(--text-secondary);
}
:global(.settings-nav-link.active) {
background: var(--accent-muted);
color: var(--accent-fg);
}
:global(.settings-main) {
min-width: 0;
min-height: 0;
overflow: auto;
padding: var(--sp-6);
}
:global(.settings-page) {
display: flex;
flex-direction: column;
gap: var(--sp-5);
max-width: 1100px;
margin: 0 auto;
}
:global(.settings-page-header) {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
:global(.settings-page-header h2) {
margin: 0;
font-size: 1.8rem;
color: var(--text-primary);
}
:global(.settings-page-header p) {
margin: 0;
color: var(--text-muted);
line-height: 1.5;
}
:global(.settings-card) {
display: flex;
flex-direction: column;
gap: 1px;
border: 1px solid var(--border-dim);
border-radius: var(--radius-xl);
overflow: hidden;
background: var(--bg-base);
}
:global(.settings-row) {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-4);
padding: var(--sp-4) var(--sp-5);
background: var(--bg-surface);
}
:global(.settings-row-stack) {
flex-direction: column;
align-items: stretch;
}
:global(.settings-row-head) {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-4);
}
:global(.settings-toggle-row) {
align-items: center;
}
:global(.settings-grid-2) {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-4);
}
:global(.settings-grid-2 > label) {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
:global(.settings-inline-control) {
display: flex;
align-items: center;
gap: var(--sp-3);
flex-wrap: wrap;
justify-content: flex-end;
}
:global(.settings-subcard) {
display: flex;
flex-direction: column;
gap: var(--sp-3);
padding: var(--sp-4);
border: 1px solid var(--border-dim);
border-radius: var(--radius-lg);
background: var(--bg-base);
}
:global(.settings-mini-row) {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
:global(.settings-label) {
color: var(--text-primary);
font-weight: 600;
}
:global(.settings-desc) {
color: var(--text-muted);
font-size: 0.92rem;
line-height: 1.45;
}
:global(.settings-input),
:global(.settings-select) {
min-height: 40px;
padding: 0.55rem 0.8rem;
border: 1px solid var(--border-base);
border-radius: var(--radius-md);
background: var(--bg-overlay);
color: var(--text-primary);
outline: none;
}
:global(.settings-input:focus),
:global(.settings-select:focus) {
border-color: var(--border-focus);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--border-focus) 25%, transparent);
}
:global(.settings-input-wide) {
width: min(100%, 480px);
}
:global(.settings-input-narrow) {
width: 96px;
text-align: center;
}
:global(.settings-slider) {
width: min(320px, 100%);
accent-color: var(--accent);
}
:global(.settings-button) {
min-height: 40px;
padding: 0.55rem 0.85rem;
border: 1px solid var(--border-base);
border-radius: var(--radius-md);
background: var(--bg-overlay);
color: var(--text-secondary);
cursor: pointer;
}
:global(.settings-button:hover) {
border-color: var(--border-strong);
background: var(--bg-raised);
}
:global(.settings-theme-grid) {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: var(--sp-3);
padding: var(--sp-4);
background: var(--bg-surface);
}
:global(.settings-theme-card) {
display: flex;
flex-direction: column;
gap: var(--sp-3);
padding: var(--sp-3);
border: 1px solid var(--border-base);
border-radius: var(--radius-lg);
background: var(--bg-base);
color: inherit;
text-align: left;
cursor: pointer;
}
:global(.settings-theme-card.active) {
border-color: var(--accent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 30%, transparent);
}
:global(.settings-theme-preview) {
display: block;
height: 72px;
border-radius: var(--radius-md);
background:
linear-gradient(135deg, var(--theme-bg), var(--theme-surface)),
linear-gradient(135deg, var(--theme-bg), var(--theme-surface));
position: relative;
overflow: hidden;
}
:global(.settings-theme-preview)::after {
content: '';
position: absolute;
inset: 14px 14px 14px 38px;
border-radius: 10px;
background: linear-gradient(180deg, color-mix(in srgb, var(--theme-accent) 82%, white), color-mix(in srgb, var(--theme-accent) 40%, transparent));
opacity: 0.86;
}
:global(.settings-theme-info) {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
@media (max-width: 760px) {
:global(.settings-grid-2) {
grid-template-columns: 1fr;
}
:global(.settings-row),
:global(.settings-row-head) {
flex-direction: column;
align-items: stretch;
}
:global(.settings-inline-control) {
justify-content: stretch;
}
}
@media (max-width: 920px) {
:global(.settings-shell) {
grid-template-columns: 1fr;
}
:global(.settings-nav-panel) {
border-right: none;
border-bottom: 1px solid var(--border-dim);
}
}
</style>
+5
View File
@@ -0,0 +1,5 @@
import {redirect} from '@sveltejs/kit';
export function load() {
throw redirect(302, '/settings/general');
}
+46
View File
@@ -0,0 +1,46 @@
<script lang="ts">
import pkg from '../../../../package.json'
import { settingsState } from '$lib/state/settings.svelte'
import { trackingState } from '$lib/state/tracking.svelte'
const appVersion = pkg.version as string
</script>
<svelte:head>
<title>Settings - About</title>
</svelte:head>
<section class="settings-page">
<header class="settings-page-header">
<p class="settings-kicker">About</p>
<h2>Build and app information</h2>
<p>Static app details and a quick summary of the connected server.</p>
</header>
<div class="settings-card">
<div class="settings-row">
<div>
<div class="settings-label">Moku</div>
<div class="settings-desc">Version {appVersion}</div>
</div>
</div>
<div class="settings-row settings-grid-2">
<div>
<div class="settings-label">Server URL</div>
<div class="settings-desc">{settingsState.serverUrl}</div>
</div>
<div>
<div class="settings-label">Tracker count</div>
<div class="settings-desc">{trackingState.trackers.length} trackers loaded</div>
</div>
</div>
<div class="settings-row">
<div>
<div class="settings-label">Project</div>
<div class="settings-desc">A manga reader frontend for Suwayomi / Tachidesk.</div>
</div>
</div>
</div>
</section>
@@ -0,0 +1,91 @@
<script lang="ts">
import { mountSystemThemeSync } from '$lib/core/theme'
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
const builtinThemes = [
['original', 'Original', '#101010', '#151515', '#a8c4a8'],
['dark', 'Dark', '#080808', '#111111', '#bcd8bc'],
['light', 'Light', '#f4f2ee', '#faf8f4', '#2a5a2a'],
['midnight', 'Midnight', '#0c1020', '#101428', '#a8b4e8'],
['warm', 'Warm', '#16130c', '#1c1810', '#e0b860'],
] as const
const allThemes = $derived([
...builtinThemes.map(([id, label]) => ({id, label})),
...settingsState.customThemes.map((theme) => ({id: theme.id, label: theme.name})),
])
function chooseTheme(id: string) {
updateSettings({theme: id})
}
function toggleSystemSync() {
updateSettings({systemThemeSync: !settingsState.systemThemeSync})
mountSystemThemeSync()
}
</script>
<svelte:head>
<title>Settings - Appearance</title>
</svelte:head>
<section class="settings-page">
<header class="settings-page-header">
<p class="settings-kicker">Appearance</p>
<h2>Theme and color behavior</h2>
<p>Choose the app theme and optional system theme sync.</p>
</header>
<div class="settings-card">
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Match system theme</div>
<div class="settings-desc">Switch between light and dark themes automatically.</div>
</div>
<input type="checkbox" checked={settingsState.systemThemeSync} onchange={toggleSystemSync} />
</label>
{#if settingsState.systemThemeSync}
<div class="settings-row settings-grid-2">
<label>
<div class="settings-label">Dark theme</div>
<select class="settings-select" value={settingsState.systemThemeDark} onchange={(event) => { updateSettings({systemThemeDark: (event.currentTarget as HTMLSelectElement).value}); mountSystemThemeSync(); }}>
{#each allThemes as theme}
<option value={theme.id}>{theme.label}</option>
{/each}
</select>
</label>
<label>
<div class="settings-label">Light theme</div>
<select class="settings-select" value={settingsState.systemThemeLight} onchange={(event) => { updateSettings({systemThemeLight: (event.currentTarget as HTMLSelectElement).value}); mountSystemThemeSync(); }}>
{#each allThemes as theme}
<option value={theme.id}>{theme.label}</option>
{/each}
</select>
</label>
</div>
{/if}
<div class="settings-theme-grid">
{#each builtinThemes as [id, label, bg, surface, accent]}
<button class="settings-theme-card" class:active={settingsState.theme === id} type="button" onclick={() => chooseTheme(id)}>
<span class="settings-theme-preview" style={`--theme-bg:${bg};--theme-surface:${surface};--theme-accent:${accent};`}></span>
<span class="settings-theme-info">
<span class="settings-label">{label}</span>
<span class="settings-desc">Built-in theme</span>
</span>
</button>
{/each}
{#each settingsState.customThemes as theme}
<button class="settings-theme-card" class:active={settingsState.theme === theme.id} type="button" onclick={() => chooseTheme(theme.id)}>
<span class="settings-theme-preview" style={`--theme-bg:${theme.tokens['bg-base']};--theme-surface:${theme.tokens['bg-surface']};--theme-accent:${theme.tokens['accent']};`}></span>
<span class="settings-theme-info">
<span class="settings-label">{theme.name}</span>
<span class="settings-desc">Custom theme</span>
</span>
</button>
{/each}
</div>
</div>
</section>
+127
View File
@@ -0,0 +1,127 @@
<script lang="ts">
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
const downloadAheadOptions = [0, 2, 5, 10]
const maxKeepOptions = [0, 5, 10, 25]
const delayOptions = [0, 24, 168]
const refreshOptions = ['daily', 'weekly', 'manual'] as const
const defaults = $derived(settingsState.automationDefaults)
function patchDefaults(patch: Partial<typeof defaults>) {
updateSettings({automationDefaults: {...defaults, ...patch}})
}
</script>
<svelte:head>
<title>Settings - Automation</title>
</svelte:head>
<section class="settings-page">
<header class="settings-page-header">
<p class="settings-kicker">Automation</p>
<h2>Series automation defaults</h2>
<p>These values are used when a manga has no per-series override.</p>
</header>
<div class="settings-card">
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Enable automation</div>
<div class="settings-desc">Allow automation rules to run at all.</div>
</div>
<input type="checkbox" checked={settingsState.automationEnabled} onchange={() => updateSettings({automationEnabled: !settingsState.automationEnabled})} />
</label>
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Enforce global defaults</div>
<div class="settings-desc">Ignore per-series overrides and use the global defaults below.</div>
</div>
<input type="checkbox" checked={settingsState.automationEnforceGlobal} onchange={() => updateSettings({automationEnforceGlobal: !settingsState.automationEnforceGlobal})} />
</label>
{#if settingsState.automationEnforceGlobal}
<div class="settings-row">
<div>
<div class="settings-label">Per-series overrides paused</div>
<div class="settings-desc">Disable enforce to allow individual manga preferences again.</div>
</div>
</div>
{/if}
<div class="settings-row settings-grid-2">
<label>
<div class="settings-label">Auto-download new chapters</div>
<select class="settings-select" value={String(defaults.autoDownload)} onchange={(event) => patchDefaults({autoDownload: (event.currentTarget as HTMLSelectElement).value === 'true'})}>
<option value="true">On</option>
<option value="false">Off</option>
</select>
</label>
<label>
<div class="settings-label">Download ahead</div>
<select class="settings-select" value={String(defaults.downloadAhead)} onchange={(event) => patchDefaults({downloadAhead: Number((event.currentTarget as HTMLSelectElement).value)})}>
{#each downloadAheadOptions as value}
<option value={String(value)}>{value === 0 ? 'Off' : value}</option>
{/each}
</select>
</label>
</div>
<div class="settings-row settings-grid-2">
<label>
<div class="settings-label">Max chapters to keep</div>
<select class="settings-select" value={String(defaults.maxKeepChapters)} onchange={(event) => patchDefaults({maxKeepChapters: Number((event.currentTarget as HTMLSelectElement).value)})}>
{#each maxKeepOptions as value}
<option value={String(value)}>{value === 0 ? 'Off' : value}</option>
{/each}
</select>
</label>
<label>
<div class="settings-label">Delete delay</div>
<select class="settings-select" value={String(defaults.deleteDelayHours)} onchange={(event) => patchDefaults({deleteDelayHours: Number((event.currentTarget as HTMLSelectElement).value)})}>
{#each delayOptions as value}
<option value={String(value)}>{value === 0 ? 'Now' : value === 24 ? '1 day' : '1 week'}</option>
{/each}
</select>
</label>
</div>
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Delete after reading</div>
<div class="settings-desc">Remove downloaded chapters after they are marked read.</div>
</div>
<input type="checkbox" checked={defaults.deleteOnRead} onchange={() => patchDefaults({deleteOnRead: !defaults.deleteOnRead})} />
</label>
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Pause updates</div>
<div class="settings-desc">Pause chapter refresh for series with this default.</div>
</div>
<input type="checkbox" checked={defaults.pauseUpdates} onchange={() => patchDefaults({pauseUpdates: !defaults.pauseUpdates})} />
</label>
<div class="settings-row">
<div>
<div class="settings-label">Refresh interval</div>
<div class="settings-desc">How often a series is checked for new chapters.</div>
</div>
<select class="settings-select" value={defaults.refreshInterval} onchange={(event) => patchDefaults({refreshInterval: (event.currentTarget as HTMLSelectElement).value as 'daily' | 'weekly' | 'manual'})}>
{#each refreshOptions as value}
<option value={value}>{value[0].toUpperCase() + value.slice(1)}</option>
{/each}
</select>
</div>
<div class="settings-row">
<div>
<div class="settings-label">Stored custom manga preferences</div>
<div class="settings-desc">{Object.keys(settingsState.mangaPrefs).length} manga records currently have custom prefs.</div>
</div>
</div>
</div>
</section>
+64
View File
@@ -0,0 +1,64 @@
<script lang="ts">
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
const levels = [
['strict', 'Strict'],
['moderate', 'Moderate'],
['unrestricted', 'Unrestricted'],
] as const
function splitIds(value: string) {
return value.split(',').map((item) => item.trim()).filter(Boolean)
}
</script>
<svelte:head>
<title>Settings - Content</title>
</svelte:head>
<section class="settings-page">
<header class="settings-page-header">
<p class="settings-kicker">Content</p>
<h2>Content filtering and source overrides</h2>
<p>Control the overall content level and any per-source exceptions.</p>
</header>
<div class="settings-card">
<div class="settings-row settings-grid-2">
<label>
<div class="settings-label">Content level</div>
<select class="settings-select" value={settingsState.contentLevel} onchange={(event) => updateSettings({contentLevel: (event.currentTarget as HTMLSelectElement).value as 'strict' | 'moderate' | 'unrestricted'})}>
{#each levels as [value, label]}
<option value={value}>{label}</option>
{/each}
</select>
</label>
<label class="settings-toggle-row">
<div>
<div class="settings-label">Per-source overrides</div>
<div class="settings-desc">Allow explicit source allow/block exceptions.</div>
</div>
<input type="checkbox" checked={settingsState.sourceOverridesEnabled} onchange={() => updateSettings({sourceOverridesEnabled: !settingsState.sourceOverridesEnabled})} />
</label>
</div>
{#if settingsState.sourceOverridesEnabled}
<label class="settings-row">
<div>
<div class="settings-label">Allowed source IDs</div>
<div class="settings-desc">Comma-separated source IDs allowed through the current filter.</div>
</div>
<input class="settings-input settings-input-wide" value={settingsState.nsfwAllowedSourceIds.join(', ')} oninput={(event) => updateSettings({nsfwAllowedSourceIds: splitIds((event.currentTarget as HTMLInputElement).value)})} />
</label>
<label class="settings-row">
<div>
<div class="settings-label">Blocked source IDs</div>
<div class="settings-desc">Comma-separated source IDs to always block.</div>
</div>
<input class="settings-input settings-input-wide" value={settingsState.nsfwBlockedSourceIds.join(', ')} oninput={(event) => updateSettings({nsfwBlockedSourceIds: splitIds((event.currentTarget as HTMLInputElement).value)})} />
</label>
{/if}
</div>
</section>
+39
View File
@@ -0,0 +1,39 @@
<script lang="ts">
import { resetSettings, settingsState } from '$lib/state/settings.svelte'
const themeCount = $derived(settingsState.customThemes.length)
const prefsCount = $derived(Object.keys(settingsState.mangaPrefs).length)
</script>
<svelte:head>
<title>Settings - Devtools</title>
</svelte:head>
<section class="settings-page">
<header class="settings-page-header">
<p class="settings-kicker">Devtools</p>
<h2>Diagnostics and reset tools</h2>
<p>Basic internal state summaries and a safe settings reset button.</p>
</header>
<div class="settings-card">
<div class="settings-row settings-grid-2">
<div>
<div class="settings-label">Custom themes</div>
<div class="settings-desc">{themeCount} stored theme definitions</div>
</div>
<div>
<div class="settings-label">Custom manga prefs</div>
<div class="settings-desc">{prefsCount} manga entries have overrides</div>
</div>
</div>
<div class="settings-row">
<div>
<div class="settings-label">Reset all settings</div>
<div class="settings-desc">Restore the entire settings object to defaults.</div>
</div>
<button class="settings-button" type="button" onclick={resetSettings}>Reset settings</button>
</div>
</div>
</section>
+57
View File
@@ -0,0 +1,57 @@
<script lang="ts">
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
function splitIds(value: string) {
return value.split(',').map((item) => item.trim()).filter(Boolean)
}
</script>
<svelte:head>
<title>Settings - Folders</title>
</svelte:head>
<section class="settings-page">
<header class="settings-page-header">
<p class="settings-kicker">Folders</p>
<h2>Library folder organization</h2>
<p>Use simple comma-separated controls to keep tab order and visibility direct.</p>
</header>
<div class="settings-card">
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Saved is default category</div>
<div class="settings-desc">Treat the Saved folder as the default category view.</div>
</div>
<input type="checkbox" checked={settingsState.savedIsDefaultCategory} onchange={() => updateSettings({savedIsDefaultCategory: !settingsState.savedIsDefaultCategory})} />
</label>
<div class="settings-row settings-grid-2">
<label>
<div class="settings-label">Default library category ID</div>
<input class="settings-input settings-input-narrow" type="number" min="0" value={settingsState.defaultLibraryCategoryId ?? ''} oninput={(event) => { const value = (event.currentTarget as HTMLInputElement).value; updateSettings({defaultLibraryCategoryId: value === '' ? null : Number(value)}); }} />
</label>
<label>
<div class="settings-label">Hidden category IDs</div>
<input class="settings-input settings-input-wide" spellcheck="false" value={settingsState.hiddenCategoryIds.join(', ')} oninput={(event) => updateSettings({hiddenCategoryIds: splitIds((event.currentTarget as HTMLInputElement).value).map(Number).filter(Number.isFinite)})} />
</label>
</div>
<label class="settings-row">
<div>
<div class="settings-label">Hidden library tabs</div>
<div class="settings-desc">Comma-separated route names such as Saved, Completed, or custom tabs.</div>
</div>
<input class="settings-input settings-input-wide" spellcheck="false" value={settingsState.hiddenLibraryTabs.join(', ')} oninput={(event) => updateSettings({hiddenLibraryTabs: splitIds((event.currentTarget as HTMLInputElement).value)})} />
</label>
<label class="settings-row">
<div>
<div class="settings-label">Pinned library tab order</div>
<div class="settings-desc">Comma-separated pinned tab IDs in the order you want them shown.</div>
</div>
<input class="settings-input settings-input-wide" spellcheck="false" value={settingsState.libraryPinnedTabOrder.join(', ')} oninput={(event) => updateSettings({libraryPinnedTabOrder: splitIds((event.currentTarget as HTMLInputElement).value)})} />
</label>
</div>
</section>
+163
View File
@@ -0,0 +1,163 @@
<script lang="ts">
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
let advancedOpen = $state(false)
const idleChoices = [0, 1, 2, 5, 10, 15, 30]
</script>
<svelte:head>
<title>Settings - General</title>
</svelte:head>
<section class="settings-page">
<header class="settings-page-header">
<p class="settings-kicker">General</p>
<h2>Application basics</h2>
<p>Core behavior, server connection, and desktop shell preferences.</p>
</header>
<div class="settings-card">
<div class="settings-row">
<div>
<div class="settings-label">Interface scale</div>
<div class="settings-desc">Scale the whole app UI.</div>
</div>
<div class="settings-inline-control">
<input
class="settings-slider"
type="range"
min="50"
max="200"
step="5"
value={Math.round((settingsState.uiZoom ?? 1) * 100)}
oninput={(event) => updateSettings({uiZoom: Number((event.currentTarget as HTMLInputElement).value) / 100})}
/>
<input
class="settings-input settings-input-narrow"
type="number"
min="50"
max="200"
value={Math.round((settingsState.uiZoom ?? 1) * 100)}
oninput={(event) => updateSettings({uiZoom: Number((event.currentTarget as HTMLInputElement).value) / 100})}
/>
<button class="settings-button" type="button" onclick={() => updateSettings({uiZoom: 1})}>Reset</button>
</div>
</div>
<div class="settings-row">
<div>
<div class="settings-label">Server URL</div>
<div class="settings-desc">Base URL for the Suwayomi server.</div>
</div>
<input
class="settings-input settings-input-wide"
spellcheck="false"
value={settingsState.serverUrl}
oninput={(event) => updateSettings({serverUrl: (event.currentTarget as HTMLInputElement).value})}
/>
</div>
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Auto-start server</div>
<div class="settings-desc">Launch the server when Moku starts.</div>
</div>
<input type="checkbox" checked={settingsState.autoStartServer} onchange={() => updateSettings({autoStartServer: !settingsState.autoStartServer})} />
</label>
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Suwayomi Web UI</div>
<div class="settings-desc">Keep the server's web UI enabled alongside Moku.</div>
</div>
<input type="checkbox" checked={settingsState.suwayomiWebUI} onchange={() => updateSettings({suwayomiWebUI: !settingsState.suwayomiWebUI})} />
</label>
<div class="settings-row settings-row-stack">
<div class="settings-row-head">
<div>
<div class="settings-label">Advanced server options</div>
<div class="settings-desc">Custom binary path and launch args.</div>
</div>
<button class="settings-button" type="button" onclick={() => advancedOpen = !advancedOpen}>{advancedOpen ? 'Hide' : 'Show'}</button>
</div>
{#if advancedOpen}
<div class="settings-subcard">
<label class="settings-mini-row">
<span class="settings-label">Server binary</span>
<input
class="settings-input settings-input-wide"
spellcheck="false"
placeholder="auto-detect"
value={settingsState.serverBinary}
oninput={(event) => updateSettings({serverBinary: (event.currentTarget as HTMLInputElement).value})}
/>
</label>
<label class="settings-mini-row">
<span class="settings-label">Server args</span>
<input
class="settings-input settings-input-wide"
spellcheck="false"
placeholder=""
value={settingsState.serverBinaryArgs}
oninput={(event) => updateSettings({serverBinaryArgs: (event.currentTarget as HTMLInputElement).value})}
/>
</label>
</div>
{/if}
</div>
<div class="settings-row">
<div>
<div class="settings-label">Idle screen timeout</div>
<div class="settings-desc">Show the splash screen after inactivity.</div>
</div>
<select class="settings-select" value={String(settingsState.idleTimeoutMin ?? 5)} onchange={(event) => updateSettings({idleTimeoutMin: Number((event.currentTarget as HTMLSelectElement).value)})}>
{#each idleChoices as minutes}
<option value={String(minutes)}>{minutes === 0 ? 'Never' : `${minutes} min`}</option>
{/each}
</select>
</div>
<div class="settings-row">
<div>
<div class="settings-label">Close button behavior</div>
<div class="settings-desc">Choose what the window close button does.</div>
</div>
<select class="settings-select" value={settingsState.closeAction} onchange={(event) => updateSettings({closeAction: (event.currentTarget as HTMLSelectElement).value as 'ask' | 'tray' | 'quit'})}>
<option value="ask">Ask</option>
<option value="tray">Tray</option>
<option value="quit">Quit</option>
</select>
</div>
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Discord Rich Presence</div>
<div class="settings-desc">Show what you're reading in Discord.</div>
</div>
<input type="checkbox" checked={settingsState.discordRpc} onchange={() => updateSettings({discordRpc: !settingsState.discordRpc})} />
</label>
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">QOL animations</div>
<div class="settings-desc">Enable small hover and transition effects.</div>
</div>
<input type="checkbox" checked={settingsState.qolAnimations} onchange={() => updateSettings({qolAnimations: !settingsState.qolAnimations})} />
</label>
<div class="settings-row">
<div>
<div class="settings-label">Preferred source language</div>
<div class="settings-desc">Used for search defaults and source sorting.</div>
</div>
<input
class="settings-input settings-input-narrow"
spellcheck="false"
value={settingsState.preferredExtensionLang}
oninput={(event) => updateSettings({preferredExtensionLang: (event.currentTarget as HTMLInputElement).value.trim().toLowerCase()})}
/>
</div>
</div>
</section>
+87
View File
@@ -0,0 +1,87 @@
<script lang="ts">
import { eventToKeybind } from '$lib/core/keybinds/keybindEngine'
import { DEFAULT_KEYBINDS, KEYBIND_LABELS, type Keybinds } from '$lib/core/keybinds/defaultBinds'
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
let listeningKey = $state<keyof Keybinds | null>(null)
function startListen(key: keyof Keybinds) {
listeningKey = listeningKey === key ? null : key
}
function setBinding(key: keyof Keybinds, binding: string) {
updateSettings({keybinds: {...settingsState.keybinds, [key]: binding}})
}
$effect(() => {
if (!listeningKey || typeof window === 'undefined') return
const handler = (event: KeyboardEvent) => {
event.preventDefault()
event.stopPropagation()
const binding = eventToKeybind(event)
if (!binding) return
setBinding(listeningKey, binding)
listeningKey = null
}
window.addEventListener('keydown', handler, true)
return () => window.removeEventListener('keydown', handler, true)
})
</script>
<svelte:head>
<title>Settings - Keybinds</title>
</svelte:head>
<section class="settings-page">
<header class="settings-page-header">
<p class="settings-kicker">Keybinds</p>
<h2>Keyboard shortcuts</h2>
<p>Click a binding and press the shortcut you want to use.</p>
</header>
<div class="settings-card settings-keybinds-card">
<div class="settings-row settings-row-head">
<div>
<div class="settings-label">Shortcut bindings</div>
<div class="settings-desc">Reset any binding individually or all at once.</div>
</div>
<button class="settings-button" type="button" onclick={() => updateSettings({keybinds: {...DEFAULT_KEYBINDS}})}>Reset all</button>
</div>
{#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]}
<div class="settings-row settings-keybind-row">
<div>
<div class="settings-label">{KEYBIND_LABELS[bindKey]}</div>
</div>
<div class="settings-keybind-actions">
<button class="settings-button" type="button" onclick={() => startListen(bindKey)}>{isListening ? 'Press a key…' : settingsState.keybinds[bindKey]}</button>
<button class="settings-button" type="button" disabled={isDefault} onclick={() => setBinding(bindKey, DEFAULT_KEYBINDS[bindKey])}>Reset</button>
</div>
</div>
{/each}
</div>
</section>
<style>
:global(.settings-keybinds-card) {
gap: 0;
}
:global(.settings-keybind-row) {
align-items: center;
}
:global(.settings-keybind-actions) {
display: flex;
gap: var(--sp-3);
flex-wrap: wrap;
justify-content: flex-end;
}
</style>
+114
View File
@@ -0,0 +1,114 @@
<script lang="ts">
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
const sortDirs = [
['desc', 'Newest first'],
['asc', 'Oldest first'],
] as const
const sortModes = [
['az', 'A-Z'],
['unreadCount', 'Unread count'],
['totalChapters', 'Total chapters'],
['recentlyAdded', 'Recently added'],
['recentlyRead', 'Recently read'],
['latestFetched', 'Latest fetched'],
['latestUploaded', 'Latest uploaded'],
] as const
</script>
<svelte:head>
<title>Settings - Library</title>
</svelte:head>
<section class="settings-page">
<header class="settings-page-header">
<p class="settings-kicker">Library</p>
<h2>Library display and sorting</h2>
<p>How manga cards and chapter lists are shown in the library.</p>
</header>
<div class="settings-card">
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Always show card stats</div>
<div class="settings-desc">Show unread and download counts without hovering.</div>
</div>
<input type="checkbox" checked={settingsState.libraryStatsAlways} onchange={() => updateSettings({libraryStatsAlways: !settingsState.libraryStatsAlways})} />
</label>
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Crop cover images</div>
<div class="settings-desc">Fill cards with cover art instead of letterboxing.</div>
</div>
<input type="checkbox" checked={settingsState.libraryCropCovers} onchange={() => updateSettings({libraryCropCovers: !settingsState.libraryCropCovers})} />
</label>
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Show all in Saved tab</div>
<div class="settings-desc">Include manga that are already in folders.</div>
</div>
<input type="checkbox" checked={settingsState.libraryShowAllInSaved} onchange={() => updateSettings({libraryShowAllInSaved: !settingsState.libraryShowAllInSaved})} />
</label>
{#if settingsState.libraryShowAllInSaved}
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Hide completed in Saved tab</div>
<div class="settings-desc">Keep completed manga out of the Saved view.</div>
</div>
<input type="checkbox" checked={settingsState.libraryHideCompletedInSaved} onchange={() => updateSettings({libraryHideCompletedInSaved: !settingsState.libraryHideCompletedInSaved})} />
</label>
{/if}
<div class="settings-row settings-grid-2">
<label>
<div class="settings-label">Default chapter sort direction</div>
<select class="settings-select" value={settingsState.chapterSortDir} onchange={(event) => updateSettings({chapterSortDir: (event.currentTarget as HTMLSelectElement).value as 'desc' | 'asc'})}>
{#each sortDirs as [value, label]}
<option value={value}>{label}</option>
{/each}
</select>
</label>
<label>
<div class="settings-label">Default chapter sort mode</div>
<select class="settings-select" value={settingsState.chapterSortMode} onchange={(event) => updateSettings({chapterSortMode: (event.currentTarget as HTMLSelectElement).value as 'source' | 'chapterNumber' | 'uploadDate'})}>
{#each sortModes as [value, label]}
<option value={value}>{label}</option>
{/each}
</select>
</label>
</div>
<div class="settings-row settings-grid-2">
<label>
<div class="settings-label">Library page size</div>
<input class="settings-input settings-input-narrow" type="number" min="1" max="200" value={settingsState.libraryPageSize} oninput={(event) => updateSettings({libraryPageSize: Number((event.currentTarget as HTMLInputElement).value) || 1})} />
</label>
<label>
<div class="settings-label">Chapter page size</div>
<input class="settings-input settings-input-narrow" type="number" min="1" max="200" value={settingsState.chapterPageSize} oninput={(event) => updateSettings({chapterPageSize: Number((event.currentTarget as HTMLInputElement).value) || 1})} />
</label>
</div>
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Auto-link on open</div>
<div class="settings-desc">Try to link a manga to similar entries when opened.</div>
</div>
<input type="checkbox" checked={settingsState.autoLinkOnOpen} onchange={() => updateSettings({autoLinkOnOpen: !settingsState.autoLinkOnOpen})} />
</label>
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Disable auto-complete</div>
<div class="settings-desc">Do not move manga to Completed automatically.</div>
</div>
<input type="checkbox" checked={settingsState.disableAutoComplete} onchange={() => updateSettings({disableAutoComplete: !settingsState.disableAutoComplete})} />
</label>
</div>
</section>
@@ -0,0 +1,63 @@
<script lang="ts">
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
const renderLimits = [48, 96, 144, 300, 500]
</script>
<svelte:head>
<title>Settings - Performance</title>
</svelte:head>
<section class="settings-page">
<header class="settings-page-header">
<p class="settings-kicker">Performance</p>
<h2>Render and behavior tuning</h2>
<p>Keep the app light or turn up quality-of-life options.</p>
</header>
<div class="settings-card">
<div class="settings-row settings-grid-2">
<label>
<div class="settings-label">Render limit</div>
<select class="settings-select" value={String(settingsState.renderLimit)} onchange={(event) => updateSettings({renderLimit: Number((event.currentTarget as HTMLSelectElement).value)})}>
{#each renderLimits as value}
<option value={String(value)}>{value}</option>
{/each}
</select>
</label>
<label>
<div class="settings-label">Reader debounce</div>
<input class="settings-input settings-input-narrow" type="number" min="0" max="1000" value={settingsState.readerDebounceMs} oninput={(event) => updateSettings({readerDebounceMs: Number((event.currentTarget as HTMLInputElement).value) || 0})} />
</label>
</div>
<div class="settings-row settings-grid-2">
<label>
<div class="settings-label">Library page size</div>
<input class="settings-input settings-input-narrow" type="number" min="1" max="200" value={settingsState.libraryPageSize} oninput={(event) => updateSettings({libraryPageSize: Number((event.currentTarget as HTMLInputElement).value) || 1})} />
</label>
<label>
<div class="settings-label">Chapter page size</div>
<input class="settings-input settings-input-narrow" type="number" min="1" max="200" value={settingsState.chapterPageSize} oninput={(event) => updateSettings({chapterPageSize: Number((event.currentTarget as HTMLInputElement).value) || 1})} />
</label>
</div>
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">GPU acceleration</div>
<div class="settings-desc">Use the GPU for rendering when available.</div>
</div>
<input type="checkbox" checked={settingsState.gpuAcceleration} onchange={() => updateSettings({gpuAcceleration: !settingsState.gpuAcceleration})} />
</label>
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">QOL animations</div>
<div class="settings-desc">Hover lifts and transition polish.</div>
</div>
<input type="checkbox" checked={settingsState.qolAnimations} onchange={() => updateSettings({qolAnimations: !settingsState.qolAnimations})} />
</label>
</div>
</section>
+160
View File
@@ -0,0 +1,160 @@
<script lang="ts">
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
const pageStyles = [
['longstrip', 'Long strip'],
['single', 'Single page'],
['double', 'Double page'],
] as const
const readingDirections = [
['ltr', 'Left to right'],
['rtl', 'Right to left'],
] as const
const fitModes = [
['width', 'Fit width'],
['height', 'Fit height'],
['screen', 'Fit screen'],
['original', 'Original'],
] as const
</script>
<svelte:head>
<title>Settings - Reader</title>
</svelte:head>
<section class="settings-page">
<header class="settings-page-header">
<p class="settings-kicker">Reader</p>
<h2>Reading defaults</h2>
<p>Behavior and layout for the full-screen reader.</p>
</header>
<div class="settings-card">
<div class="settings-row settings-grid-2">
<label>
<div class="settings-label">Default page style</div>
<select class="settings-select" value={settingsState.pageStyle} onchange={(event) => updateSettings({pageStyle: (event.currentTarget as HTMLSelectElement).value as 'single' | 'double' | 'longstrip'})}>
{#each pageStyles as [value, label]}
<option value={value}>{label}</option>
{/each}
</select>
</label>
<label>
<div class="settings-label">Reading direction</div>
<select class="settings-select" value={settingsState.readingDirection} onchange={(event) => updateSettings({readingDirection: (event.currentTarget as HTMLSelectElement).value as 'ltr' | 'rtl'})}>
{#each readingDirections as [value, label]}
<option value={value}>{label}</option>
{/each}
</select>
</label>
</div>
<div class="settings-row settings-grid-2">
<label>
<div class="settings-label">Default fit mode</div>
<select class="settings-select" value={settingsState.fitMode} onchange={(event) => updateSettings({fitMode: (event.currentTarget as HTMLSelectElement).value as 'width' | 'height' | 'screen' | 'original'})}>
{#each fitModes as [value, label]}
<option value={value}>{label}</option>
{/each}
</select>
</label>
<label>
<div class="settings-label">Reader zoom</div>
<input
class="settings-slider"
type="range"
min="10"
max="100"
step="5"
value={Math.round((settingsState.readerZoom ?? 0.5) * 100)}
oninput={(event) => updateSettings({readerZoom: Number((event.currentTarget as HTMLInputElement).value) / 100})}
/>
</label>
</div>
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Page gap</div>
<div class="settings-desc">Adds spacing between pages in single-page mode.</div>
</div>
<input type="checkbox" checked={settingsState.pageGap} onchange={() => updateSettings({pageGap: !settingsState.pageGap})} />
</label>
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Overlay bars</div>
<div class="settings-desc">Float reader bars over the content.</div>
</div>
<input type="checkbox" checked={settingsState.overlayBars} onchange={() => updateSettings({overlayBars: !settingsState.overlayBars})} />
</label>
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Tap to toggle bar</div>
<div class="settings-desc">Double tap the reader to show or hide the bars.</div>
</div>
<input type="checkbox" checked={settingsState.tapToToggleBar} onchange={() => updateSettings({tapToToggleBar: !settingsState.tapToToggleBar})} />
</label>
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Optimize contrast</div>
<div class="settings-desc">Boost line contrast for black-and-white pages.</div>
</div>
<input type="checkbox" checked={settingsState.optimizeContrast} onchange={() => updateSettings({optimizeContrast: !settingsState.optimizeContrast})} />
</label>
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Auto-mark read</div>
<div class="settings-desc">Mark chapters read at the end of the last page.</div>
</div>
<input type="checkbox" checked={settingsState.autoMarkRead} onchange={() => updateSettings({autoMarkRead: !settingsState.autoMarkRead})} />
</label>
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Auto-advance chapters</div>
<div class="settings-desc">Open the next chapter automatically when you finish.</div>
</div>
<input type="checkbox" checked={settingsState.autoNextChapter} onchange={() => updateSettings({autoNextChapter: !settingsState.autoNextChapter})} />
</label>
{#if !settingsState.autoNextChapter}
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Mark read when skipping</div>
<div class="settings-desc">Mark the current chapter read when you skip ahead manually.</div>
</div>
<input type="checkbox" checked={settingsState.markReadOnNext} onchange={() => updateSettings({markReadOnNext: !settingsState.markReadOnNext})} />
</label>
{/if}
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Auto-bookmark</div>
<div class="settings-desc">Save page position while reading.</div>
</div>
<input type="checkbox" checked={settingsState.autoBookmark} onchange={() => updateSettings({autoBookmark: !settingsState.autoBookmark})} />
</label>
<div class="settings-row">
<div>
<div class="settings-label">Pages to preload</div>
<div class="settings-desc">How many pages ahead to fetch in the background.</div>
</div>
<input
class="settings-input settings-input-narrow"
type="number"
min="0"
max="10"
value={settingsState.preloadPages}
oninput={(event) => updateSettings({preloadPages: Math.max(0, Math.min(10, Number((event.currentTarget as HTMLInputElement).value) || 0))})}
/>
</div>
</div>
</section>
+149
View File
@@ -0,0 +1,149 @@
<script lang="ts">
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
const authModes = [
['NONE', 'Disabled'],
['BASIC_AUTH', 'Basic auth'],
['SIMPLE_LOGIN', 'Simple login'],
['UI_LOGIN', 'UI login'],
] as const
const proxyVersions = [
[4, 'SOCKS4'],
[5, 'SOCKS5'],
] as const
</script>
<svelte:head>
<title>Settings - Security</title>
</svelte:head>
<section class="settings-page">
<header class="settings-page-header">
<p class="settings-kicker">Security</p>
<h2>Server access and proxy settings</h2>
<p>Authentication, SOCKS proxy, FlareSolverr, and app lock options.</p>
</header>
<div class="settings-card">
<div class="settings-row settings-grid-2">
<label>
<div class="settings-label">Server auth mode</div>
<select class="settings-select" value={settingsState.serverAuthMode} onchange={(event) => updateSettings({serverAuthMode: (event.currentTarget as HTMLSelectElement).value as 'NONE' | 'BASIC_AUTH' | 'SIMPLE_LOGIN' | 'UI_LOGIN'})}>
{#each authModes as [value, label]}
<option value={value}>{label}</option>
{/each}
</select>
</label>
<label>
<div class="settings-label">Username</div>
<input class="settings-input settings-input-wide" spellcheck="false" value={settingsState.serverAuthUser} oninput={(event) => updateSettings({serverAuthUser: (event.currentTarget as HTMLInputElement).value})} />
</label>
</div>
<div class="settings-row settings-grid-2">
<label>
<div class="settings-label">Password</div>
<input class="settings-input settings-input-wide" type="password" value={settingsState.serverAuthPass} oninput={(event) => updateSettings({serverAuthPass: (event.currentTarget as HTMLInputElement).value})} />
</label>
<label>
<div class="settings-label">App lock PIN</div>
<input class="settings-input settings-input-wide" type="password" value={settingsState.appLockPin} oninput={(event) => updateSettings({appLockPin: (event.currentTarget as HTMLInputElement).value})} />
</label>
</div>
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">App lock</div>
<div class="settings-desc">Require the PIN when opening the app.</div>
</div>
<input type="checkbox" checked={settingsState.appLockEnabled} onchange={() => updateSettings({appLockEnabled: !settingsState.appLockEnabled})} />
</label>
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">SOCKS proxy</div>
<div class="settings-desc">Route server requests through a SOCKS proxy.</div>
</div>
<input type="checkbox" checked={settingsState.socksProxyEnabled} onchange={() => updateSettings({socksProxyEnabled: !settingsState.socksProxyEnabled})} />
</label>
{#if settingsState.socksProxyEnabled}
<div class="settings-row settings-grid-2">
<label>
<div class="settings-label">Proxy host</div>
<input class="settings-input settings-input-wide" spellcheck="false" value={settingsState.socksProxyHost} oninput={(event) => updateSettings({socksProxyHost: (event.currentTarget as HTMLInputElement).value})} />
</label>
<label>
<div class="settings-label">Proxy port</div>
<input class="settings-input settings-input-narrow" spellcheck="false" value={settingsState.socksProxyPort} oninput={(event) => updateSettings({socksProxyPort: (event.currentTarget as HTMLInputElement).value})} />
</label>
</div>
<div class="settings-row settings-grid-2">
<label>
<div class="settings-label">Proxy version</div>
<select class="settings-select" value={String(settingsState.socksProxyVersion)} onchange={(event) => updateSettings({socksProxyVersion: Number((event.currentTarget as HTMLSelectElement).value)})}>
{#each proxyVersions as [value, label]}
<option value={String(value)}>{label}</option>
{/each}
</select>
</label>
<label>
<div class="settings-label">Proxy username</div>
<input class="settings-input settings-input-wide" spellcheck="false" value={settingsState.socksProxyUsername} oninput={(event) => updateSettings({socksProxyUsername: (event.currentTarget as HTMLInputElement).value})} />
</label>
</div>
<label class="settings-row">
<div>
<div class="settings-label">Proxy password</div>
<div class="settings-desc">Stored locally and used for outgoing requests.</div>
</div>
<input class="settings-input settings-input-wide" type="password" value={settingsState.socksProxyPassword} oninput={(event) => updateSettings({socksProxyPassword: (event.currentTarget as HTMLInputElement).value})} />
</label>
{/if}
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">FlareSolverr</div>
<div class="settings-desc">Use FlareSolverr for sites protected by anti-bot challenges.</div>
</div>
<input type="checkbox" checked={settingsState.flareSolverrEnabled} onchange={() => updateSettings({flareSolverrEnabled: !settingsState.flareSolverrEnabled})} />
</label>
{#if settingsState.flareSolverrEnabled}
<div class="settings-row settings-grid-2">
<label>
<div class="settings-label">FlareSolverr URL</div>
<input class="settings-input settings-input-wide" spellcheck="false" value={settingsState.flareSolverrUrl} oninput={(event) => updateSettings({flareSolverrUrl: (event.currentTarget as HTMLInputElement).value})} />
</label>
<label>
<div class="settings-label">Timeout</div>
<input class="settings-input settings-input-narrow" type="number" min="1" value={settingsState.flareSolverrTimeout} oninput={(event) => updateSettings({flareSolverrTimeout: Number((event.currentTarget as HTMLInputElement).value) || 1})} />
</label>
</div>
<div class="settings-row settings-grid-2">
<label>
<div class="settings-label">Session name</div>
<input class="settings-input settings-input-wide" spellcheck="false" value={settingsState.flareSolverrSessionName} oninput={(event) => updateSettings({flareSolverrSessionName: (event.currentTarget as HTMLInputElement).value})} />
</label>
<label>
<div class="settings-label">Session TTL</div>
<input class="settings-input settings-input-narrow" type="number" min="1" value={settingsState.flareSolverrSessionTtl} oninput={(event) => updateSettings({flareSolverrSessionTtl: Number((event.currentTarget as HTMLInputElement).value) || 1})} />
</label>
</div>
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Fallback to response mode</div>
<div class="settings-desc">Use FlareSolverr responses directly when needed.</div>
</div>
<input type="checkbox" checked={settingsState.flareSolverrAsResponseFallback} onchange={() => updateSettings({flareSolverrAsResponseFallback: !settingsState.flareSolverrAsResponseFallback})} />
</label>
{/if}
</div>
</section>
+37
View File
@@ -0,0 +1,37 @@
<script lang="ts">
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
</script>
<svelte:head>
<title>Settings - Storage</title>
</svelte:head>
<section class="settings-page">
<header class="settings-page-header">
<p class="settings-kicker">Storage</p>
<h2>Paths and limits</h2>
<p>Control where Moku stores downloads and local sources.</p>
</header>
<div class="settings-card">
<div class="settings-row">
<div>
<div class="settings-label">Storage limit</div>
<div class="settings-desc">Maximum local storage in gigabytes. Leave blank for no limit.</div>
</div>
<input class="settings-input settings-input-narrow" type="number" min="0" step="1" value={settingsState.storageLimitGb ?? ''} oninput={(event) => { const value = (event.currentTarget as HTMLInputElement).value; updateSettings({storageLimitGb: value === '' ? null : Number(value)}); }} />
</div>
<div class="settings-row settings-grid-2">
<label>
<div class="settings-label">Downloads path</div>
<input class="settings-input settings-input-wide" spellcheck="false" value={settingsState.serverDownloadsPath} oninput={(event) => updateSettings({serverDownloadsPath: (event.currentTarget as HTMLInputElement).value})} />
</label>
<label>
<div class="settings-label">Local source path</div>
<input class="settings-input settings-input-wide" spellcheck="false" value={settingsState.serverLocalSourcePath} oninput={(event) => updateSettings({serverLocalSourcePath: (event.currentTarget as HTMLInputElement).value})} />
</label>
</div>
</div>
</section>
+264
View File
@@ -0,0 +1,264 @@
<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
}
}
`
let oauthTrackerId = $state<number | null>(null)
let oauthCallback = $state('')
let credsTrackerId = $state<number | null>(null)
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
} 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()})
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,
})
credsTrackerId = null
credsUsername = ''
credsPassword = ''
await refreshTrackers()
}
async function disconnectTracker(trackerId: number) {
await gql(LOGOUT_TRACKER, {trackerId})
await refreshTrackers()
}
async function syncAllTrackers() {
trackingState.syncing = true
try {
const mangaIds = new Set<number>()
for (const tracker of trackingState.trackers) {
for (const record of tracker.trackRecords?.nodes ?? []) {
if (record.manga?.id) mangaIds.add(record.manga.id)
}
}
for (const mangaId of mangaIds) {
await syncTracking(String(mangaId))
}
} finally {
trackingState.syncing = false
}
}
function openOAuth(tracker: GqlTracker) {
if (tracker.authUrl) window.open(tracker.authUrl, '_blank', 'noopener')
oauthTrackerId = tracker.id
oauthCallback = ''
credsTrackerId = null
}
function openCredentials(tracker: GqlTracker) {
credsTrackerId = tracker.id
credsUsername = ''
credsPassword = ''
oauthTrackerId = null
}
onMount(() => {
void refreshTrackers()
})
</script>
<svelte:head>
<title>Settings - Tracking</title>
</svelte:head>
<section class="settings-page">
<header class="settings-page-header">
<p class="settings-kicker">Tracking</p>
<h2>Tracker connections</h2>
<p>Connect trackers and sync progress back to your library.</p>
</header>
<div class="settings-card">
<div class="settings-row">
<div>
<div class="settings-label">Connected trackers</div>
<div class="settings-desc">{trackingState.loading ? 'Loading…' : `${trackingState.trackers.length} trackers found`}</div>
</div>
<button class="settings-button" type="button" onclick={() => void refreshTrackers()}>Refresh</button>
</div>
{#each trackingState.trackers as tracker}
<div class="settings-row settings-tracker-row">
<div>
<div class="settings-label">{tracker.name}</div>
<div class="settings-desc">{tracker.isLoggedIn ? 'Connected' : 'Not connected'}{tracker.isTokenExpired ? ' · token expired' : ''}</div>
</div>
<div class="settings-tracker-actions">
{#if tracker.isLoggedIn}
<button class="settings-button" type="button" onclick={() => void disconnectTracker(tracker.id)}>Disconnect</button>
{:else}
<button class="settings-button" type="button" onclick={() => tracker.authUrl ? openOAuth(tracker) : openCredentials(tracker)}>{tracker.authUrl ? 'Open login' : 'Connect'}</button>
{/if}
</div>
</div>
{#if oauthTrackerId === tracker.id}
<div class="settings-row settings-row-stack">
<div>
<div class="settings-label">OAuth callback URL</div>
<div class="settings-desc">Paste the callback URL after authorizing in the browser.</div>
</div>
<input class="settings-input settings-input-wide" spellcheck="false" placeholder="https://…#access_token=…" bind:value={oauthCallback} />
<div class="settings-inline-control">
<button class="settings-button" type="button" onclick={() => void reconnectOAuth()}>Connect</button>
<button class="settings-button" type="button" onclick={() => { oauthTrackerId = null; oauthCallback = ''; }}>Cancel</button>
</div>
</div>
{/if}
{#if credsTrackerId === tracker.id}
<div class="settings-row settings-row-stack">
<div>
<div class="settings-label">Tracker login</div>
<div class="settings-desc">Use a username and password to connect.</div>
</div>
<div class="settings-grid-2">
<input class="settings-input settings-input-wide" placeholder="Username" bind:value={credsUsername} />
<input class="settings-input settings-input-wide" type="password" placeholder="Password" bind:value={credsPassword} />
</div>
<div class="settings-inline-control">
<button class="settings-button" type="button" onclick={() => void connectCredentials()}>Connect</button>
<button class="settings-button" type="button" onclick={() => { credsTrackerId = null; credsUsername = ''; credsPassword = ''; }}>Cancel</button>
</div>
</div>
{/if}
{/each}
<div class="settings-row">
<div>
<div class="settings-label">Sync back now</div>
<div class="settings-desc">Apply tracker progress to all linked manga in your library.</div>
</div>
<button class="settings-button" type="button" onclick={() => void syncAllTrackers()} disabled={trackingState.syncing}>Sync all</button>
</div>
</div>
</section>
<style>
:global(.settings-tracker-row) {
border-top: 1px solid var(--border-dim);
}
:global(.settings-tracker-actions) {
display: flex;
gap: var(--sp-3);
flex-wrap: wrap;
justify-content: flex-end;
}
</style>