Migrate remaining routes

This commit is contained in:
Zerebos
2026-05-23 17:15:02 -04:00
parent 68f25a2ea7
commit b3fca70f27
22 changed files with 2092 additions and 171 deletions
+370
View File
@@ -0,0 +1,370 @@
<script lang="ts">
import { page } from '$app/stores'
const sections = [
['general', 'General'],
['appearance', 'Appearance'],
['reader', 'Reader'],
['library', 'Library'],
['automation', 'Automation'],
['performance', 'Performance'],
['keybinds', 'Keybinds'],
['storage', 'Storage'],
['folders', 'Folders'],
['tracking', 'Tracking'],
['security', 'Security'],
['content', 'Content'],
['about', 'About'],
['devtools', 'Devtools'],
] as const
let { children } = $props()
const activeSection = $derived(
sections.find(([section]) => $page.url.pathname === `/settings/${section}`)?.[0] ?? 'general'
)
</script>
<div class="settings-shell">
<aside class="settings-nav-panel">
<div class="settings-nav-header">
<p class="settings-kicker">Preferences</p>
<h1>Settings</h1>
<p class="settings-nav-copy">Route-driven sections backed by shared state.</p>
</div>
<nav class="settings-nav" aria-label="Settings sections">
{#each sections as [section, label]}
<a
class="settings-nav-link"
class:active={activeSection === section}
href={`/settings/${section}`}
aria-current={activeSection === section ? 'page' : undefined}
>
{label}
</a>
{/each}
</nav>
</aside>
<main class="settings-main">
{@render children()}
</main>
</div>
<style>
:global(.settings-shell) {
display: grid;
grid-template-columns: 240px minmax(0, 1fr);
width: 100%;
height: 100%;
overflow: hidden;
background: linear-gradient(180deg, color-mix(in srgb, var(--bg-base) 82%, transparent), var(--bg-surface));
}
:global(.settings-nav-panel) {
display: flex;
flex-direction: column;
min-width: 0;
border-right: 1px solid var(--border-dim);
background: color-mix(in srgb, var(--bg-base) 94%, black);
padding: var(--sp-5) var(--sp-4);
overflow: hidden;
}
:global(.settings-nav-header) {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-bottom: var(--sp-4);
}
:global(.settings-kicker) {
margin: 0;
font-size: 0.7rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--text-faint);
}
:global(.settings-nav-header h1) {
margin: 0;
font-size: 1.45rem;
color: var(--text-primary);
}
:global(.settings-nav-copy) {
margin: 0;
color: var(--text-muted);
font-size: 0.9rem;
line-height: 1.45;
}
:global(.settings-nav) {
display: grid;
gap: 0.35rem;
overflow: auto;
padding-right: 0.25rem;
}
:global(.settings-nav-link) {
display: flex;
align-items: center;
min-height: 38px;
padding: 0.55rem 0.8rem;
border-radius: var(--radius-md);
color: var(--text-muted);
text-decoration: none;
transition: background var(--t-base), color var(--t-base), transform var(--t-base);
}
:global(.settings-nav-link:hover) {
background: var(--bg-raised);
color: var(--text-secondary);
}
:global(.settings-nav-link.active) {
background: var(--accent-muted);
color: var(--accent-fg);
}
:global(.settings-main) {
min-width: 0;
min-height: 0;
overflow: auto;
padding: var(--sp-6);
}
:global(.settings-page) {
display: flex;
flex-direction: column;
gap: var(--sp-5);
max-width: 1100px;
margin: 0 auto;
}
:global(.settings-page-header) {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
:global(.settings-page-header h2) {
margin: 0;
font-size: 1.8rem;
color: var(--text-primary);
}
:global(.settings-page-header p) {
margin: 0;
color: var(--text-muted);
line-height: 1.5;
}
:global(.settings-card) {
display: flex;
flex-direction: column;
gap: 1px;
border: 1px solid var(--border-dim);
border-radius: var(--radius-xl);
overflow: hidden;
background: var(--bg-base);
}
:global(.settings-row) {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-4);
padding: var(--sp-4) var(--sp-5);
background: var(--bg-surface);
}
:global(.settings-row-stack) {
flex-direction: column;
align-items: stretch;
}
:global(.settings-row-head) {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sp-4);
}
:global(.settings-toggle-row) {
align-items: center;
}
:global(.settings-grid-2) {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-4);
}
:global(.settings-grid-2 > label) {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
:global(.settings-inline-control) {
display: flex;
align-items: center;
gap: var(--sp-3);
flex-wrap: wrap;
justify-content: flex-end;
}
:global(.settings-subcard) {
display: flex;
flex-direction: column;
gap: var(--sp-3);
padding: var(--sp-4);
border: 1px solid var(--border-dim);
border-radius: var(--radius-lg);
background: var(--bg-base);
}
:global(.settings-mini-row) {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
:global(.settings-label) {
color: var(--text-primary);
font-weight: 600;
}
:global(.settings-desc) {
color: var(--text-muted);
font-size: 0.92rem;
line-height: 1.45;
}
:global(.settings-input),
:global(.settings-select) {
min-height: 40px;
padding: 0.55rem 0.8rem;
border: 1px solid var(--border-base);
border-radius: var(--radius-md);
background: var(--bg-overlay);
color: var(--text-primary);
outline: none;
}
:global(.settings-input:focus),
:global(.settings-select:focus) {
border-color: var(--border-focus);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--border-focus) 25%, transparent);
}
:global(.settings-input-wide) {
width: min(100%, 480px);
}
:global(.settings-input-narrow) {
width: 96px;
text-align: center;
}
:global(.settings-slider) {
width: min(320px, 100%);
accent-color: var(--accent);
}
:global(.settings-button) {
min-height: 40px;
padding: 0.55rem 0.85rem;
border: 1px solid var(--border-base);
border-radius: var(--radius-md);
background: var(--bg-overlay);
color: var(--text-secondary);
cursor: pointer;
}
:global(.settings-button:hover) {
border-color: var(--border-strong);
background: var(--bg-raised);
}
:global(.settings-theme-grid) {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: var(--sp-3);
padding: var(--sp-4);
background: var(--bg-surface);
}
:global(.settings-theme-card) {
display: flex;
flex-direction: column;
gap: var(--sp-3);
padding: var(--sp-3);
border: 1px solid var(--border-base);
border-radius: var(--radius-lg);
background: var(--bg-base);
color: inherit;
text-align: left;
cursor: pointer;
}
:global(.settings-theme-card.active) {
border-color: var(--accent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 30%, transparent);
}
:global(.settings-theme-preview) {
display: block;
height: 72px;
border-radius: var(--radius-md);
background:
linear-gradient(135deg, var(--theme-bg), var(--theme-surface)),
linear-gradient(135deg, var(--theme-bg), var(--theme-surface));
position: relative;
overflow: hidden;
}
:global(.settings-theme-preview)::after {
content: '';
position: absolute;
inset: 14px 14px 14px 38px;
border-radius: 10px;
background: linear-gradient(180deg, color-mix(in srgb, var(--theme-accent) 82%, white), color-mix(in srgb, var(--theme-accent) 40%, transparent));
opacity: 0.86;
}
:global(.settings-theme-info) {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
@media (max-width: 760px) {
:global(.settings-grid-2) {
grid-template-columns: 1fr;
}
:global(.settings-row),
:global(.settings-row-head) {
flex-direction: column;
align-items: stretch;
}
:global(.settings-inline-control) {
justify-content: stretch;
}
}
@media (max-width: 920px) {
:global(.settings-shell) {
grid-template-columns: 1fr;
}
:global(.settings-nav-panel) {
border-right: none;
border-bottom: 1px solid var(--border-dim);
}
}
</style>
+5
View File
@@ -0,0 +1,5 @@
import {redirect} from '@sveltejs/kit';
export function load() {
throw redirect(302, '/settings/general');
}
+46
View File
@@ -0,0 +1,46 @@
<script lang="ts">
import pkg from '../../../../package.json'
import { settingsState } from '$lib/state/settings.svelte'
import { trackingState } from '$lib/state/tracking.svelte'
const appVersion = pkg.version as string
</script>
<svelte:head>
<title>Settings - About</title>
</svelte:head>
<section class="settings-page">
<header class="settings-page-header">
<p class="settings-kicker">About</p>
<h2>Build and app information</h2>
<p>Static app details and a quick summary of the connected server.</p>
</header>
<div class="settings-card">
<div class="settings-row">
<div>
<div class="settings-label">Moku</div>
<div class="settings-desc">Version {appVersion}</div>
</div>
</div>
<div class="settings-row settings-grid-2">
<div>
<div class="settings-label">Server URL</div>
<div class="settings-desc">{settingsState.serverUrl}</div>
</div>
<div>
<div class="settings-label">Tracker count</div>
<div class="settings-desc">{trackingState.trackers.length} trackers loaded</div>
</div>
</div>
<div class="settings-row">
<div>
<div class="settings-label">Project</div>
<div class="settings-desc">A manga reader frontend for Suwayomi / Tachidesk.</div>
</div>
</div>
</div>
</section>
@@ -0,0 +1,91 @@
<script lang="ts">
import { mountSystemThemeSync } from '$lib/core/theme'
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
const builtinThemes = [
['original', 'Original', '#101010', '#151515', '#a8c4a8'],
['dark', 'Dark', '#080808', '#111111', '#bcd8bc'],
['light', 'Light', '#f4f2ee', '#faf8f4', '#2a5a2a'],
['midnight', 'Midnight', '#0c1020', '#101428', '#a8b4e8'],
['warm', 'Warm', '#16130c', '#1c1810', '#e0b860'],
] as const
const allThemes = $derived([
...builtinThemes.map(([id, label]) => ({id, label})),
...settingsState.customThemes.map((theme) => ({id: theme.id, label: theme.name})),
])
function chooseTheme(id: string) {
updateSettings({theme: id})
}
function toggleSystemSync() {
updateSettings({systemThemeSync: !settingsState.systemThemeSync})
mountSystemThemeSync()
}
</script>
<svelte:head>
<title>Settings - Appearance</title>
</svelte:head>
<section class="settings-page">
<header class="settings-page-header">
<p class="settings-kicker">Appearance</p>
<h2>Theme and color behavior</h2>
<p>Choose the app theme and optional system theme sync.</p>
</header>
<div class="settings-card">
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Match system theme</div>
<div class="settings-desc">Switch between light and dark themes automatically.</div>
</div>
<input type="checkbox" checked={settingsState.systemThemeSync} onchange={toggleSystemSync} />
</label>
{#if settingsState.systemThemeSync}
<div class="settings-row settings-grid-2">
<label>
<div class="settings-label">Dark theme</div>
<select class="settings-select" value={settingsState.systemThemeDark} onchange={(event) => { updateSettings({systemThemeDark: (event.currentTarget as HTMLSelectElement).value}); mountSystemThemeSync(); }}>
{#each allThemes as theme}
<option value={theme.id}>{theme.label}</option>
{/each}
</select>
</label>
<label>
<div class="settings-label">Light theme</div>
<select class="settings-select" value={settingsState.systemThemeLight} onchange={(event) => { updateSettings({systemThemeLight: (event.currentTarget as HTMLSelectElement).value}); mountSystemThemeSync(); }}>
{#each allThemes as theme}
<option value={theme.id}>{theme.label}</option>
{/each}
</select>
</label>
</div>
{/if}
<div class="settings-theme-grid">
{#each builtinThemes as [id, label, bg, surface, accent]}
<button class="settings-theme-card" class:active={settingsState.theme === id} type="button" onclick={() => chooseTheme(id)}>
<span class="settings-theme-preview" style={`--theme-bg:${bg};--theme-surface:${surface};--theme-accent:${accent};`}></span>
<span class="settings-theme-info">
<span class="settings-label">{label}</span>
<span class="settings-desc">Built-in theme</span>
</span>
</button>
{/each}
{#each settingsState.customThemes as theme}
<button class="settings-theme-card" class:active={settingsState.theme === theme.id} type="button" onclick={() => chooseTheme(theme.id)}>
<span class="settings-theme-preview" style={`--theme-bg:${theme.tokens['bg-base']};--theme-surface:${theme.tokens['bg-surface']};--theme-accent:${theme.tokens['accent']};`}></span>
<span class="settings-theme-info">
<span class="settings-label">{theme.name}</span>
<span class="settings-desc">Custom theme</span>
</span>
</button>
{/each}
</div>
</div>
</section>
+127
View File
@@ -0,0 +1,127 @@
<script lang="ts">
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
const downloadAheadOptions = [0, 2, 5, 10]
const maxKeepOptions = [0, 5, 10, 25]
const delayOptions = [0, 24, 168]
const refreshOptions = ['daily', 'weekly', 'manual'] as const
const defaults = $derived(settingsState.automationDefaults)
function patchDefaults(patch: Partial<typeof defaults>) {
updateSettings({automationDefaults: {...defaults, ...patch}})
}
</script>
<svelte:head>
<title>Settings - Automation</title>
</svelte:head>
<section class="settings-page">
<header class="settings-page-header">
<p class="settings-kicker">Automation</p>
<h2>Series automation defaults</h2>
<p>These values are used when a manga has no per-series override.</p>
</header>
<div class="settings-card">
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Enable automation</div>
<div class="settings-desc">Allow automation rules to run at all.</div>
</div>
<input type="checkbox" checked={settingsState.automationEnabled} onchange={() => updateSettings({automationEnabled: !settingsState.automationEnabled})} />
</label>
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Enforce global defaults</div>
<div class="settings-desc">Ignore per-series overrides and use the global defaults below.</div>
</div>
<input type="checkbox" checked={settingsState.automationEnforceGlobal} onchange={() => updateSettings({automationEnforceGlobal: !settingsState.automationEnforceGlobal})} />
</label>
{#if settingsState.automationEnforceGlobal}
<div class="settings-row">
<div>
<div class="settings-label">Per-series overrides paused</div>
<div class="settings-desc">Disable enforce to allow individual manga preferences again.</div>
</div>
</div>
{/if}
<div class="settings-row settings-grid-2">
<label>
<div class="settings-label">Auto-download new chapters</div>
<select class="settings-select" value={String(defaults.autoDownload)} onchange={(event) => patchDefaults({autoDownload: (event.currentTarget as HTMLSelectElement).value === 'true'})}>
<option value="true">On</option>
<option value="false">Off</option>
</select>
</label>
<label>
<div class="settings-label">Download ahead</div>
<select class="settings-select" value={String(defaults.downloadAhead)} onchange={(event) => patchDefaults({downloadAhead: Number((event.currentTarget as HTMLSelectElement).value)})}>
{#each downloadAheadOptions as value}
<option value={String(value)}>{value === 0 ? 'Off' : value}</option>
{/each}
</select>
</label>
</div>
<div class="settings-row settings-grid-2">
<label>
<div class="settings-label">Max chapters to keep</div>
<select class="settings-select" value={String(defaults.maxKeepChapters)} onchange={(event) => patchDefaults({maxKeepChapters: Number((event.currentTarget as HTMLSelectElement).value)})}>
{#each maxKeepOptions as value}
<option value={String(value)}>{value === 0 ? 'Off' : value}</option>
{/each}
</select>
</label>
<label>
<div class="settings-label">Delete delay</div>
<select class="settings-select" value={String(defaults.deleteDelayHours)} onchange={(event) => patchDefaults({deleteDelayHours: Number((event.currentTarget as HTMLSelectElement).value)})}>
{#each delayOptions as value}
<option value={String(value)}>{value === 0 ? 'Now' : value === 24 ? '1 day' : '1 week'}</option>
{/each}
</select>
</label>
</div>
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Delete after reading</div>
<div class="settings-desc">Remove downloaded chapters after they are marked read.</div>
</div>
<input type="checkbox" checked={defaults.deleteOnRead} onchange={() => patchDefaults({deleteOnRead: !defaults.deleteOnRead})} />
</label>
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Pause updates</div>
<div class="settings-desc">Pause chapter refresh for series with this default.</div>
</div>
<input type="checkbox" checked={defaults.pauseUpdates} onchange={() => patchDefaults({pauseUpdates: !defaults.pauseUpdates})} />
</label>
<div class="settings-row">
<div>
<div class="settings-label">Refresh interval</div>
<div class="settings-desc">How often a series is checked for new chapters.</div>
</div>
<select class="settings-select" value={defaults.refreshInterval} onchange={(event) => patchDefaults({refreshInterval: (event.currentTarget as HTMLSelectElement).value as 'daily' | 'weekly' | 'manual'})}>
{#each refreshOptions as value}
<option value={value}>{value[0].toUpperCase() + value.slice(1)}</option>
{/each}
</select>
</div>
<div class="settings-row">
<div>
<div class="settings-label">Stored custom manga preferences</div>
<div class="settings-desc">{Object.keys(settingsState.mangaPrefs).length} manga records currently have custom prefs.</div>
</div>
</div>
</div>
</section>
+64
View File
@@ -0,0 +1,64 @@
<script lang="ts">
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
const levels = [
['strict', 'Strict'],
['moderate', 'Moderate'],
['unrestricted', 'Unrestricted'],
] as const
function splitIds(value: string) {
return value.split(',').map((item) => item.trim()).filter(Boolean)
}
</script>
<svelte:head>
<title>Settings - Content</title>
</svelte:head>
<section class="settings-page">
<header class="settings-page-header">
<p class="settings-kicker">Content</p>
<h2>Content filtering and source overrides</h2>
<p>Control the overall content level and any per-source exceptions.</p>
</header>
<div class="settings-card">
<div class="settings-row settings-grid-2">
<label>
<div class="settings-label">Content level</div>
<select class="settings-select" value={settingsState.contentLevel} onchange={(event) => updateSettings({contentLevel: (event.currentTarget as HTMLSelectElement).value as 'strict' | 'moderate' | 'unrestricted'})}>
{#each levels as [value, label]}
<option value={value}>{label}</option>
{/each}
</select>
</label>
<label class="settings-toggle-row">
<div>
<div class="settings-label">Per-source overrides</div>
<div class="settings-desc">Allow explicit source allow/block exceptions.</div>
</div>
<input type="checkbox" checked={settingsState.sourceOverridesEnabled} onchange={() => updateSettings({sourceOverridesEnabled: !settingsState.sourceOverridesEnabled})} />
</label>
</div>
{#if settingsState.sourceOverridesEnabled}
<label class="settings-row">
<div>
<div class="settings-label">Allowed source IDs</div>
<div class="settings-desc">Comma-separated source IDs allowed through the current filter.</div>
</div>
<input class="settings-input settings-input-wide" value={settingsState.nsfwAllowedSourceIds.join(', ')} oninput={(event) => updateSettings({nsfwAllowedSourceIds: splitIds((event.currentTarget as HTMLInputElement).value)})} />
</label>
<label class="settings-row">
<div>
<div class="settings-label">Blocked source IDs</div>
<div class="settings-desc">Comma-separated source IDs to always block.</div>
</div>
<input class="settings-input settings-input-wide" value={settingsState.nsfwBlockedSourceIds.join(', ')} oninput={(event) => updateSettings({nsfwBlockedSourceIds: splitIds((event.currentTarget as HTMLInputElement).value)})} />
</label>
{/if}
</div>
</section>
+39
View File
@@ -0,0 +1,39 @@
<script lang="ts">
import { resetSettings, settingsState } from '$lib/state/settings.svelte'
const themeCount = $derived(settingsState.customThemes.length)
const prefsCount = $derived(Object.keys(settingsState.mangaPrefs).length)
</script>
<svelte:head>
<title>Settings - Devtools</title>
</svelte:head>
<section class="settings-page">
<header class="settings-page-header">
<p class="settings-kicker">Devtools</p>
<h2>Diagnostics and reset tools</h2>
<p>Basic internal state summaries and a safe settings reset button.</p>
</header>
<div class="settings-card">
<div class="settings-row settings-grid-2">
<div>
<div class="settings-label">Custom themes</div>
<div class="settings-desc">{themeCount} stored theme definitions</div>
</div>
<div>
<div class="settings-label">Custom manga prefs</div>
<div class="settings-desc">{prefsCount} manga entries have overrides</div>
</div>
</div>
<div class="settings-row">
<div>
<div class="settings-label">Reset all settings</div>
<div class="settings-desc">Restore the entire settings object to defaults.</div>
</div>
<button class="settings-button" type="button" onclick={resetSettings}>Reset settings</button>
</div>
</div>
</section>
+57
View File
@@ -0,0 +1,57 @@
<script lang="ts">
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
function splitIds(value: string) {
return value.split(',').map((item) => item.trim()).filter(Boolean)
}
</script>
<svelte:head>
<title>Settings - Folders</title>
</svelte:head>
<section class="settings-page">
<header class="settings-page-header">
<p class="settings-kicker">Folders</p>
<h2>Library folder organization</h2>
<p>Use simple comma-separated controls to keep tab order and visibility direct.</p>
</header>
<div class="settings-card">
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Saved is default category</div>
<div class="settings-desc">Treat the Saved folder as the default category view.</div>
</div>
<input type="checkbox" checked={settingsState.savedIsDefaultCategory} onchange={() => updateSettings({savedIsDefaultCategory: !settingsState.savedIsDefaultCategory})} />
</label>
<div class="settings-row settings-grid-2">
<label>
<div class="settings-label">Default library category ID</div>
<input class="settings-input settings-input-narrow" type="number" min="0" value={settingsState.defaultLibraryCategoryId ?? ''} oninput={(event) => { const value = (event.currentTarget as HTMLInputElement).value; updateSettings({defaultLibraryCategoryId: value === '' ? null : Number(value)}); }} />
</label>
<label>
<div class="settings-label">Hidden category IDs</div>
<input class="settings-input settings-input-wide" spellcheck="false" value={settingsState.hiddenCategoryIds.join(', ')} oninput={(event) => updateSettings({hiddenCategoryIds: splitIds((event.currentTarget as HTMLInputElement).value).map(Number).filter(Number.isFinite)})} />
</label>
</div>
<label class="settings-row">
<div>
<div class="settings-label">Hidden library tabs</div>
<div class="settings-desc">Comma-separated route names such as Saved, Completed, or custom tabs.</div>
</div>
<input class="settings-input settings-input-wide" spellcheck="false" value={settingsState.hiddenLibraryTabs.join(', ')} oninput={(event) => updateSettings({hiddenLibraryTabs: splitIds((event.currentTarget as HTMLInputElement).value)})} />
</label>
<label class="settings-row">
<div>
<div class="settings-label">Pinned library tab order</div>
<div class="settings-desc">Comma-separated pinned tab IDs in the order you want them shown.</div>
</div>
<input class="settings-input settings-input-wide" spellcheck="false" value={settingsState.libraryPinnedTabOrder.join(', ')} oninput={(event) => updateSettings({libraryPinnedTabOrder: splitIds((event.currentTarget as HTMLInputElement).value)})} />
</label>
</div>
</section>
+163
View File
@@ -0,0 +1,163 @@
<script lang="ts">
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
let advancedOpen = $state(false)
const idleChoices = [0, 1, 2, 5, 10, 15, 30]
</script>
<svelte:head>
<title>Settings - General</title>
</svelte:head>
<section class="settings-page">
<header class="settings-page-header">
<p class="settings-kicker">General</p>
<h2>Application basics</h2>
<p>Core behavior, server connection, and desktop shell preferences.</p>
</header>
<div class="settings-card">
<div class="settings-row">
<div>
<div class="settings-label">Interface scale</div>
<div class="settings-desc">Scale the whole app UI.</div>
</div>
<div class="settings-inline-control">
<input
class="settings-slider"
type="range"
min="50"
max="200"
step="5"
value={Math.round((settingsState.uiZoom ?? 1) * 100)}
oninput={(event) => updateSettings({uiZoom: Number((event.currentTarget as HTMLInputElement).value) / 100})}
/>
<input
class="settings-input settings-input-narrow"
type="number"
min="50"
max="200"
value={Math.round((settingsState.uiZoom ?? 1) * 100)}
oninput={(event) => updateSettings({uiZoom: Number((event.currentTarget as HTMLInputElement).value) / 100})}
/>
<button class="settings-button" type="button" onclick={() => updateSettings({uiZoom: 1})}>Reset</button>
</div>
</div>
<div class="settings-row">
<div>
<div class="settings-label">Server URL</div>
<div class="settings-desc">Base URL for the Suwayomi server.</div>
</div>
<input
class="settings-input settings-input-wide"
spellcheck="false"
value={settingsState.serverUrl}
oninput={(event) => updateSettings({serverUrl: (event.currentTarget as HTMLInputElement).value})}
/>
</div>
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Auto-start server</div>
<div class="settings-desc">Launch the server when Moku starts.</div>
</div>
<input type="checkbox" checked={settingsState.autoStartServer} onchange={() => updateSettings({autoStartServer: !settingsState.autoStartServer})} />
</label>
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Suwayomi Web UI</div>
<div class="settings-desc">Keep the server's web UI enabled alongside Moku.</div>
</div>
<input type="checkbox" checked={settingsState.suwayomiWebUI} onchange={() => updateSettings({suwayomiWebUI: !settingsState.suwayomiWebUI})} />
</label>
<div class="settings-row settings-row-stack">
<div class="settings-row-head">
<div>
<div class="settings-label">Advanced server options</div>
<div class="settings-desc">Custom binary path and launch args.</div>
</div>
<button class="settings-button" type="button" onclick={() => advancedOpen = !advancedOpen}>{advancedOpen ? 'Hide' : 'Show'}</button>
</div>
{#if advancedOpen}
<div class="settings-subcard">
<label class="settings-mini-row">
<span class="settings-label">Server binary</span>
<input
class="settings-input settings-input-wide"
spellcheck="false"
placeholder="auto-detect"
value={settingsState.serverBinary}
oninput={(event) => updateSettings({serverBinary: (event.currentTarget as HTMLInputElement).value})}
/>
</label>
<label class="settings-mini-row">
<span class="settings-label">Server args</span>
<input
class="settings-input settings-input-wide"
spellcheck="false"
placeholder=""
value={settingsState.serverBinaryArgs}
oninput={(event) => updateSettings({serverBinaryArgs: (event.currentTarget as HTMLInputElement).value})}
/>
</label>
</div>
{/if}
</div>
<div class="settings-row">
<div>
<div class="settings-label">Idle screen timeout</div>
<div class="settings-desc">Show the splash screen after inactivity.</div>
</div>
<select class="settings-select" value={String(settingsState.idleTimeoutMin ?? 5)} onchange={(event) => updateSettings({idleTimeoutMin: Number((event.currentTarget as HTMLSelectElement).value)})}>
{#each idleChoices as minutes}
<option value={String(minutes)}>{minutes === 0 ? 'Never' : `${minutes} min`}</option>
{/each}
</select>
</div>
<div class="settings-row">
<div>
<div class="settings-label">Close button behavior</div>
<div class="settings-desc">Choose what the window close button does.</div>
</div>
<select class="settings-select" value={settingsState.closeAction} onchange={(event) => updateSettings({closeAction: (event.currentTarget as HTMLSelectElement).value as 'ask' | 'tray' | 'quit'})}>
<option value="ask">Ask</option>
<option value="tray">Tray</option>
<option value="quit">Quit</option>
</select>
</div>
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Discord Rich Presence</div>
<div class="settings-desc">Show what you're reading in Discord.</div>
</div>
<input type="checkbox" checked={settingsState.discordRpc} onchange={() => updateSettings({discordRpc: !settingsState.discordRpc})} />
</label>
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">QOL animations</div>
<div class="settings-desc">Enable small hover and transition effects.</div>
</div>
<input type="checkbox" checked={settingsState.qolAnimations} onchange={() => updateSettings({qolAnimations: !settingsState.qolAnimations})} />
</label>
<div class="settings-row">
<div>
<div class="settings-label">Preferred source language</div>
<div class="settings-desc">Used for search defaults and source sorting.</div>
</div>
<input
class="settings-input settings-input-narrow"
spellcheck="false"
value={settingsState.preferredExtensionLang}
oninput={(event) => updateSettings({preferredExtensionLang: (event.currentTarget as HTMLInputElement).value.trim().toLowerCase()})}
/>
</div>
</div>
</section>
+87
View File
@@ -0,0 +1,87 @@
<script lang="ts">
import { eventToKeybind } from '$lib/core/keybinds/keybindEngine'
import { DEFAULT_KEYBINDS, KEYBIND_LABELS, type Keybinds } from '$lib/core/keybinds/defaultBinds'
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
let listeningKey = $state<keyof Keybinds | null>(null)
function startListen(key: keyof Keybinds) {
listeningKey = listeningKey === key ? null : key
}
function setBinding(key: keyof Keybinds, binding: string) {
updateSettings({keybinds: {...settingsState.keybinds, [key]: binding}})
}
$effect(() => {
if (!listeningKey || typeof window === 'undefined') return
const handler = (event: KeyboardEvent) => {
event.preventDefault()
event.stopPropagation()
const binding = eventToKeybind(event)
if (!binding) return
setBinding(listeningKey, binding)
listeningKey = null
}
window.addEventListener('keydown', handler, true)
return () => window.removeEventListener('keydown', handler, true)
})
</script>
<svelte:head>
<title>Settings - Keybinds</title>
</svelte:head>
<section class="settings-page">
<header class="settings-page-header">
<p class="settings-kicker">Keybinds</p>
<h2>Keyboard shortcuts</h2>
<p>Click a binding and press the shortcut you want to use.</p>
</header>
<div class="settings-card settings-keybinds-card">
<div class="settings-row settings-row-head">
<div>
<div class="settings-label">Shortcut bindings</div>
<div class="settings-desc">Reset any binding individually or all at once.</div>
</div>
<button class="settings-button" type="button" onclick={() => updateSettings({keybinds: {...DEFAULT_KEYBINDS}})}>Reset all</button>
</div>
{#each Object.keys(KEYBIND_LABELS) as key}
{@const bindKey = key as keyof Keybinds}
{@const isListening = listeningKey === bindKey}
{@const isDefault = settingsState.keybinds[bindKey] === DEFAULT_KEYBINDS[bindKey]}
<div class="settings-row settings-keybind-row">
<div>
<div class="settings-label">{KEYBIND_LABELS[bindKey]}</div>
</div>
<div class="settings-keybind-actions">
<button class="settings-button" type="button" onclick={() => startListen(bindKey)}>{isListening ? 'Press a key…' : settingsState.keybinds[bindKey]}</button>
<button class="settings-button" type="button" disabled={isDefault} onclick={() => setBinding(bindKey, DEFAULT_KEYBINDS[bindKey])}>Reset</button>
</div>
</div>
{/each}
</div>
</section>
<style>
:global(.settings-keybinds-card) {
gap: 0;
}
:global(.settings-keybind-row) {
align-items: center;
}
:global(.settings-keybind-actions) {
display: flex;
gap: var(--sp-3);
flex-wrap: wrap;
justify-content: flex-end;
}
</style>
+114
View File
@@ -0,0 +1,114 @@
<script lang="ts">
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
const sortDirs = [
['desc', 'Newest first'],
['asc', 'Oldest first'],
] as const
const sortModes = [
['az', 'A-Z'],
['unreadCount', 'Unread count'],
['totalChapters', 'Total chapters'],
['recentlyAdded', 'Recently added'],
['recentlyRead', 'Recently read'],
['latestFetched', 'Latest fetched'],
['latestUploaded', 'Latest uploaded'],
] as const
</script>
<svelte:head>
<title>Settings - Library</title>
</svelte:head>
<section class="settings-page">
<header class="settings-page-header">
<p class="settings-kicker">Library</p>
<h2>Library display and sorting</h2>
<p>How manga cards and chapter lists are shown in the library.</p>
</header>
<div class="settings-card">
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Always show card stats</div>
<div class="settings-desc">Show unread and download counts without hovering.</div>
</div>
<input type="checkbox" checked={settingsState.libraryStatsAlways} onchange={() => updateSettings({libraryStatsAlways: !settingsState.libraryStatsAlways})} />
</label>
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Crop cover images</div>
<div class="settings-desc">Fill cards with cover art instead of letterboxing.</div>
</div>
<input type="checkbox" checked={settingsState.libraryCropCovers} onchange={() => updateSettings({libraryCropCovers: !settingsState.libraryCropCovers})} />
</label>
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Show all in Saved tab</div>
<div class="settings-desc">Include manga that are already in folders.</div>
</div>
<input type="checkbox" checked={settingsState.libraryShowAllInSaved} onchange={() => updateSettings({libraryShowAllInSaved: !settingsState.libraryShowAllInSaved})} />
</label>
{#if settingsState.libraryShowAllInSaved}
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Hide completed in Saved tab</div>
<div class="settings-desc">Keep completed manga out of the Saved view.</div>
</div>
<input type="checkbox" checked={settingsState.libraryHideCompletedInSaved} onchange={() => updateSettings({libraryHideCompletedInSaved: !settingsState.libraryHideCompletedInSaved})} />
</label>
{/if}
<div class="settings-row settings-grid-2">
<label>
<div class="settings-label">Default chapter sort direction</div>
<select class="settings-select" value={settingsState.chapterSortDir} onchange={(event) => updateSettings({chapterSortDir: (event.currentTarget as HTMLSelectElement).value as 'desc' | 'asc'})}>
{#each sortDirs as [value, label]}
<option value={value}>{label}</option>
{/each}
</select>
</label>
<label>
<div class="settings-label">Default chapter sort mode</div>
<select class="settings-select" value={settingsState.chapterSortMode} onchange={(event) => updateSettings({chapterSortMode: (event.currentTarget as HTMLSelectElement).value as 'source' | 'chapterNumber' | 'uploadDate'})}>
{#each sortModes as [value, label]}
<option value={value}>{label}</option>
{/each}
</select>
</label>
</div>
<div class="settings-row settings-grid-2">
<label>
<div class="settings-label">Library page size</div>
<input class="settings-input settings-input-narrow" type="number" min="1" max="200" value={settingsState.libraryPageSize} oninput={(event) => updateSettings({libraryPageSize: Number((event.currentTarget as HTMLInputElement).value) || 1})} />
</label>
<label>
<div class="settings-label">Chapter page size</div>
<input class="settings-input settings-input-narrow" type="number" min="1" max="200" value={settingsState.chapterPageSize} oninput={(event) => updateSettings({chapterPageSize: Number((event.currentTarget as HTMLInputElement).value) || 1})} />
</label>
</div>
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Auto-link on open</div>
<div class="settings-desc">Try to link a manga to similar entries when opened.</div>
</div>
<input type="checkbox" checked={settingsState.autoLinkOnOpen} onchange={() => updateSettings({autoLinkOnOpen: !settingsState.autoLinkOnOpen})} />
</label>
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Disable auto-complete</div>
<div class="settings-desc">Do not move manga to Completed automatically.</div>
</div>
<input type="checkbox" checked={settingsState.disableAutoComplete} onchange={() => updateSettings({disableAutoComplete: !settingsState.disableAutoComplete})} />
</label>
</div>
</section>
@@ -0,0 +1,63 @@
<script lang="ts">
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
const renderLimits = [48, 96, 144, 300, 500]
</script>
<svelte:head>
<title>Settings - Performance</title>
</svelte:head>
<section class="settings-page">
<header class="settings-page-header">
<p class="settings-kicker">Performance</p>
<h2>Render and behavior tuning</h2>
<p>Keep the app light or turn up quality-of-life options.</p>
</header>
<div class="settings-card">
<div class="settings-row settings-grid-2">
<label>
<div class="settings-label">Render limit</div>
<select class="settings-select" value={String(settingsState.renderLimit)} onchange={(event) => updateSettings({renderLimit: Number((event.currentTarget as HTMLSelectElement).value)})}>
{#each renderLimits as value}
<option value={String(value)}>{value}</option>
{/each}
</select>
</label>
<label>
<div class="settings-label">Reader debounce</div>
<input class="settings-input settings-input-narrow" type="number" min="0" max="1000" value={settingsState.readerDebounceMs} oninput={(event) => updateSettings({readerDebounceMs: Number((event.currentTarget as HTMLInputElement).value) || 0})} />
</label>
</div>
<div class="settings-row settings-grid-2">
<label>
<div class="settings-label">Library page size</div>
<input class="settings-input settings-input-narrow" type="number" min="1" max="200" value={settingsState.libraryPageSize} oninput={(event) => updateSettings({libraryPageSize: Number((event.currentTarget as HTMLInputElement).value) || 1})} />
</label>
<label>
<div class="settings-label">Chapter page size</div>
<input class="settings-input settings-input-narrow" type="number" min="1" max="200" value={settingsState.chapterPageSize} oninput={(event) => updateSettings({chapterPageSize: Number((event.currentTarget as HTMLInputElement).value) || 1})} />
</label>
</div>
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">GPU acceleration</div>
<div class="settings-desc">Use the GPU for rendering when available.</div>
</div>
<input type="checkbox" checked={settingsState.gpuAcceleration} onchange={() => updateSettings({gpuAcceleration: !settingsState.gpuAcceleration})} />
</label>
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">QOL animations</div>
<div class="settings-desc">Hover lifts and transition polish.</div>
</div>
<input type="checkbox" checked={settingsState.qolAnimations} onchange={() => updateSettings({qolAnimations: !settingsState.qolAnimations})} />
</label>
</div>
</section>
+160
View File
@@ -0,0 +1,160 @@
<script lang="ts">
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
const pageStyles = [
['longstrip', 'Long strip'],
['single', 'Single page'],
['double', 'Double page'],
] as const
const readingDirections = [
['ltr', 'Left to right'],
['rtl', 'Right to left'],
] as const
const fitModes = [
['width', 'Fit width'],
['height', 'Fit height'],
['screen', 'Fit screen'],
['original', 'Original'],
] as const
</script>
<svelte:head>
<title>Settings - Reader</title>
</svelte:head>
<section class="settings-page">
<header class="settings-page-header">
<p class="settings-kicker">Reader</p>
<h2>Reading defaults</h2>
<p>Behavior and layout for the full-screen reader.</p>
</header>
<div class="settings-card">
<div class="settings-row settings-grid-2">
<label>
<div class="settings-label">Default page style</div>
<select class="settings-select" value={settingsState.pageStyle} onchange={(event) => updateSettings({pageStyle: (event.currentTarget as HTMLSelectElement).value as 'single' | 'double' | 'longstrip'})}>
{#each pageStyles as [value, label]}
<option value={value}>{label}</option>
{/each}
</select>
</label>
<label>
<div class="settings-label">Reading direction</div>
<select class="settings-select" value={settingsState.readingDirection} onchange={(event) => updateSettings({readingDirection: (event.currentTarget as HTMLSelectElement).value as 'ltr' | 'rtl'})}>
{#each readingDirections as [value, label]}
<option value={value}>{label}</option>
{/each}
</select>
</label>
</div>
<div class="settings-row settings-grid-2">
<label>
<div class="settings-label">Default fit mode</div>
<select class="settings-select" value={settingsState.fitMode} onchange={(event) => updateSettings({fitMode: (event.currentTarget as HTMLSelectElement).value as 'width' | 'height' | 'screen' | 'original'})}>
{#each fitModes as [value, label]}
<option value={value}>{label}</option>
{/each}
</select>
</label>
<label>
<div class="settings-label">Reader zoom</div>
<input
class="settings-slider"
type="range"
min="10"
max="100"
step="5"
value={Math.round((settingsState.readerZoom ?? 0.5) * 100)}
oninput={(event) => updateSettings({readerZoom: Number((event.currentTarget as HTMLInputElement).value) / 100})}
/>
</label>
</div>
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Page gap</div>
<div class="settings-desc">Adds spacing between pages in single-page mode.</div>
</div>
<input type="checkbox" checked={settingsState.pageGap} onchange={() => updateSettings({pageGap: !settingsState.pageGap})} />
</label>
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Overlay bars</div>
<div class="settings-desc">Float reader bars over the content.</div>
</div>
<input type="checkbox" checked={settingsState.overlayBars} onchange={() => updateSettings({overlayBars: !settingsState.overlayBars})} />
</label>
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Tap to toggle bar</div>
<div class="settings-desc">Double tap the reader to show or hide the bars.</div>
</div>
<input type="checkbox" checked={settingsState.tapToToggleBar} onchange={() => updateSettings({tapToToggleBar: !settingsState.tapToToggleBar})} />
</label>
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Optimize contrast</div>
<div class="settings-desc">Boost line contrast for black-and-white pages.</div>
</div>
<input type="checkbox" checked={settingsState.optimizeContrast} onchange={() => updateSettings({optimizeContrast: !settingsState.optimizeContrast})} />
</label>
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Auto-mark read</div>
<div class="settings-desc">Mark chapters read at the end of the last page.</div>
</div>
<input type="checkbox" checked={settingsState.autoMarkRead} onchange={() => updateSettings({autoMarkRead: !settingsState.autoMarkRead})} />
</label>
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Auto-advance chapters</div>
<div class="settings-desc">Open the next chapter automatically when you finish.</div>
</div>
<input type="checkbox" checked={settingsState.autoNextChapter} onchange={() => updateSettings({autoNextChapter: !settingsState.autoNextChapter})} />
</label>
{#if !settingsState.autoNextChapter}
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Mark read when skipping</div>
<div class="settings-desc">Mark the current chapter read when you skip ahead manually.</div>
</div>
<input type="checkbox" checked={settingsState.markReadOnNext} onchange={() => updateSettings({markReadOnNext: !settingsState.markReadOnNext})} />
</label>
{/if}
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Auto-bookmark</div>
<div class="settings-desc">Save page position while reading.</div>
</div>
<input type="checkbox" checked={settingsState.autoBookmark} onchange={() => updateSettings({autoBookmark: !settingsState.autoBookmark})} />
</label>
<div class="settings-row">
<div>
<div class="settings-label">Pages to preload</div>
<div class="settings-desc">How many pages ahead to fetch in the background.</div>
</div>
<input
class="settings-input settings-input-narrow"
type="number"
min="0"
max="10"
value={settingsState.preloadPages}
oninput={(event) => updateSettings({preloadPages: Math.max(0, Math.min(10, Number((event.currentTarget as HTMLInputElement).value) || 0))})}
/>
</div>
</div>
</section>
+149
View File
@@ -0,0 +1,149 @@
<script lang="ts">
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
const authModes = [
['NONE', 'Disabled'],
['BASIC_AUTH', 'Basic auth'],
['SIMPLE_LOGIN', 'Simple login'],
['UI_LOGIN', 'UI login'],
] as const
const proxyVersions = [
[4, 'SOCKS4'],
[5, 'SOCKS5'],
] as const
</script>
<svelte:head>
<title>Settings - Security</title>
</svelte:head>
<section class="settings-page">
<header class="settings-page-header">
<p class="settings-kicker">Security</p>
<h2>Server access and proxy settings</h2>
<p>Authentication, SOCKS proxy, FlareSolverr, and app lock options.</p>
</header>
<div class="settings-card">
<div class="settings-row settings-grid-2">
<label>
<div class="settings-label">Server auth mode</div>
<select class="settings-select" value={settingsState.serverAuthMode} onchange={(event) => updateSettings({serverAuthMode: (event.currentTarget as HTMLSelectElement).value as 'NONE' | 'BASIC_AUTH' | 'SIMPLE_LOGIN' | 'UI_LOGIN'})}>
{#each authModes as [value, label]}
<option value={value}>{label}</option>
{/each}
</select>
</label>
<label>
<div class="settings-label">Username</div>
<input class="settings-input settings-input-wide" spellcheck="false" value={settingsState.serverAuthUser} oninput={(event) => updateSettings({serverAuthUser: (event.currentTarget as HTMLInputElement).value})} />
</label>
</div>
<div class="settings-row settings-grid-2">
<label>
<div class="settings-label">Password</div>
<input class="settings-input settings-input-wide" type="password" value={settingsState.serverAuthPass} oninput={(event) => updateSettings({serverAuthPass: (event.currentTarget as HTMLInputElement).value})} />
</label>
<label>
<div class="settings-label">App lock PIN</div>
<input class="settings-input settings-input-wide" type="password" value={settingsState.appLockPin} oninput={(event) => updateSettings({appLockPin: (event.currentTarget as HTMLInputElement).value})} />
</label>
</div>
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">App lock</div>
<div class="settings-desc">Require the PIN when opening the app.</div>
</div>
<input type="checkbox" checked={settingsState.appLockEnabled} onchange={() => updateSettings({appLockEnabled: !settingsState.appLockEnabled})} />
</label>
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">SOCKS proxy</div>
<div class="settings-desc">Route server requests through a SOCKS proxy.</div>
</div>
<input type="checkbox" checked={settingsState.socksProxyEnabled} onchange={() => updateSettings({socksProxyEnabled: !settingsState.socksProxyEnabled})} />
</label>
{#if settingsState.socksProxyEnabled}
<div class="settings-row settings-grid-2">
<label>
<div class="settings-label">Proxy host</div>
<input class="settings-input settings-input-wide" spellcheck="false" value={settingsState.socksProxyHost} oninput={(event) => updateSettings({socksProxyHost: (event.currentTarget as HTMLInputElement).value})} />
</label>
<label>
<div class="settings-label">Proxy port</div>
<input class="settings-input settings-input-narrow" spellcheck="false" value={settingsState.socksProxyPort} oninput={(event) => updateSettings({socksProxyPort: (event.currentTarget as HTMLInputElement).value})} />
</label>
</div>
<div class="settings-row settings-grid-2">
<label>
<div class="settings-label">Proxy version</div>
<select class="settings-select" value={String(settingsState.socksProxyVersion)} onchange={(event) => updateSettings({socksProxyVersion: Number((event.currentTarget as HTMLSelectElement).value)})}>
{#each proxyVersions as [value, label]}
<option value={String(value)}>{label}</option>
{/each}
</select>
</label>
<label>
<div class="settings-label">Proxy username</div>
<input class="settings-input settings-input-wide" spellcheck="false" value={settingsState.socksProxyUsername} oninput={(event) => updateSettings({socksProxyUsername: (event.currentTarget as HTMLInputElement).value})} />
</label>
</div>
<label class="settings-row">
<div>
<div class="settings-label">Proxy password</div>
<div class="settings-desc">Stored locally and used for outgoing requests.</div>
</div>
<input class="settings-input settings-input-wide" type="password" value={settingsState.socksProxyPassword} oninput={(event) => updateSettings({socksProxyPassword: (event.currentTarget as HTMLInputElement).value})} />
</label>
{/if}
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">FlareSolverr</div>
<div class="settings-desc">Use FlareSolverr for sites protected by anti-bot challenges.</div>
</div>
<input type="checkbox" checked={settingsState.flareSolverrEnabled} onchange={() => updateSettings({flareSolverrEnabled: !settingsState.flareSolverrEnabled})} />
</label>
{#if settingsState.flareSolverrEnabled}
<div class="settings-row settings-grid-2">
<label>
<div class="settings-label">FlareSolverr URL</div>
<input class="settings-input settings-input-wide" spellcheck="false" value={settingsState.flareSolverrUrl} oninput={(event) => updateSettings({flareSolverrUrl: (event.currentTarget as HTMLInputElement).value})} />
</label>
<label>
<div class="settings-label">Timeout</div>
<input class="settings-input settings-input-narrow" type="number" min="1" value={settingsState.flareSolverrTimeout} oninput={(event) => updateSettings({flareSolverrTimeout: Number((event.currentTarget as HTMLInputElement).value) || 1})} />
</label>
</div>
<div class="settings-row settings-grid-2">
<label>
<div class="settings-label">Session name</div>
<input class="settings-input settings-input-wide" spellcheck="false" value={settingsState.flareSolverrSessionName} oninput={(event) => updateSettings({flareSolverrSessionName: (event.currentTarget as HTMLInputElement).value})} />
</label>
<label>
<div class="settings-label">Session TTL</div>
<input class="settings-input settings-input-narrow" type="number" min="1" value={settingsState.flareSolverrSessionTtl} oninput={(event) => updateSettings({flareSolverrSessionTtl: Number((event.currentTarget as HTMLInputElement).value) || 1})} />
</label>
</div>
<label class="settings-row settings-toggle-row">
<div>
<div class="settings-label">Fallback to response mode</div>
<div class="settings-desc">Use FlareSolverr responses directly when needed.</div>
</div>
<input type="checkbox" checked={settingsState.flareSolverrAsResponseFallback} onchange={() => updateSettings({flareSolverrAsResponseFallback: !settingsState.flareSolverrAsResponseFallback})} />
</label>
{/if}
</div>
</section>
+37
View File
@@ -0,0 +1,37 @@
<script lang="ts">
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
</script>
<svelte:head>
<title>Settings - Storage</title>
</svelte:head>
<section class="settings-page">
<header class="settings-page-header">
<p class="settings-kicker">Storage</p>
<h2>Paths and limits</h2>
<p>Control where Moku stores downloads and local sources.</p>
</header>
<div class="settings-card">
<div class="settings-row">
<div>
<div class="settings-label">Storage limit</div>
<div class="settings-desc">Maximum local storage in gigabytes. Leave blank for no limit.</div>
</div>
<input class="settings-input settings-input-narrow" type="number" min="0" step="1" value={settingsState.storageLimitGb ?? ''} oninput={(event) => { const value = (event.currentTarget as HTMLInputElement).value; updateSettings({storageLimitGb: value === '' ? null : Number(value)}); }} />
</div>
<div class="settings-row settings-grid-2">
<label>
<div class="settings-label">Downloads path</div>
<input class="settings-input settings-input-wide" spellcheck="false" value={settingsState.serverDownloadsPath} oninput={(event) => updateSettings({serverDownloadsPath: (event.currentTarget as HTMLInputElement).value})} />
</label>
<label>
<div class="settings-label">Local source path</div>
<input class="settings-input settings-input-wide" spellcheck="false" value={settingsState.serverLocalSourcePath} oninput={(event) => updateSettings({serverLocalSourcePath: (event.currentTarget as HTMLInputElement).value})} />
</label>
</div>
</div>
</section>
+264
View File
@@ -0,0 +1,264 @@
<script lang="ts">
import { onMount } from 'svelte'
import { settingsState } from '$lib/state/settings.svelte'
import { trackingState } from '$lib/state/tracking.svelte'
import { syncTracking } from '$lib/request-manager/tracking'
interface GqlTracker {
id: number
name: string
icon?: string | null
isLoggedIn: boolean
isTokenExpired: boolean
authUrl?: string | null
trackRecords?: {
nodes: Array<{
id: number
manga?: { id: number; title: string; thumbnailUrl: string; inLibrary: boolean } | null
}>
}
}
const GET_TRACKERS = `
query GetTrackers {
trackers {
nodes {
id name icon isLoggedIn isTokenExpired authUrl
trackRecords {
nodes {
id trackerId remoteId title status score displayScore lastChapterRead totalChapters remoteUrl
manga { id title thumbnailUrl inLibrary }
}
}
}
}
}
`
const LOGIN_TRACKER_OAUTH = `
mutation LoginTrackerOAuth($trackerId: Int!, $callbackUrl: String!) {
loginTrackerOAuth(input: { trackerId: $trackerId, callbackUrl: $callbackUrl }) {
isLoggedIn
}
}
`
const LOGIN_TRACKER_CREDENTIALS = `
mutation LoginTrackerCredentials($trackerId: Int!, $username: String!, $password: String!) {
loginTrackerCredentials(input: { trackerId: $trackerId, username: $username, password: $password }) {
isLoggedIn
}
}
`
const LOGOUT_TRACKER = `
mutation LogoutTracker($trackerId: Int!) {
logoutTracker(input: { trackerId: $trackerId }) {
isLoggedIn
}
}
`
let oauthTrackerId = $state<number | null>(null)
let oauthCallback = $state('')
let credsTrackerId = $state<number | null>(null)
let credsUsername = $state('')
let credsPassword = $state('')
function endpoint() {
return `${settingsState.serverUrl.replace(/\/$/, '')}/api/graphql`
}
function authHeaders() {
const headers: Record<string, string> = {'Content-Type': 'application/json'}
if (settingsState.serverAuthMode === 'BASIC_AUTH' && settingsState.serverAuthUser) {
headers.Authorization = `Basic ${btoa(`${settingsState.serverAuthUser}:${settingsState.serverAuthPass}`)}`
}
return headers
}
async function gql<T>(query: string, variables?: Record<string, unknown>) {
const response = await fetch(endpoint(), {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({query, variables}),
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
const json = await response.json() as { data?: T; errors?: { message: string }[] }
if (json.errors?.length) {
throw new Error(json.errors[0].message)
}
return json.data as T
}
async function refreshTrackers() {
trackingState.loading = true
trackingState.error = null
try {
const data = await gql<{ trackers: { nodes: GqlTracker[] } }>(GET_TRACKERS)
trackingState.trackers = data.trackers.nodes as never
} catch (error) {
trackingState.error = error instanceof Error ? error.message : String(error)
} finally {
trackingState.loading = false
}
}
async function reconnectOAuth() {
if (!oauthTrackerId || !oauthCallback.trim()) return
await gql(LOGIN_TRACKER_OAUTH, {trackerId: oauthTrackerId, callbackUrl: oauthCallback.trim()})
oauthTrackerId = null
oauthCallback = ''
await refreshTrackers()
}
async function connectCredentials() {
if (!credsTrackerId || !credsUsername.trim() || !credsPassword.trim()) return
await gql(LOGIN_TRACKER_CREDENTIALS, {
trackerId: credsTrackerId,
username: credsUsername.trim(),
password: credsPassword,
})
credsTrackerId = null
credsUsername = ''
credsPassword = ''
await refreshTrackers()
}
async function disconnectTracker(trackerId: number) {
await gql(LOGOUT_TRACKER, {trackerId})
await refreshTrackers()
}
async function syncAllTrackers() {
trackingState.syncing = true
try {
const mangaIds = new Set<number>()
for (const tracker of trackingState.trackers) {
for (const record of tracker.trackRecords?.nodes ?? []) {
if (record.manga?.id) mangaIds.add(record.manga.id)
}
}
for (const mangaId of mangaIds) {
await syncTracking(String(mangaId))
}
} finally {
trackingState.syncing = false
}
}
function openOAuth(tracker: GqlTracker) {
if (tracker.authUrl) window.open(tracker.authUrl, '_blank', 'noopener')
oauthTrackerId = tracker.id
oauthCallback = ''
credsTrackerId = null
}
function openCredentials(tracker: GqlTracker) {
credsTrackerId = tracker.id
credsUsername = ''
credsPassword = ''
oauthTrackerId = null
}
onMount(() => {
void refreshTrackers()
})
</script>
<svelte:head>
<title>Settings - Tracking</title>
</svelte:head>
<section class="settings-page">
<header class="settings-page-header">
<p class="settings-kicker">Tracking</p>
<h2>Tracker connections</h2>
<p>Connect trackers and sync progress back to your library.</p>
</header>
<div class="settings-card">
<div class="settings-row">
<div>
<div class="settings-label">Connected trackers</div>
<div class="settings-desc">{trackingState.loading ? 'Loading…' : `${trackingState.trackers.length} trackers found`}</div>
</div>
<button class="settings-button" type="button" onclick={() => void refreshTrackers()}>Refresh</button>
</div>
{#each trackingState.trackers as tracker}
<div class="settings-row settings-tracker-row">
<div>
<div class="settings-label">{tracker.name}</div>
<div class="settings-desc">{tracker.isLoggedIn ? 'Connected' : 'Not connected'}{tracker.isTokenExpired ? ' · token expired' : ''}</div>
</div>
<div class="settings-tracker-actions">
{#if tracker.isLoggedIn}
<button class="settings-button" type="button" onclick={() => void disconnectTracker(tracker.id)}>Disconnect</button>
{:else}
<button class="settings-button" type="button" onclick={() => tracker.authUrl ? openOAuth(tracker) : openCredentials(tracker)}>{tracker.authUrl ? 'Open login' : 'Connect'}</button>
{/if}
</div>
</div>
{#if oauthTrackerId === tracker.id}
<div class="settings-row settings-row-stack">
<div>
<div class="settings-label">OAuth callback URL</div>
<div class="settings-desc">Paste the callback URL after authorizing in the browser.</div>
</div>
<input class="settings-input settings-input-wide" spellcheck="false" placeholder="https://…#access_token=…" bind:value={oauthCallback} />
<div class="settings-inline-control">
<button class="settings-button" type="button" onclick={() => void reconnectOAuth()}>Connect</button>
<button class="settings-button" type="button" onclick={() => { oauthTrackerId = null; oauthCallback = ''; }}>Cancel</button>
</div>
</div>
{/if}
{#if credsTrackerId === tracker.id}
<div class="settings-row settings-row-stack">
<div>
<div class="settings-label">Tracker login</div>
<div class="settings-desc">Use a username and password to connect.</div>
</div>
<div class="settings-grid-2">
<input class="settings-input settings-input-wide" placeholder="Username" bind:value={credsUsername} />
<input class="settings-input settings-input-wide" type="password" placeholder="Password" bind:value={credsPassword} />
</div>
<div class="settings-inline-control">
<button class="settings-button" type="button" onclick={() => void connectCredentials()}>Connect</button>
<button class="settings-button" type="button" onclick={() => { credsTrackerId = null; credsUsername = ''; credsPassword = ''; }}>Cancel</button>
</div>
</div>
{/if}
{/each}
<div class="settings-row">
<div>
<div class="settings-label">Sync back now</div>
<div class="settings-desc">Apply tracker progress to all linked manga in your library.</div>
</div>
<button class="settings-button" type="button" onclick={() => void syncAllTrackers()} disabled={trackingState.syncing}>Sync all</button>
</div>
</div>
</section>
<style>
:global(.settings-tracker-row) {
border-top: 1px solid var(--border-dim);
}
:global(.settings-tracker-actions) {
display: flex;
gap: var(--sp-3);
flex-wrap: wrap;
justify-content: flex-end;
}
</style>