mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Migrate remaining routes
This commit is contained in:
@@ -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;
|
||||||
@@ -38,4 +39,41 @@ 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;
|
||||||
}
|
}
|
||||||
+32
-32
@@ -1,17 +1,17 @@
|
|||||||
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";
|
||||||
|
|
||||||
export function timeAgo(ts: number): string {
|
export function timeAgo(ts: number): string {
|
||||||
const diff = Date.now() - ts, m = Math.floor(diff / 60000);
|
const diff = Date.now() - ts, m = Math.floor(diff / 60000);
|
||||||
if (m < 1) return "Just now";
|
if (m < 1) return "Just now";
|
||||||
if (m < 60) return `${m}m ago`;
|
if (m < 60) return `${m}m ago`;
|
||||||
const h = Math.floor(m / 60);
|
const h = Math.floor(m / 60);
|
||||||
if (h < 24) return `${h}h ago`;
|
if (h < 24) return `${h}h ago`;
|
||||||
const d = Math.floor(h / 24);
|
const d = Math.floor(h / 24);
|
||||||
if (d < 7) return `${d}d ago`;
|
if (d < 7) return `${d}d ago`;
|
||||||
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
return new Date(ts).toLocaleDateString("en-US", {month: "short", day: "numeric"});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function dayLabel(ts: number): string {
|
export function dayLabel(ts: number): string {
|
||||||
@@ -19,11 +19,11 @@ export function dayLabel(ts: number): string {
|
|||||||
if (d.toDateString() === now.toDateString()) return "Today";
|
if (d.toDateString() === now.toDateString()) return "Today";
|
||||||
const yest = new Date(now); yest.setDate(now.getDate() - 1);
|
const yest = new Date(now); yest.setDate(now.getDate() - 1);
|
||||||
if (d.toDateString() === yest.toDateString()) return "Yesterday";
|
if (d.toDateString() === yest.toDateString()) return "Yesterday";
|
||||||
return d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" });
|
return d.toLocaleDateString("en-US", {weekday: "long", month: "long", day: "numeric"});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatReadTime(m: number): string {
|
export function formatReadTime(m: number): string {
|
||||||
if (m < 1) return "< 1 min";
|
if (m < 1) return "< 1 min";
|
||||||
if (m < 60) return `${m} min`;
|
if (m < 60) return `${m} min`;
|
||||||
const h = Math.floor(m / 60), r = m % 60;
|
const h = Math.floor(m / 60), r = m % 60;
|
||||||
return r === 0 ? `${h}h` : `${h}h ${r}m`;
|
return r === 0 ? `${h}h` : `${h}h ${r}m`;
|
||||||
@@ -46,7 +46,7 @@ type ContentFilterSettings = Pick<
|
|||||||
>;
|
>;
|
||||||
|
|
||||||
function blockedTagsForSettings(settings: ContentFilterSettings): string[] {
|
function blockedTagsForSettings(settings: ContentFilterSettings): string[] {
|
||||||
if (settings.contentLevel === "strict") return STRICT_TAGS;
|
if (settings.contentLevel === "strict") return STRICT_TAGS;
|
||||||
if (settings.contentLevel === "moderate") return MODERATE_TAGS;
|
if (settings.contentLevel === "moderate") return MODERATE_TAGS;
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -59,7 +59,7 @@ function genreMatchesBlocklist(genre: string[], blockedTags: string[]): boolean
|
|||||||
const idx = norm.indexOf(tag);
|
const idx = norm.indexOf(tag);
|
||||||
if (idx === -1) return false;
|
if (idx === -1) return false;
|
||||||
const before = idx === 0 || /\W/.test(norm[idx - 1]);
|
const before = idx === 0 || /\W/.test(norm[idx - 1]);
|
||||||
const after = idx + tag.length === norm.length || /\W/.test(norm[idx + tag.length]);
|
const after = idx + tag.length === norm.length || /\W/.test(norm[idx + tag.length]);
|
||||||
return before && after;
|
return before && after;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -71,7 +71,7 @@ export function shouldHideNsfw(
|
|||||||
): boolean {
|
): boolean {
|
||||||
if (settings.contentLevel === "unrestricted") return false;
|
if (settings.contentLevel === "unrestricted") return false;
|
||||||
|
|
||||||
const srcId = manga.source?.id;
|
const srcId = manga.source?.id;
|
||||||
const blocked = settings.sourceOverridesEnabled ? (settings.nsfwBlockedSourceIds ?? []) : [];
|
const blocked = settings.sourceOverridesEnabled ? (settings.nsfwBlockedSourceIds ?? []) : [];
|
||||||
const allowed = settings.sourceOverridesEnabled ? (settings.nsfwAllowedSourceIds ?? []) : [];
|
const allowed = settings.sourceOverridesEnabled ? (settings.nsfwAllowedSourceIds ?? []) : [];
|
||||||
|
|
||||||
@@ -99,19 +99,19 @@ export function shouldHideSource(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function dedupeSourcesByLang(
|
export function dedupeSourcesByLang(
|
||||||
sources: Source[],
|
sources: Source[],
|
||||||
preferredLang: string,
|
preferredLang: string,
|
||||||
settings: ContentFilterSettings,
|
settings: ContentFilterSettings,
|
||||||
applyHide = false,
|
applyHide = false,
|
||||||
): Source[] {
|
): Source[] {
|
||||||
const map = new Map<string, Source>();
|
const map = new Map<string, Source>();
|
||||||
for (const s of sources) {
|
for (const s of sources) {
|
||||||
if (s.id === "0") continue;
|
if (s.id === "0") continue;
|
||||||
if (applyHide && shouldHideSource(s, settings)) continue;
|
if (applyHide && shouldHideSource(s, settings)) continue;
|
||||||
const existing = map.get(s.name);
|
const existing = map.get(s.name);
|
||||||
if (!existing) { map.set(s.name, s); continue; }
|
if (!existing) {map.set(s.name, s); continue;}
|
||||||
const existingPref = existing.lang === preferredLang;
|
const existingPref = existing.lang === preferredLang;
|
||||||
const newPref = s.lang === preferredLang;
|
const newPref = s.lang === preferredLang;
|
||||||
if (newPref && !existingPref) map.set(s.name, s);
|
if (newPref && !existingPref) map.set(s.name, s);
|
||||||
else if (!existingPref && !newPref && s.lang < existing.lang) map.set(s.name, s);
|
else if (!existingPref && !newPref && s.lang < existing.lang) map.set(s.name, s);
|
||||||
}
|
}
|
||||||
@@ -159,36 +159,36 @@ function authorFingerprint(author?: string | null, artist?: string | null): stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function dedupeMangaByTitle<T extends {
|
export function dedupeMangaByTitle<T extends {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
author?: string | null;
|
author?: string | null;
|
||||||
artist?: string | null;
|
artist?: string | null;
|
||||||
inLibrary?: boolean;
|
inLibrary?: boolean;
|
||||||
downloadCount?: number;
|
downloadCount?: number;
|
||||||
}>(items: T[], links: Record<number, number[]> = {}): T[] {
|
}>(items: T[], links: Record<number, number[]> = {}): T[] {
|
||||||
const byTitle = new Map<string, number>();
|
const byTitle = new Map<string, number>();
|
||||||
const byDesc = new Map<string, number>();
|
const byDesc = new Map<string, number>();
|
||||||
const byAuthorDesc = new Map<string, number>();
|
const byAuthorDesc = new Map<string, number>();
|
||||||
const byId = new Map<number, number>();
|
const byId = new Map<number, number>();
|
||||||
const out: T[] = [];
|
const out: T[] = [];
|
||||||
|
|
||||||
for (const m of items) {
|
for (const m of items) {
|
||||||
const tk = normalizeTitle(m.title);
|
const tk = normalizeTitle(m.title);
|
||||||
const dk = descFingerprint(m.description);
|
const dk = descFingerprint(m.description);
|
||||||
const ak = (dk && m.author) ? `${authorFingerprint(m.author, m.artist)}||${dk}` : null;
|
const ak = (dk && m.author) ? `${authorFingerprint(m.author, m.artist)}||${dk}` : null;
|
||||||
|
|
||||||
const linkedIds = links[m.id] ?? [];
|
const linkedIds = links[m.id] ?? [];
|
||||||
const linkedIdx = linkedIds.map(lid => byId.get(lid)).find(i => i !== undefined);
|
const linkedIdx = linkedIds.map(lid => byId.get(lid)).find(i => i !== undefined);
|
||||||
const existingIdx =
|
const existingIdx =
|
||||||
linkedIdx ??
|
linkedIdx ??
|
||||||
byTitle.get(tk) ??
|
byTitle.get(tk) ??
|
||||||
(dk ? byDesc.get(dk) : undefined) ??
|
(dk ? byDesc.get(dk) : undefined) ??
|
||||||
(ak ? byAuthorDesc.get(ak) : undefined);
|
(ak ? byAuthorDesc.get(ak) : undefined);
|
||||||
|
|
||||||
if (existingIdx !== undefined) {
|
if (existingIdx !== undefined) {
|
||||||
const existing = out[existingIdx];
|
const existing = out[existingIdx];
|
||||||
const mBetter =
|
const mBetter =
|
||||||
(m.inLibrary && !existing.inLibrary) ||
|
(m.inLibrary && !existing.inLibrary) ||
|
||||||
(!existing.inLibrary && (m.downloadCount ?? 0) > (existing.downloadCount ?? 0));
|
(!existing.inLibrary && (m.downloadCount ?? 0) > (existing.downloadCount ?? 0));
|
||||||
|
|
||||||
@@ -213,11 +213,11 @@ 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) {
|
||||||
if (!seen.has(m.id)) { seen.add(m.id); out.push(m); }
|
if (!seen.has(m.id)) {seen.add(m.id); out.push(m);}
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+119
-118
@@ -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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,182 +331,182 @@ export class SuwayomiAdapter implements ServerAdapter {
|
|||||||
const res = await fetch(`${this.baseUrl}/api/graphql`, {
|
const res = await fetch(`${this.baseUrl}/api/graphql`, {
|
||||||
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> {
|
||||||
const res = await fetch(`${this.baseUrl}/api/graphql`, {
|
const res = await fetch(`${this.baseUrl}/api/graphql`, {
|
||||||
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 [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+62
-19
@@ -1,12 +1,13 @@
|
|||||||
import type { Keybinds } from "$lib/core/keybinds/defaultBinds";
|
import type {Keybinds} from "$lib/core/keybinds/defaultBinds";
|
||||||
|
|
||||||
export type PageStyle = "single" | "double" | "longstrip";
|
export type PageStyle = "single" | "double" | "longstrip";
|
||||||
export type FitMode = "width" | "height" | "screen" | "original";
|
export type FitMode = "width" | "height" | "screen" | "original";
|
||||||
export type LibraryFilter = "all" | "library" | "downloaded" | string;
|
export type LibraryFilter = "all" | "library" | "downloaded" | string;
|
||||||
export type ReadingDirection = "ltr" | "rtl";
|
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"
|
||||||
@@ -14,11 +15,11 @@ export type LibrarySortMode =
|
|||||||
|
|
||||||
export type LibrarySortDir = "asc" | "desc";
|
export type LibrarySortDir = "asc" | "desc";
|
||||||
|
|
||||||
export type LibraryStatusFilter = "ALL" | "ONGOING" | "COMPLETED" | "CANCELLED" | "HIATUS" | "UNKNOWN";
|
export type LibraryStatusFilter = "ALL" | "ONGOING" | "COMPLETED" | "CANCELLED" | "HIATUS" | "UNKNOWN";
|
||||||
export type LibraryContentFilter = "unread" | "started" | "downloaded" | "bookmarked" | "marked";
|
export type LibraryContentFilter = "unread" | "started" | "downloaded" | "bookmarked" | "marked";
|
||||||
|
|
||||||
export type BuiltinTheme = "original" | "dark" | "light" | "light-contrast" | "midnight" | "warm";
|
export type BuiltinTheme = "original" | "dark" | "light" | "light-contrast" | "midnight" | "warm";
|
||||||
export type Theme = BuiltinTheme | string;
|
export type Theme = BuiltinTheme | string;
|
||||||
|
|
||||||
export interface ThemeTokens {
|
export interface ThemeTokens {
|
||||||
"bg-void": string;
|
"bg-void": string;
|
||||||
@@ -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,20 +124,30 @@ 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;
|
||||||
readingDirection: ReadingDirection;
|
readingDirection: ReadingDirection;
|
||||||
readerZoom: number;
|
readerZoom: number;
|
||||||
pageGap: boolean;
|
pageGap: boolean;
|
||||||
optimizeContrast: boolean;
|
optimizeContrast: boolean;
|
||||||
offsetDoubleSpreads: boolean;
|
offsetDoubleSpreads: boolean;
|
||||||
barPosition?: "top" | "left" | "right";
|
barPosition?: "top" | "left" | "right";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ReaderPreset {
|
export interface ReaderPreset {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
settings: ReaderSettings;
|
settings: ReaderSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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?.()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import {redirect} from '@sveltejs/kit';
|
||||||
|
|
||||||
|
export function load() {
|
||||||
|
throw redirect(302, '/settings/general');
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user