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,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
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 { applyZoom, mountZoomKey } from '$lib/core/ui/zoom'
|
||||
import { appState } from '$lib/state/app.svelte'
|
||||
@@ -40,6 +40,7 @@
|
||||
onMount(() => {
|
||||
applyTheme(settingsState.theme, settingsState.customThemes)
|
||||
applyZoom(settingsState.uiZoom)
|
||||
mountSystemThemeSync()
|
||||
|
||||
const stopZoomKey = mountZoomKey(
|
||||
() => settingsState.uiZoom,
|
||||
@@ -77,6 +78,7 @@
|
||||
stopZoomKey()
|
||||
stopIdleDetection()
|
||||
window.removeEventListener('resize', handleResize)
|
||||
unmountSystemThemeSync()
|
||||
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