mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Chore: Port over Settings (Barely Works)
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { listen } from '@tauri-apps/api/event'
|
||||
import { getVersion } from '@tauri-apps/api/app'
|
||||
import { open as openUrl } from '@tauri-apps/plugin-shell'
|
||||
import { autoBackupAppData } from '$lib/core/backup'
|
||||
import { requestManager } from '$lib/request-manager'
|
||||
|
||||
interface ReleaseInfo { tag_name: string; name: string; body: string; published_at: string; html_url: string }
|
||||
type UpdatePhase = 'idle' | 'downloading' | 'launching' | 'ready' | 'error'
|
||||
const IS_WINDOWS = navigator.userAgent.includes('Windows')
|
||||
|
||||
interface AboutServer { name: string; version: string; buildType: string; buildTime: number; github: string; discord: string }
|
||||
interface AboutWebUI { channel: string; tag: string; updateTimestamp: number }
|
||||
|
||||
let appVersion = $state('…')
|
||||
let releases = $state<ReleaseInfo[]>([])
|
||||
let releasesLoading = $state(false)
|
||||
let releasesError = $state<string | null>(null)
|
||||
let expandedTag = $state<string | null>(null)
|
||||
let updatePhase = $state<UpdatePhase>('idle')
|
||||
let updateError = $state<string | null>(null)
|
||||
let dlBytes = $state(0)
|
||||
let dlTotal = $state<number | null>(null)
|
||||
let targetTag = $state<string | null>(null)
|
||||
let releasesLoaded = false
|
||||
|
||||
let serverInfo = $state<AboutServer | null>(null)
|
||||
let webuiInfo = $state<AboutWebUI | null>(null)
|
||||
|
||||
$effect(() => {
|
||||
getVersion().then(v => appVersion = v).catch(() => appVersion = 'unknown')
|
||||
if (!releasesLoaded) { releasesLoaded = true; loadReleases() }
|
||||
})
|
||||
|
||||
$effect(() => { loadServerInfo() })
|
||||
|
||||
$effect(() => {
|
||||
let unlisten: (() => void) | undefined
|
||||
listen<{ downloaded: number; total: number | null }>('update-progress', e => {
|
||||
dlBytes = e.payload.downloaded; dlTotal = e.payload.total ?? null
|
||||
}).then(fn => { unlisten = fn })
|
||||
return () => unlisten?.()
|
||||
})
|
||||
|
||||
$effect(() => {
|
||||
let unlisten: (() => void) | undefined
|
||||
listen('update-launching', () => { updatePhase = 'launching' }).then(fn => { unlisten = fn })
|
||||
return () => unlisten?.()
|
||||
})
|
||||
|
||||
async function loadReleases() {
|
||||
releasesLoading = true; releasesError = null
|
||||
try {
|
||||
const timeout = new Promise<never>((_, reject) => setTimeout(() => reject(new Error('Request timed out after 10s')), 10_000))
|
||||
const all = await Promise.race([invoke<ReleaseInfo[]>('list_releases'), timeout])
|
||||
releases = all.filter(r => typeof r.tag_name === 'string' && r.tag_name.trim())
|
||||
} catch (e: any) {
|
||||
releasesError = e instanceof Error ? e.message : String(e)
|
||||
} finally { releasesLoading = false }
|
||||
}
|
||||
|
||||
async function loadServerInfo() {
|
||||
try {
|
||||
const [s, w] = await Promise.all([
|
||||
requestManager.meta.getAboutServer(),
|
||||
requestManager.meta.getAboutWebUI(),
|
||||
])
|
||||
serverInfo = s
|
||||
webuiInfo = w
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function stripV(v: string) { return v.replace(/^v/, '') }
|
||||
function isCurrentVersion(tag: string) { return stripV(tag) === appVersion }
|
||||
function parseSemver(v: string) { return stripV(v).split('.').map(Number) }
|
||||
function compareSemver(a: string, b: string) {
|
||||
const pa = parseSemver(a), pb = parseSemver(b)
|
||||
for (let i = 0; i < 3; i++) if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pb[i] ?? 0) - (pa[i] ?? 0)
|
||||
return 0
|
||||
}
|
||||
|
||||
const onLatestVersion = $derived((() => {
|
||||
if (releasesLoading || releases.length === 0 || !appVersion || appVersion === '…') return false
|
||||
const sorted = releases.slice().sort((a, b) => compareSemver(a.tag_name, b.tag_name))
|
||||
return compareSemver(appVersion, sorted[0].tag_name) >= 0
|
||||
})())
|
||||
|
||||
function fmtDate(iso: string) {
|
||||
if (!iso) return ''
|
||||
return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function fmtBuildTime(unix: number | string) {
|
||||
if (!unix) return ''
|
||||
return new Date(Number(unix) * 1000).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
function fmtBytes(bytes: number) {
|
||||
if (bytes === 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(i >= 2 ? 1 : 0)} ${units[i]}`
|
||||
}
|
||||
|
||||
function fmtProgress() {
|
||||
return dlTotal
|
||||
? `${fmtBytes(dlBytes)} / ${fmtBytes(dlTotal)} (${Math.round((dlBytes / dlTotal) * 100)}%)`
|
||||
: fmtBytes(dlBytes)
|
||||
}
|
||||
|
||||
async function installUpdate(release: ReleaseInfo) {
|
||||
if (updatePhase === 'downloading') return
|
||||
targetTag = release.tag_name; updatePhase = 'downloading'; updateError = null; dlBytes = 0; dlTotal = null
|
||||
try {
|
||||
if (IS_WINDOWS) {
|
||||
await autoBackupAppData()
|
||||
try { await invoke('kill_server') } catch {}
|
||||
await invoke('download_and_install_update', { tag: release.tag_name })
|
||||
updatePhase = 'ready'
|
||||
} else {
|
||||
await openUrl(release.html_url)
|
||||
updatePhase = 'idle'; targetTag = null
|
||||
}
|
||||
} catch (e: any) {
|
||||
updateError = e instanceof Error ? e.message : String(e)
|
||||
updatePhase = 'error'
|
||||
}
|
||||
}
|
||||
|
||||
async function restartNow() { await invoke('restart_app') }
|
||||
function cancelUpdate() { updatePhase = 'idle'; updateError = null; targetTag = null; dlBytes = 0; dlTotal = null }
|
||||
</script>
|
||||
|
||||
<div class="s-panel">
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Moku</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row" style="flex-direction:column;align-items:flex-start;gap:var(--sp-1)">
|
||||
<span class="s-label">A manga reader frontend for Suwayomi / Tachidesk.</span>
|
||||
<span class="s-desc">Built with Tauri + Svelte.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Version</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Installed</span><span class="s-desc">v{appVersion}</span></div>
|
||||
<button class="s-btn" onclick={() => { releasesError = null; loadReleases() }} disabled={releasesLoading}>
|
||||
{releasesLoading ? 'Loading…' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
{#if onLatestVersion}
|
||||
<div class="s-row">
|
||||
<span class="s-desc" style="color:var(--accent-fg)">✓ You're on the latest version.</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if updatePhase === 'downloading' && IS_WINDOWS}
|
||||
<div class="s-update-progress">
|
||||
<div class="s-update-bar">
|
||||
<div class="s-update-fill" style="width:{dlTotal ? Math.round((dlBytes / dlTotal) * 100) : 0}%"></div>
|
||||
</div>
|
||||
<div class="s-update-labels">
|
||||
<span>Downloading {targetTag ?? 'update'}…</span>
|
||||
<span>{fmtProgress()}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if updatePhase === 'launching'}
|
||||
<div class="s-update-ready">
|
||||
<span class="s-update-ready-label">Launching installer for {targetTag}…</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if updatePhase === 'ready'}
|
||||
<div class="s-update-ready">
|
||||
<span class="s-update-ready-label">{targetTag} downloaded — restart to finish installing.</span>
|
||||
<button class="s-btn s-btn-accent" onclick={restartNow}>Restart now</button>
|
||||
<button class="s-btn-icon" onclick={cancelUpdate} title="Dismiss">✕</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if updatePhase === 'error'}
|
||||
<div class="s-row">
|
||||
<span class="s-desc" style="color:var(--color-error)">{updateError}</span>
|
||||
<button class="s-btn" onclick={cancelUpdate}>Dismiss</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if serverInfo}
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Server</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Version</span>
|
||||
<span class="s-desc">
|
||||
{serverInfo.version}
|
||||
{#if serverInfo.buildType}
|
||||
<span class="s-release-badge">{serverInfo.buildType}</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if serverInfo.buildTime}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Built</span>
|
||||
<span class="s-desc">{fmtBuildTime(serverInfo.buildTime)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if webuiInfo?.channel}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Channel</span>
|
||||
<span class="s-desc">{webuiInfo.channel}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Releases</p>
|
||||
<div class="s-section-body">
|
||||
{#if releasesError}
|
||||
<p class="s-empty" style="color:var(--color-error)">{releasesError}</p>
|
||||
{:else if releasesLoading}
|
||||
<p class="s-empty">Fetching releases…</p>
|
||||
{:else if releases.length === 0}
|
||||
<p class="s-empty">No releases found.</p>
|
||||
{:else}
|
||||
<div class="s-release-scroll">
|
||||
{#each releases as release}
|
||||
{@const isCurrent = isCurrentVersion(release.tag_name)}
|
||||
{@const isExpanded = expandedTag === release.tag_name}
|
||||
{@const isTarget = targetTag === release.tag_name}
|
||||
{@const isInstalling = isTarget && updatePhase === 'downloading'}
|
||||
<div class="s-release-row" class:current={isCurrent}>
|
||||
<div class="s-release-header">
|
||||
<div class="s-release-meta">
|
||||
<span class="s-release-tag">{release.tag_name}</span>
|
||||
{#if isCurrent}<span class="s-release-badge">installed</span>{/if}
|
||||
{#if release.published_at}<span class="s-release-date">{fmtDate(release.published_at)}</span>{/if}
|
||||
</div>
|
||||
<div class="s-btn-row">
|
||||
{#if release.body.trim()}
|
||||
<button class="s-btn" onclick={() => expandedTag = isExpanded ? null : release.tag_name}>
|
||||
{isExpanded ? 'Hide' : 'Changelog'}
|
||||
</button>
|
||||
{/if}
|
||||
{#if !isCurrent}
|
||||
{#if IS_WINDOWS}
|
||||
<button class="s-btn" class:s-btn-accent={!isInstalling}
|
||||
disabled={updatePhase === 'downloading'} onclick={() => installUpdate(release)}>
|
||||
{isInstalling ? 'Downloading…' : 'Install'}
|
||||
</button>
|
||||
{:else}
|
||||
<button class="s-btn" onclick={() => installUpdate(release)}>Open on GitHub</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if isExpanded && release.body.trim()}
|
||||
<div class="s-release-body">
|
||||
<pre class="s-release-body pre">{release.body.trim()}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Links</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row" style="flex-direction:column;align-items:flex-start;gap:var(--sp-2)">
|
||||
<a href="https://github.com/moku-project/Moku" target="_blank" class="s-label" style="color:var(--accent-fg);text-decoration:none">GitHub →</a>
|
||||
<a href="https://discord.gg/Jq3pwuNqPp" target="_blank" class="s-label" style="color:var(--accent-fg);text-decoration:none">Discord →</a>
|
||||
{#if serverInfo?.github && serverInfo.github !== 'https://github.com/moku-project/Moku'}
|
||||
<a href={serverInfo.github} target="_blank" class="s-label" style="color:var(--accent-fg);text-decoration:none">Suwayomi GitHub →</a>
|
||||
{/if}
|
||||
{#if serverInfo?.discord && serverInfo.discord !== 'https://discord.gg/Jq3pwuNqPp'}
|
||||
<a href={serverInfo.discord} target="_blank" class="s-label" style="color:var(--accent-fg);text-decoration:none">Suwayomi Discord →</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,161 @@
|
||||
<script lang="ts">
|
||||
import { Pencil, Trash, Plus } from 'phosphor-svelte'
|
||||
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
|
||||
|
||||
interface Props {
|
||||
selectOpen: string | null
|
||||
closingSelect: string | null
|
||||
toggleSelect: (id: string) => void
|
||||
anims: boolean
|
||||
onOpenThemeEditor?: (id?: string | null) => void
|
||||
}
|
||||
let { selectOpen, closingSelect, toggleSelect, anims, onOpenThemeEditor }: Props = $props()
|
||||
|
||||
const THEMES = [
|
||||
{ id: 'original', label: 'Original', description: 'Default near-black', swatches: ['#101010','#151515','#a8c4a8','#f0efec'] },
|
||||
{ id: 'dark', label: 'Dark', description: 'Darker base, sharper text', swatches: ['#080808','#111111','#bcd8bc','#ffffff'] },
|
||||
{ id: 'light', label: 'Light', description: 'Warm off-white', swatches: ['#f4f2ee','#faf8f4','#2a5a2a','#1a1916'] },
|
||||
{ id: 'midnight', label: 'Midnight', description: 'Deep blue-black tint', swatches: ['#0c1020','#101428','#a8b4e8','#eeeef8'] },
|
||||
{ id: 'warm', label: 'Warm', description: 'Amber and sepia tones', swatches: ['#16130c','#1c1810','#e0b860','#f5f0e0'] },
|
||||
]
|
||||
|
||||
const allThemeOptions = $derived([
|
||||
...THEMES.map(t => ({ id: t.id, label: t.label })),
|
||||
...(settingsState.settings.customThemes ?? []).map(t => ({ id: t.id, label: t.name })),
|
||||
])
|
||||
|
||||
let triggerDark = $state<HTMLButtonElement>(null!)
|
||||
let triggerLight = $state<HTMLButtonElement>(null!)
|
||||
|
||||
function deleteCustomTheme(id: string) {
|
||||
updateSettings({ customThemes: (settingsState.settings.customThemes ?? []).filter(t => t.id !== id) })
|
||||
if (settingsState.settings.theme === id) updateSettings({ theme: 'dark' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="s-panel">
|
||||
|
||||
<div class="s-section">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Match system theme</span>
|
||||
<span class="s-desc">Automatically switch theme when your OS switches between light and dark</span>
|
||||
</div>
|
||||
<button class="s-toggle" class:on={settingsState.settings.systemThemeSync}
|
||||
onclick={() => updateSettings({ systemThemeSync: !settingsState.settings.systemThemeSync })}
|
||||
role="switch" aria-label="Match system theme" aria-checked={settingsState.settings.systemThemeSync}>
|
||||
<span class="s-toggle-thumb"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if settingsState.settings.systemThemeSync}
|
||||
<div class="s-sync-pair">
|
||||
<div class="s-sync-item">
|
||||
<span class="s-sync-label">Dark theme</span>
|
||||
<div class="s-select">
|
||||
<button bind:this={triggerDark} class="s-select-btn" onclick={() => toggleSelect('sync-dark')}>
|
||||
<span>{allThemeOptions.find(o => o.id === (settingsState.settings.systemThemeDark ?? 'dark'))?.label ?? 'Dark'}</span>
|
||||
<svg class="s-select-caret" class:open={selectOpen === 'sync-dark'} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||
</button>
|
||||
{#if selectOpen === 'sync-dark' || closingSelect === 'sync-dark'}
|
||||
<div class="s-select-menu" class:anims class:closing={closingSelect === 'sync-dark'}>
|
||||
{#each allThemeOptions as opt}
|
||||
<button class="s-select-option" class:active={opt.id === (settingsState.settings.systemThemeDark ?? 'dark')}
|
||||
onclick={() => { updateSettings({ systemThemeDark: opt.id }); toggleSelect('sync-dark') }}>
|
||||
{opt.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="s-sync-item">
|
||||
<span class="s-sync-label">Light theme</span>
|
||||
<div class="s-select">
|
||||
<button bind:this={triggerLight} class="s-select-btn" onclick={() => toggleSelect('sync-light')}>
|
||||
<span>{allThemeOptions.find(o => o.id === (settingsState.settings.systemThemeLight ?? 'light'))?.label ?? 'Light'}</span>
|
||||
<svg class="s-select-caret" class:open={selectOpen === 'sync-light'} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||
</button>
|
||||
{#if selectOpen === 'sync-light' || closingSelect === 'sync-light'}
|
||||
<div class="s-select-menu" class:anims class:closing={closingSelect === 'sync-light'}>
|
||||
{#each allThemeOptions as opt}
|
||||
<button class="s-select-option" class:active={opt.id === (settingsState.settings.systemThemeLight ?? 'light')}
|
||||
onclick={() => { updateSettings({ systemThemeLight: opt.id }); toggleSelect('sync-light') }}>
|
||||
{opt.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Theme</p>
|
||||
<div class="s-theme-grid">
|
||||
{#each THEMES as theme}
|
||||
{@const active = (settingsState.settings.theme ?? 'dark') === theme.id}
|
||||
<div class="s-theme-card" class:active>
|
||||
<button class="s-theme-card" style="border:none;padding:0;width:100%;display:block" onclick={() => updateSettings({ theme: theme.id })} title={theme.description}>
|
||||
<div class="s-theme-preview">
|
||||
<div class="s-theme-preview-bg" style="background:{theme.swatches[0]}">
|
||||
<div class="s-theme-preview-sidebar" style="background:{theme.swatches[1]}"></div>
|
||||
<div class="s-theme-preview-content">
|
||||
<div class="s-theme-preview-accent" style="background:{theme.swatches[2]}"></div>
|
||||
<div class="s-theme-preview-text" style="background:{theme.swatches[3]}55"></div>
|
||||
<div class="s-theme-preview-text" style="background:{theme.swatches[3]}33;width:60%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="s-theme-info">
|
||||
<span class="s-theme-name">{theme.label}</span>
|
||||
<span class="s-theme-desc">{theme.description}</span>
|
||||
</div>
|
||||
{#if active}<span class="s-theme-check">✓</span>{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
{#each settingsState.settings.customThemes ?? [] as custom}
|
||||
{@const active = settingsState.settings.theme === custom.id}
|
||||
<div class="s-theme-card" class:active>
|
||||
<div class="s-theme-actions">
|
||||
<button class="s-theme-action-btn edit" onclick={() => onOpenThemeEditor?.(custom.id)} title="Edit theme"><Pencil size={10} /></button>
|
||||
<button class="s-theme-action-btn delete"
|
||||
onclick={() => { if (confirm(`Delete theme "${custom.name}"?`)) deleteCustomTheme(custom.id) }}
|
||||
title="Delete theme"><Trash size={10} /></button>
|
||||
</div>
|
||||
<button style="border:none;padding:0;width:100%;display:block;background:none;cursor:pointer"
|
||||
onclick={() => updateSettings({ theme: custom.id })} title="Apply {custom.name}">
|
||||
<div class="s-theme-preview">
|
||||
<div class="s-theme-preview-bg" style="background:{custom.tokens['bg-base']}">
|
||||
<div class="s-theme-preview-sidebar" style="background:{custom.tokens['bg-surface']}"></div>
|
||||
<div class="s-theme-preview-content">
|
||||
<div class="s-theme-preview-accent" style="background:{custom.tokens['accent']}"></div>
|
||||
<div class="s-theme-preview-text" style="background:{custom.tokens['text-primary']}55"></div>
|
||||
<div class="s-theme-preview-text" style="background:{custom.tokens['text-primary']}33;width:60%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="s-theme-info">
|
||||
<span class="s-theme-name">{custom.name}</span>
|
||||
<span class="s-theme-desc" style="color:var(--accent-fg)">Custom</span>
|
||||
</div>
|
||||
</button>
|
||||
{#if active}<span class="s-theme-check">✓</span>{/if}
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<button class="s-theme-card s-theme-new" onclick={() => onOpenThemeEditor?.(null)} title="Create a custom theme">
|
||||
<Plus size={18} weight="light" />
|
||||
<div class="s-theme-info">
|
||||
<span class="s-theme-name">New Theme</span>
|
||||
<span class="s-theme-desc">Create custom</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,143 @@
|
||||
<script lang="ts">
|
||||
import { ArrowCounterClockwise, LockSimple, Warning } from 'phosphor-svelte'
|
||||
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
|
||||
import type { MangaPrefs } from '$lib/types/settings'
|
||||
|
||||
const DOWNLOAD_AHEAD_OPTIONS = [{ value: 0, label: 'Off' },{ value: 2, label: '2' },{ value: 5, label: '5' },{ value: 10, label: '10' }]
|
||||
const MAX_KEEP_OPTIONS = [{ value: 0, label: 'Off' },{ value: 5, label: '5' },{ value: 10, label: '10' },{ value: 25, label: '25' }]
|
||||
const DELETE_DELAY_OPTIONS = [{ value: 0, label: 'Now' },{ value: 24, label: '1 day' },{ value: 168, label: '1 week' }]
|
||||
const REFRESH_INTERVAL_OPTIONS = [{ value: 'daily', label: 'Daily' },{ value: 'weekly', label: 'Weekly' },{ value: 'manual', label: 'Manual' }]
|
||||
|
||||
type GlobalDefaults = Omit<MangaPrefs, 'refreshInterval'> & { refreshInterval: 'daily' | 'weekly' | 'manual' }
|
||||
|
||||
const fallback: GlobalDefaults = {
|
||||
autoDownload: false, downloadAhead: 0, maxKeepChapters: 0, deleteOnRead: false,
|
||||
deleteDelayHours: 0, pauseUpdates: false, refreshInterval: 'weekly',
|
||||
preferredScanlator: '', scanlatorFilter: [], scanlatorBlacklist: [],
|
||||
scanlatorForce: false, autoDownloadScanlators: [],
|
||||
}
|
||||
|
||||
function getGlobal<K extends keyof GlobalDefaults>(key: K): GlobalDefaults[K] {
|
||||
return (settingsState.settings.automationDefaults as GlobalDefaults | undefined)?.[key] ?? fallback[key]
|
||||
}
|
||||
|
||||
function setGlobal<K extends keyof GlobalDefaults>(key: K, value: GlobalDefaults[K]) {
|
||||
updateSettings({ automationDefaults: { ...(settingsState.settings.automationDefaults ?? fallback), [key]: value } })
|
||||
}
|
||||
|
||||
const enforceGlobal = $derived(settingsState.settings.automationEnforceGlobal ?? false)
|
||||
|
||||
const customCount = $derived(
|
||||
Object.values(settingsState.settings.mangaPrefs ?? {}).filter(p => p && Object.keys(p).length > 0).length
|
||||
)
|
||||
|
||||
let confirmReset = $state(false)
|
||||
|
||||
function resetAllCustoms() {
|
||||
if (!confirmReset) { confirmReset = true; return }
|
||||
updateSettings({ mangaPrefs: {} })
|
||||
confirmReset = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="s-panel">
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Behaviour</p>
|
||||
<div class="s-section-body">
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Enable automation</span><span class="s-desc">Allow per-series and global automation rules to run</span></div>
|
||||
<button role="switch" aria-checked={settingsState.settings.automationEnabled ?? false} aria-label="Enable automation" class="s-toggle" class:on={settingsState.settings.automationEnabled ?? false} onclick={() => updateSettings({ automationEnabled: !(settingsState.settings.automationEnabled ?? false) })}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Enforce global defaults</span><span class="s-desc">Ignore per-series overrides — all series use the global settings below</span></div>
|
||||
<button role="switch" aria-checked={enforceGlobal} aria-label="Enforce global defaults" class="s-toggle" class:on={enforceGlobal} onclick={() => updateSettings({ automationEnforceGlobal: !enforceGlobal })}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
{#if enforceGlobal}
|
||||
<div class="s-banner s-banner-info enforce-banner"><LockSimple size={12} weight="fill" /><span>Per-series overrides are paused.</span></div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Global Defaults</p>
|
||||
<div class="s-section-body">
|
||||
<p class="sub-head">Downloads</p>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Auto-download new chapters</span><span class="s-desc">Queue new chapters when a series refreshes</span></div>
|
||||
<button role="switch" aria-checked={getGlobal('autoDownload')} aria-label="Auto-download new chapters" class="s-toggle" class:on={getGlobal('autoDownload')} onclick={() => setGlobal('autoDownload', !getGlobal('autoDownload'))}><span class="s-toggle-thumb"></span></button>
|
||||
</div>
|
||||
<div class="s-row chip-row">
|
||||
<div class="s-row-info"><span class="s-label">Download ahead</span><span class="s-desc">Pre-fetch chapters while reading</span></div>
|
||||
<div class="chip-group">
|
||||
{#each DOWNLOAD_AHEAD_OPTIONS as opt}
|
||||
<button class="s-preset" class:active={getGlobal('downloadAhead') === opt.value} onclick={() => setGlobal('downloadAhead', opt.value)}>{opt.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="s-row chip-row">
|
||||
<div class="s-row-info"><span class="s-label">Max chapters to keep</span><span class="s-desc">Delete oldest downloads when limit is exceeded</span></div>
|
||||
<div class="chip-group">
|
||||
{#each MAX_KEEP_OPTIONS as opt}
|
||||
<button class="s-preset" class:active={getGlobal('maxKeepChapters') === opt.value} onclick={() => setGlobal('maxKeepChapters', opt.value)}>{opt.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<p class="sub-head sub-head-rule">On Read</p>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Delete after reading</span><span class="s-desc">Remove download when a chapter is marked read</span></div>
|
||||
<button role="switch" aria-checked={getGlobal('deleteOnRead')} aria-label="Delete after reading" class="s-toggle" class:on={getGlobal('deleteOnRead')} onclick={() => setGlobal('deleteOnRead', !getGlobal('deleteOnRead'))}><span class="s-toggle-thumb"></span></button>
|
||||
</div>
|
||||
{#if getGlobal('deleteOnRead')}
|
||||
<div class="s-row chip-row sub-row">
|
||||
<span class="s-label">Delete delay</span>
|
||||
<div class="chip-group">
|
||||
{#each DELETE_DELAY_OPTIONS as opt}
|
||||
<button class="s-preset" class:active={getGlobal('deleteDelayHours') === opt.value} onclick={() => setGlobal('deleteDelayHours', opt.value)}>{opt.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<p class="sub-head sub-head-rule">Updates</p>
|
||||
<div class="s-row chip-row">
|
||||
<div class="s-row-info"><span class="s-label">Default refresh interval</span><span class="s-desc">How often series check for new chapters by default</span></div>
|
||||
<div class="chip-group">
|
||||
{#each REFRESH_INTERVAL_OPTIONS as opt}
|
||||
<button class="s-preset" class:active={getGlobal('refreshInterval') === opt.value} onclick={() => setGlobal('refreshInterval', opt.value as GlobalDefaults['refreshInterval'])}>{opt.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Custom Overrides</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Series with custom rules</span><span class="s-desc">Per-series settings set via the series automation panel</span></div>
|
||||
<span class="s-pill" class:on={customCount > 0}>{customCount}</span>
|
||||
</div>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Reset all custom rules</span><span class="s-desc">Revert every series to the global defaults above</span></div>
|
||||
{#if confirmReset}
|
||||
<div class="s-btn-row">
|
||||
<button class="s-btn s-btn-danger" onclick={resetAllCustoms}><Warning size={11} weight="fill" /> Confirm reset</button>
|
||||
<button class="s-btn" onclick={() => confirmReset = false}>Cancel</button>
|
||||
</div>
|
||||
{:else}
|
||||
<button class="s-btn" disabled={customCount === 0} onclick={resetAllCustoms}><ArrowCounterClockwise size={11} /> Reset</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.enforce-banner { display: flex; align-items: center; gap: var(--sp-2); }
|
||||
.sub-head { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-widest); text-transform: uppercase; color: var(--text-faint); margin: 0; padding: var(--sp-2) var(--sp-4) 0; }
|
||||
.sub-head-rule { border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); margin-top: var(--sp-1); }
|
||||
.chip-row { align-items: flex-start; padding-top: 8px; padding-bottom: 8px; }
|
||||
.chip-group { display: flex; flex-direction: row; gap: 4px; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; }
|
||||
.sub-row { padding-left: calc(var(--sp-4) + var(--sp-2)); border-left: 2px solid var(--border-dim); }
|
||||
</style>
|
||||
@@ -0,0 +1,212 @@
|
||||
<script lang="ts">
|
||||
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
|
||||
import { requestManager } from '$lib/request-manager'
|
||||
import { platformService } from '$lib/platform-service'
|
||||
import type { ContentLevel } from '$lib/types/settings'
|
||||
import type { Source } from '$lib/types'
|
||||
|
||||
let contentSources = $state<Source[]>([])
|
||||
let contentSourcesLoading = $state(false)
|
||||
let sourceSearch = $state('')
|
||||
|
||||
$effect(() => {
|
||||
if (settingsState.settings.sourceOverridesEnabled && contentSources.length === 0 && !contentSourcesLoading)
|
||||
loadContentSources()
|
||||
})
|
||||
|
||||
async function loadContentSources() {
|
||||
contentSourcesLoading = true
|
||||
try {
|
||||
const d = await requestManager.extensions.getSources()
|
||||
contentSources = d.filter(s => s.id !== '0')
|
||||
} catch (e) { console.error(e) }
|
||||
finally { contentSourcesLoading = false }
|
||||
}
|
||||
|
||||
function toggleSourceAllowed(ids: string[]) {
|
||||
const allowed = settingsState.settings.nsfwAllowedSourceIds ?? []
|
||||
const blocked = settingsState.settings.nsfwBlockedSourceIds ?? []
|
||||
const allAllowed = ids.every(id => allowed.includes(id))
|
||||
if (allAllowed) {
|
||||
updateSettings({ nsfwAllowedSourceIds: allowed.filter(x => !ids.includes(x)) })
|
||||
} else {
|
||||
updateSettings({
|
||||
nsfwAllowedSourceIds: [...allowed.filter(x => !ids.includes(x)), ...ids],
|
||||
nsfwBlockedSourceIds: blocked.filter(x => !ids.includes(x)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSourceBlocked(ids: string[]) {
|
||||
const allowed = settingsState.settings.nsfwAllowedSourceIds ?? []
|
||||
const blocked = settingsState.settings.nsfwBlockedSourceIds ?? []
|
||||
const allBlocked = ids.every(id => blocked.includes(id))
|
||||
if (allBlocked) {
|
||||
updateSettings({ nsfwBlockedSourceIds: blocked.filter(x => !ids.includes(x)) })
|
||||
} else {
|
||||
updateSettings({
|
||||
nsfwBlockedSourceIds: [...blocked.filter(x => !ids.includes(x)), ...ids],
|
||||
nsfwAllowedSourceIds: allowed.filter(x => !ids.includes(x)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
interface ContentSourceGroup { name: string; iconUrl: string; isNsfw: boolean; sources: Source[] }
|
||||
|
||||
const contentSourcesFiltered = $derived.by(() => {
|
||||
const q = sourceSearch.trim().toLowerCase()
|
||||
const filtered = q
|
||||
? contentSources.filter(s => s.displayName.toLowerCase().includes(q) || s.lang.toLowerCase().includes(q))
|
||||
: contentSources
|
||||
const map = new Map<string, ContentSourceGroup>()
|
||||
for (const s of filtered) {
|
||||
if (!map.has(s.name)) map.set(s.name, { name: s.name, iconUrl: s.iconUrl, isNsfw: s.isNsfw, sources: [] })
|
||||
map.get(s.name)!.sources.push(s)
|
||||
}
|
||||
return Array.from(map.values())
|
||||
})
|
||||
|
||||
const LEVELS: { value: ContentLevel; label: string; desc: string }[] = [
|
||||
{ value: 'strict', label: 'Strict', desc: 'Hides all adult, sexual, and graphic violent content' },
|
||||
{ value: 'moderate', label: 'Moderate', desc: 'Allows violence and gore, filters sexual content' },
|
||||
{ value: 'unrestricted', label: 'Unrestricted', desc: 'No content filtering applied' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<div class="s-panel">
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Content Level</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row" style="border-bottom: none; padding-bottom: 0;">
|
||||
<span class="s-desc">Controls what content is visible across library, search, and discover.</span>
|
||||
</div>
|
||||
<div class="s-level-group">
|
||||
{#each LEVELS as lvl}
|
||||
{@const active = settingsState.settings.contentLevel === lvl.value}
|
||||
<button class="s-level-btn" class:active onclick={() => updateSettings({ contentLevel: lvl.value })}>
|
||||
<span class="s-level-dot" class:active></span>
|
||||
<div class="s-level-text">
|
||||
<span class="s-level-label">{lvl.label}</span>
|
||||
<span class="s-level-desc">{lvl.desc}</span>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Source Overrides</p>
|
||||
<div class="s-section-body">
|
||||
<label class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Per-source overrides</span>
|
||||
<span class="s-desc">Allow a source through even if flagged NSFW, or always block it. Allowed sources still respect the active content level.</span>
|
||||
</div>
|
||||
<button
|
||||
role="switch"
|
||||
aria-checked={settingsState.settings.sourceOverridesEnabled}
|
||||
aria-label="Enable source overrides"
|
||||
class="s-toggle"
|
||||
class:on={settingsState.settings.sourceOverridesEnabled}
|
||||
onclick={() => updateSettings({ sourceOverridesEnabled: !settingsState.settings.sourceOverridesEnabled })}
|
||||
><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
|
||||
{#if settingsState.settings.sourceOverridesEnabled}
|
||||
<div class="s-search-wrap">
|
||||
<input class="s-input full" placeholder="Filter sources…" bind:value={sourceSearch} />
|
||||
</div>
|
||||
{#if contentSourcesLoading}
|
||||
<p class="s-empty">Loading sources…</p>
|
||||
{:else if contentSources.length === 0}
|
||||
<p class="s-empty">No sources found — check your server connection.</p>
|
||||
{:else}
|
||||
<div class="s-source-list">
|
||||
{#each contentSourcesFiltered as group (group.name)}
|
||||
{@const ids = group.sources.map(s => s.id)}
|
||||
{@const allowed = settingsState.settings.nsfwAllowedSourceIds ?? []}
|
||||
{@const blocked = settingsState.settings.nsfwBlockedSourceIds ?? []}
|
||||
{@const isAllowed = ids.every(id => allowed.includes(id))}
|
||||
{@const isBlocked = ids.every(id => blocked.includes(id))}
|
||||
<div class="s-source-row" class:allowed={isAllowed} class:blocked={isBlocked}>
|
||||
<img src={platformService.thumbUrl(group.iconUrl)} alt="" class="s-source-icon" loading="lazy" decoding="async" />
|
||||
<div class="s-source-info">
|
||||
<span class="s-source-name">{group.name}</span>
|
||||
<span class="s-source-meta">
|
||||
{group.sources[0].isNsfw ? 'NSFW · ' : ''}{group.sources.length > 1 ? `${group.sources.length} languages` : group.sources[0].lang.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div class="s-source-actions">
|
||||
<button class="s-source-action-btn" class:allow={isAllowed} onclick={() => toggleSourceAllowed(ids)}>Allow</button>
|
||||
<button class="s-source-action-btn" class:block={isBlocked} onclick={() => toggleSourceBlocked(ids)}>Block</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.s-level-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--sp-2) var(--sp-4) var(--sp-3);
|
||||
gap: var(--sp-1);
|
||||
}
|
||||
|
||||
.s-level-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sp-3);
|
||||
padding: 10px var(--sp-3);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-dim);
|
||||
background: var(--bg-surface);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: border-color var(--t-base), background var(--t-base);
|
||||
width: 100%;
|
||||
}
|
||||
.s-level-btn:hover { background: var(--bg-overlay); border-color: var(--border-strong); }
|
||||
.s-level-btn.active { background: var(--accent-muted); border-color: var(--accent-dim); }
|
||||
|
||||
.s-level-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--border-strong);
|
||||
background: none;
|
||||
flex-shrink: 0;
|
||||
transition: border-color var(--t-base), background var(--t-base);
|
||||
}
|
||||
.s-level-dot.active { border-color: var(--accent); background: var(--accent); }
|
||||
|
||||
.s-level-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.s-level-label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.3;
|
||||
}
|
||||
.s-level-btn.active .s-level-label { color: var(--accent-fg); }
|
||||
|
||||
.s-level-desc {
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-faint);
|
||||
letter-spacing: var(--tracking-wide);
|
||||
line-height: var(--leading-snug);
|
||||
}
|
||||
.s-level-btn.active .s-level-desc { color: var(--accent-fg); opacity: 0.7; }
|
||||
</style>
|
||||
@@ -0,0 +1,241 @@
|
||||
<script lang="ts">
|
||||
import ThreeDCard from '$lib/components/shared/manga/ThreeDCard.svelte'
|
||||
import { appState } from '$lib/state/app.svelte'
|
||||
import { toast } from '$lib/state/notifications.svelte'
|
||||
import { settingsState } from '$lib/state/settings.svelte'
|
||||
import { cache } from '$lib/core/cache/queryCache'
|
||||
import { getUiAuthDebugStatus, refreshUiAccessToken, type UiAuthDebugStatus } from '$lib/core/auth'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
|
||||
interface PerfSnapshot { cacheEntries: number; cacheKeys: string[]; oldestEntryMs: number | null; newestEntryMs: number | null }
|
||||
|
||||
let perfSnapshot = $state<PerfSnapshot | null>(null)
|
||||
let splashTriggered = $state(false)
|
||||
let expOpen = $state(false)
|
||||
let appVersion = $state('…')
|
||||
let helloAvailable = $state<boolean | null>(null)
|
||||
let helloBusy = $state(false)
|
||||
let authStatus = $state<UiAuthDebugStatus | null>(null)
|
||||
let authRefreshBusy = $state(false)
|
||||
|
||||
$effect(() => {
|
||||
import('@tauri-apps/api/app').then(m => m.getVersion()).then(v => appVersion = v).catch(() => {})
|
||||
refreshPerfMetrics()
|
||||
refreshAuthStatus()
|
||||
invoke<boolean>('windows_hello_available').then(v => helloAvailable = v).catch(() => helloAvailable = false)
|
||||
const timer = setInterval(() => refreshAuthStatus(), 1000)
|
||||
return () => clearInterval(timer)
|
||||
})
|
||||
|
||||
function refreshAuthStatus() { authStatus = getUiAuthDebugStatus() }
|
||||
|
||||
function fmtCountdown(ms: number | null): string {
|
||||
if (ms === null) return '—'
|
||||
if (ms <= 0) return 'expired'
|
||||
const total = Math.floor(ms / 1000)
|
||||
const month = 30 * 24 * 60 * 60
|
||||
const day = 24 * 60 * 60
|
||||
const hour = 60 * 60
|
||||
const minute = 60
|
||||
const months = Math.floor(total / month)
|
||||
const days = Math.floor((total % month) / day)
|
||||
const hours = Math.floor(total / 3600)
|
||||
const remainingHours = Math.floor((total % day) / hour)
|
||||
const mins = Math.floor((total % hour) / minute)
|
||||
const secs = total % 60
|
||||
if (months > 0) return days > 0 ? `${months}mo ${days}d` : `${months}mo`
|
||||
if (days > 0) return remainingHours > 0 ? `${days}d ${remainingHours}h` : `${days}d`
|
||||
if (hours > 0) return `${hours}h ${mins}m ${secs}s`
|
||||
if (mins > 0) return `${mins}m ${secs}s`
|
||||
return `${secs}s`
|
||||
}
|
||||
|
||||
function fmtTime(ts: number | null): string {
|
||||
if (ts === null) return '—'
|
||||
return new Date(ts).toLocaleString([], { dateStyle: 'medium', timeStyle: 'medium' })
|
||||
}
|
||||
|
||||
async function forceTokenRefresh() {
|
||||
authRefreshBusy = true
|
||||
try {
|
||||
const token = await refreshUiAccessToken(true)
|
||||
toast({
|
||||
kind: token ? 'success' : 'info',
|
||||
title: 'UI auth refresh',
|
||||
body: token ? 'Refresh succeeded' : 'No refreshed token available',
|
||||
})
|
||||
} catch (e: any) {
|
||||
toast({ kind: 'error', title: 'UI auth refresh', body: String(e?.message ?? e) })
|
||||
} finally {
|
||||
authRefreshBusy = false
|
||||
refreshAuthStatus()
|
||||
}
|
||||
}
|
||||
|
||||
function refreshPerfMetrics() {
|
||||
let entries = 0, oldest: number | null = null, newest: number | null = null
|
||||
const foundKeys: string[] = []
|
||||
const checkKey = (k: string) => {
|
||||
const age = cache.ageOf(k)
|
||||
if (age !== undefined) {
|
||||
entries++
|
||||
foundKeys.push(k)
|
||||
const ts = Date.now() - age
|
||||
if (oldest === null || ts < oldest) oldest = ts
|
||||
if (newest === null || ts > newest) newest = ts
|
||||
}
|
||||
}
|
||||
['library', 'sources', 'popular'].forEach(checkKey)
|
||||
['Action','Romance','Fantasy','Comedy','Drama','Horror','Sci-Fi','Adventure','Thriller',
|
||||
'Isekai','Supernatural','Historical','Psychological','Sports','Mystery','Mecha',
|
||||
'Slice of Life','School Life','Martial Arts','Magic','Military'].forEach(g => checkKey(`genre:${g}`))
|
||||
perfSnapshot = { cacheEntries: entries, cacheKeys: foundKeys, oldestEntryMs: oldest, newestEntryMs: newest }
|
||||
}
|
||||
|
||||
function fmtAge(ts: number | null) {
|
||||
if (ts === null) return '—'
|
||||
const secs = Math.floor((Date.now() - ts) / 1000)
|
||||
if (secs < 60) return `${secs}s ago`
|
||||
const mins = Math.floor(secs / 60)
|
||||
if (mins < 60) return `${mins}m ago`
|
||||
return `${Math.floor(mins / 60)}h ago`
|
||||
}
|
||||
|
||||
function triggerSplash() {
|
||||
splashTriggered = true
|
||||
setTimeout(() => splashTriggered = false, 200)
|
||||
;(window as any).__mokuShowSplash?.()
|
||||
}
|
||||
|
||||
async function testWindowsHello() {
|
||||
helloBusy = true
|
||||
try {
|
||||
await invoke('windows_hello_authenticate', { reason: 'Moku devtools test' })
|
||||
toast({ kind: 'success', title: 'Windows Hello', body: 'Verified successfully' })
|
||||
} catch (e: any) {
|
||||
toast({ kind: 'error', title: 'Windows Hello', body: String(e) })
|
||||
} finally { helloBusy = false }
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="s-panel">
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Toasts</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Fire test toast</span><span class="s-desc">Triggers each kind with realistic content</span></div>
|
||||
<div class="s-dev-pill-group">
|
||||
{#each (['success', 'error', 'info', 'download'] as const) as kind (kind)}
|
||||
{@const label = kind === 'success' ? 'S' : kind === 'error' ? 'E' : kind === 'info' ? 'I' : 'D'}
|
||||
{@const title = kind === 'success' ? 'Library updated' : kind === 'error' ? 'Could not reach server' : kind === 'info' ? 'Already up to date' : 'Download complete'}
|
||||
{@const body = kind === 'success' ? '3 new chapters across 2 series' : kind === 'error' ? 'Connection refused on port 4567' : kind === 'info' ? 'No new chapters found' : 'Berserk · Ch. 372 ready to read'}
|
||||
<button class="s-dev-pill {kind}" onclick={() => toast({ kind, title, body })}>{label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Previews</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Idle splash</span><span class="s-desc">Dismiss with any click or key</span></div>
|
||||
<button class="s-btn" class:s-btn-accent={splashTriggered} onclick={triggerSplash}>Show</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Biometrics</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Windows Hello</span>
|
||||
<span class="s-desc">Available: {helloAvailable === null ? '…' : helloAvailable ? 'yes' : 'no'}</span>
|
||||
</div>
|
||||
<button class="s-btn" disabled={!helloAvailable || helloBusy} onclick={testWindowsHello}>
|
||||
{helloBusy ? '…' : 'Test'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<button class="s-collapsible-trigger" onclick={() => expOpen = !expOpen} aria-expanded={expOpen}>
|
||||
<span class="s-label">Experimental</span>
|
||||
<svg class="s-collapsible-caret" class:open={expOpen} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||
</button>
|
||||
{#if expOpen}
|
||||
<div class="s-collapsible-body">
|
||||
<div class="s-row" style="flex-direction:column;align-items:flex-start;gap:var(--sp-3)">
|
||||
<span class="s-desc">3D tilt cards — hover to preview</span>
|
||||
<div style="display:flex;gap:var(--sp-3)">
|
||||
{#each [{ title: 'Berserk', sub: 'Ch. 372', hue: '265' }, { title: 'Vinland Saga', sub: 'Ch. 208', hue: '200' }, { title: 'Dungeon Meshi', sub: 'Ch. 97', hue: '140' }] as card (card.title)}
|
||||
<ThreeDCard>
|
||||
<div style="width:72px;height:100px;border-radius:var(--radius-md);background:hsl({card.hue},40%,18%);display:flex;flex-direction:column;align-items:center;justify-content:flex-end;padding:var(--sp-2)">
|
||||
<span style="font-size:var(--text-2xs);color:var(--text-secondary);text-align:center;line-height:1.2">{card.title}</span>
|
||||
<span style="font-size:10px;color:var(--text-faint)">{card.sub}</span>
|
||||
</div>
|
||||
</ThreeDCard>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Runtime</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-dev-grid">
|
||||
<span class="s-dev-key">Filter</span> <span class="s-dev-val">{appState.libraryFilter}</span>
|
||||
<span class="s-dev-key">Folders</span> <span class="s-dev-val">{appState.categories.filter(c => c.id !== 0).map(c => c.name).join(', ') || 'none'}</span>
|
||||
<span class="s-dev-key">History</span> <span class="s-dev-val">{appState.history.length} entries</span>
|
||||
<span class="s-dev-key">Cache</span> <span class="s-dev-val">{perfSnapshot?.cacheEntries ?? '—'} entries</span>
|
||||
<span class="s-dev-key">Toasts</span> <span class="s-dev-val">{appState.toasts.length} queued</span>
|
||||
<span class="s-dev-key">Version</span> <span class="s-dev-val">{appVersion} · {import.meta.env.MODE}</span>
|
||||
</div>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
{#if perfSnapshot && perfSnapshot.cacheEntries > 0}
|
||||
<span class="s-desc">{perfSnapshot.cacheKeys.join(', ')}</span>
|
||||
<span class="s-desc">Oldest: {fmtAge(perfSnapshot.oldestEntryMs)} · Newest: {fmtAge(perfSnapshot.newestEntryMs)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<button class="s-btn-icon" onclick={refreshPerfMetrics} title="Refresh cache stats">↺</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Auth (UI Login)</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-dev-grid">
|
||||
<span class="s-dev-key">Mode</span> <span class="s-dev-val">{authStatus?.mode ?? '—'}</span>
|
||||
<span class="s-dev-key">Session</span> <span class="s-dev-val">{authStatus?.hasSession ? 'present' : 'none'}</span>
|
||||
<span class="s-dev-key">Refresh token</span> <span class="s-dev-val">{authStatus?.hasRefreshToken ? 'present' : 'none'}</span>
|
||||
<span class="s-dev-key">Access expires in</span> <span class="s-dev-val">{fmtCountdown(authStatus?.accessExpiresInMs ?? null)}</span>
|
||||
<span class="s-dev-key">Refresh expires in</span> <span class="s-dev-val">{fmtCountdown(authStatus?.refreshExpiresInMs ?? null)}</span>
|
||||
<span class="s-dev-key">Refresh window</span> <span class="s-dev-val">{authStatus?.shouldRefreshSoon ? 'open' : 'not yet'}</span>
|
||||
<span class="s-dev-key">Refresh in-flight</span> <span class="s-dev-val">{authStatus?.refreshInFlight ? 'yes' : 'no'}</span>
|
||||
</div>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-desc">Access expiry at: {fmtTime(authStatus?.accessExpiresAt ?? null)}</span>
|
||||
<span class="s-desc">Refresh expiry at: {fmtTime(authStatus?.refreshExpiresAt ?? null)}</span>
|
||||
<span class="s-desc">Skew window: {Math.round((authStatus?.skewMs ?? 0) / 1000)}s before expiry</span>
|
||||
</div>
|
||||
<div class="s-btn-row">
|
||||
<button class="s-btn" onclick={refreshAuthStatus}>Refresh</button>
|
||||
<button class="s-btn s-btn-accent" onclick={forceTokenRefresh}
|
||||
disabled={authRefreshBusy || authStatus?.mode !== 'UI_LOGIN' || !authStatus?.hasRefreshToken}>
|
||||
{authRefreshBusy ? 'Refreshing…' : 'Force refresh'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,368 @@
|
||||
<script lang="ts">
|
||||
import { FolderSimple, Plus, Trash, Star, Eye, EyeSlash, ArrowsClockwise, ArrowsCounterClockwise, DownloadSimple, DotsSixVertical, BookmarkSimple, Lock, CheckSquare } from 'phosphor-svelte'
|
||||
import { getAdapter } from '$lib/request-manager'
|
||||
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
|
||||
import type { Category } from '$lib/types'
|
||||
|
||||
let categories = $state<Category[]>([])
|
||||
let catsLoading = $state(false)
|
||||
let catsError = $state<string | null>(null)
|
||||
let newFolderName = $state('')
|
||||
let editingId = $state<number | null>(null)
|
||||
let editingName = $state('')
|
||||
|
||||
let dragStrId = $state<string | null>(null)
|
||||
let dragOverStrId = $state<string | null>(null)
|
||||
let dropPosition = $state<'above' | 'below' | null>(null)
|
||||
|
||||
const completedCat = $derived(categories.find(c => c.name === 'Completed' && c.id !== 0) ?? null)
|
||||
const completedId = $derived(completedCat ? String(completedCat.id) : null)
|
||||
const sortedCatIds = $derived(categories.filter(c => c.id !== 0).map(c => String(c.id)))
|
||||
|
||||
const orderedAllIds = $derived.by(() => {
|
||||
const order = settingsState.settings.libraryPinnedTabOrder ?? []
|
||||
const allIds = ['library', 'downloaded', ...sortedCatIds]
|
||||
const known = new Set(allIds)
|
||||
return [...new Set([...order.filter(id => known.has(id)), ...allIds])]
|
||||
})
|
||||
|
||||
function isHidden(id: string) {
|
||||
return (settingsState.settings.hiddenLibraryTabs ?? []).includes(id)
|
||||
}
|
||||
|
||||
function toggleHidden(id: string) {
|
||||
const current = settingsState.settings.hiddenLibraryTabs ?? []
|
||||
updateSettings({ hiddenLibraryTabs: current.includes(id) ? current.filter(x => x !== id) : [...current, id] })
|
||||
}
|
||||
|
||||
async function loadCategories() {
|
||||
catsLoading = true; catsError = null
|
||||
try {
|
||||
const fresh = await getAdapter().getCategories()
|
||||
const zeroCat = categories.filter(c => c.id === 0)
|
||||
const merged = fresh.filter((c: Category) => c.id !== 0).map((f: Category) => {
|
||||
const existing = categories.find(c => c.id === f.id)
|
||||
return existing ? { ...existing, ...f } : f
|
||||
})
|
||||
categories = [...zeroCat, ...merged]
|
||||
} catch (e: any) {
|
||||
catsError = e?.message ?? 'Failed to load folders'
|
||||
} finally { catsLoading = false }
|
||||
}
|
||||
|
||||
async function createFolder() {
|
||||
const name = newFolderName.trim()
|
||||
if (!name) return
|
||||
try {
|
||||
const cat = await getAdapter().createCategory({ name })
|
||||
categories = [...categories, cat]
|
||||
newFolderName = ''
|
||||
} catch (e: any) { catsError = e?.message ?? 'Failed to create folder' }
|
||||
}
|
||||
|
||||
function startEdit(id: number, name: string) { editingId = id; editingName = name }
|
||||
|
||||
async function commitEdit() {
|
||||
if (editingId !== null && editingName.trim()) {
|
||||
try {
|
||||
await getAdapter().updateCategory({ id: editingId, name: editingName.trim() })
|
||||
categories = categories.map(c => c.id === editingId ? { ...c, name: editingName.trim() } : c)
|
||||
} catch (e: any) { catsError = e?.message ?? 'Failed to rename' }
|
||||
}
|
||||
editingId = null; editingName = ''
|
||||
}
|
||||
|
||||
async function deleteFolder(id: number) {
|
||||
try {
|
||||
await getAdapter().deleteCategory({ id })
|
||||
categories = categories.filter(c => c.id !== id)
|
||||
} catch (e: any) { catsError = e?.message ?? 'Failed to delete folder' }
|
||||
}
|
||||
|
||||
async function toggleCategoryFlag(id: number, flag: 'includeInUpdate' | 'includeInDownload') {
|
||||
const cat = categories.find(c => c.id === id)
|
||||
if (!cat) return
|
||||
const next = !cat[flag]
|
||||
categories = categories.map(c => c.id === id ? { ...c, [flag]: next } : c)
|
||||
try {
|
||||
await getAdapter().updateCategories({ ids: [id], patch: { [flag]: next ? 'INCLUDE' : 'EXCLUDE' } })
|
||||
} catch (e: any) {
|
||||
categories = categories.map(c => c.id === id ? { ...c, [flag]: !next } : c)
|
||||
catsError = e?.message ?? 'Failed to update folder'
|
||||
}
|
||||
}
|
||||
|
||||
function applyReorder(fromStrId: string, toStrId: string) {
|
||||
const catIds = categories.filter(c => c.id !== 0).map(c => String(c.id))
|
||||
const allIds = ['library', 'downloaded', ...catIds]
|
||||
const current = settingsState.settings.libraryPinnedTabOrder ?? []
|
||||
const base = [...new Set([...current.filter(id => allIds.includes(id)), ...allIds])]
|
||||
const fromIdx = base.indexOf(fromStrId)
|
||||
const toIdx = base.indexOf(toStrId)
|
||||
if (fromIdx < 0 || toIdx < 0 || fromIdx === toIdx) return
|
||||
base.splice(fromIdx, 1)
|
||||
base.splice(toIdx, 0, fromStrId)
|
||||
updateSettings({ libraryPinnedTabOrder: base })
|
||||
|
||||
const fromNumId = Number(fromStrId)
|
||||
if (!isNaN(fromNumId) && fromStrId !== 'library' && fromStrId !== 'downloaded') {
|
||||
const zeroCat = categories.filter(c => c.id === 0)
|
||||
const sortable = categories.filter(c => c.id !== 0).sort((a, b) => a.order - b.order)
|
||||
const sFromIdx = sortable.findIndex(c => c.id === fromNumId)
|
||||
const sToIdx = sortable.findIndex(c => String(c.id) === toStrId)
|
||||
if (sFromIdx >= 0 && sToIdx >= 0 && sFromIdx !== sToIdx) {
|
||||
const reordered = [...sortable]
|
||||
const [moved] = reordered.splice(sFromIdx, 1)
|
||||
reordered.splice(sToIdx, 0, moved)
|
||||
categories = [...zeroCat, ...reordered.map((c, i) => ({ ...c, order: i + 1 }))]
|
||||
getAdapter().updateCategoryOrder({ id: fromNumId, position: sToIdx + 1 })
|
||||
.then((updated: Category[]) => {
|
||||
categories = [
|
||||
...zeroCat,
|
||||
...updated.sort((a: Category, b: Category) => a.order - b.order).map((fresh: Category) => {
|
||||
const existing = categories.find(c => c.id === fresh.id)
|
||||
return existing ? { ...existing, ...fresh } : fresh
|
||||
}),
|
||||
]
|
||||
})
|
||||
.catch(async (e: any) => {
|
||||
catsError = e?.message ?? 'Failed to reorder'
|
||||
await loadCategories()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onDragStart(e: DragEvent, id: string) {
|
||||
dragStrId = id
|
||||
if (e.dataTransfer) { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', id) }
|
||||
}
|
||||
|
||||
function onDragOver(e: DragEvent, id: string) {
|
||||
e.preventDefault()
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move'
|
||||
if (dragStrId === id) return
|
||||
dragOverStrId = id
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
dropPosition = e.clientY < rect.top + rect.height / 2 ? 'above' : 'below'
|
||||
}
|
||||
|
||||
function onDrop(e: DragEvent, id: string) {
|
||||
e.preventDefault()
|
||||
if (dragStrId !== null && dragStrId !== id) applyReorder(dragStrId, id)
|
||||
dragStrId = null; dragOverStrId = null; dropPosition = null
|
||||
}
|
||||
|
||||
function onDragEnd() { dragStrId = null; dragOverStrId = null; dropPosition = null }
|
||||
|
||||
function focusInput(node: HTMLElement) { node.focus() }
|
||||
|
||||
$effect(() => {
|
||||
if (!categories.length && !catsLoading) loadCategories()
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="s-panel">
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Manage Folders</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<span class="s-desc">Folders are stored as Suwayomi categories. Changes sync across all clients.</span>
|
||||
</div>
|
||||
|
||||
{#if catsError}
|
||||
<div class="s-banner s-banner-error">{catsError}</div>
|
||||
{/if}
|
||||
|
||||
{#if catsLoading}
|
||||
<p class="s-empty">Loading folders…</p>
|
||||
{:else}
|
||||
<div class="s-folder-list" class:is-dragging={dragStrId !== null}>
|
||||
{#each orderedAllIds as id}
|
||||
{@const isBuiltin = id === 'library' || id === 'downloaded'}
|
||||
{@const isCompleted = id === completedId}
|
||||
{@const cat = isBuiltin ? null : (categories.find(c => String(c.id) === id) ?? null)}
|
||||
{@const hidden = isHidden(id)}
|
||||
|
||||
{#if isBuiltin || cat}
|
||||
<div
|
||||
class="s-folder-row"
|
||||
class:dragging={dragStrId === id}
|
||||
class:drop-above={dragOverStrId === id && dragStrId !== id && dropPosition === 'above'}
|
||||
class:drop-below={dragOverStrId === id && dragStrId !== id && dropPosition === 'below'}
|
||||
draggable="true"
|
||||
ondragstart={(e) => onDragStart(e, id)}
|
||||
ondragover={(e) => onDragOver(e, id)}
|
||||
ondragleave={() => { if (dragOverStrId === id) { dragOverStrId = null; dropPosition = null } }}
|
||||
ondrop={(e) => onDrop(e, id)}
|
||||
ondragend={onDragEnd}
|
||||
>
|
||||
{#if isCompleted}
|
||||
<span class="s-folder-icon">
|
||||
<CheckSquare size={14} weight="light" />
|
||||
<DotsSixVertical size={14} weight="bold" />
|
||||
</span>
|
||||
<span class="s-folder-name">{cat?.name ?? 'Completed'}</span>
|
||||
<span class="s-folder-count">{cat?.mangas?.nodes.length ?? 0} manga</span>
|
||||
<span class="s-folder-badge">built-in</span>
|
||||
<div class="s-folder-actions">
|
||||
<button class="s-btn-icon" class:muted={hidden} onclick={() => toggleHidden(id)} title={hidden ? 'Show tab in library' : 'Hide tab from library'}>
|
||||
{#if hidden}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
|
||||
</button>
|
||||
<button class="s-btn-icon s-btn-icon-lock" disabled title="Built-in tab — cannot be deleted"><Lock size={12} weight="light" /></button>
|
||||
</div>
|
||||
|
||||
{:else if isBuiltin}
|
||||
<span class="s-folder-icon">
|
||||
{#if id === 'library'}<BookmarkSimple size={14} weight="light" />{:else}<DownloadSimple size={14} weight="light" />{/if}
|
||||
<DotsSixVertical size={14} weight="bold" />
|
||||
</span>
|
||||
<span class="s-folder-name">{id === 'library' ? 'Saved' : 'Downloaded'}</span>
|
||||
<span class="s-folder-badge">built-in</span>
|
||||
<div class="s-folder-actions">
|
||||
<button class="s-btn-icon" class:muted={hidden} onclick={() => toggleHidden(id)} title={hidden ? 'Show tab in library' : 'Hide tab from library'}>
|
||||
{#if hidden}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
|
||||
</button>
|
||||
<button class="s-btn-icon s-btn-icon-lock" disabled title="Built-in tab — cannot be deleted"><Lock size={12} weight="light" /></button>
|
||||
</div>
|
||||
|
||||
{:else if cat}
|
||||
{#if editingId === cat.id}
|
||||
<input class="s-input full" bind:value={editingName}
|
||||
onkeydown={(e) => { if (e.key === 'Enter') commitEdit(); if (e.key === 'Escape') { editingId = null } }}
|
||||
onblur={commitEdit} use:focusInput />
|
||||
<button class="s-btn-icon" onclick={commitEdit} title="Save">✓</button>
|
||||
{:else}
|
||||
<div class="s-folder-identity" draggable="true"
|
||||
ondragstart={(e) => onDragStart(e, id)}
|
||||
ondragend={onDragEnd}>
|
||||
<span class="s-folder-icon">
|
||||
<FolderSimple size={14} weight="light" />
|
||||
<DotsSixVertical size={14} weight="bold" />
|
||||
</span>
|
||||
<span class="s-folder-name" onclick={(e) => { e.stopPropagation(); startEdit(cat.id, cat.name) }} title="Click to rename">{cat.name}</span>
|
||||
</div>
|
||||
<span class="s-folder-count">{cat.mangas?.nodes.length ?? 0} manga</span>
|
||||
<div class="s-folder-actions">
|
||||
<button class="s-btn-icon"
|
||||
class:active={(settingsState.settings.defaultLibraryCategoryId ?? null) === cat.id}
|
||||
onclick={() => updateSettings({ defaultLibraryCategoryId: (settingsState.settings.defaultLibraryCategoryId ?? null) === cat.id ? null : cat.id })}
|
||||
title={(settingsState.settings.defaultLibraryCategoryId ?? null) === cat.id ? 'Remove as default folder' : 'Set as default folder'}>
|
||||
<Star size={13} weight={(settingsState.settings.defaultLibraryCategoryId ?? null) === cat.id ? 'fill' : 'light'} />
|
||||
</button>
|
||||
<button class="s-btn-icon" class:muted={hidden} onclick={() => toggleHidden(id)} title={hidden ? 'Show in library' : 'Hide from library'}>
|
||||
{#if hidden}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
|
||||
</button>
|
||||
<button class="s-btn-icon"
|
||||
class:active={cat.includeInUpdate !== false}
|
||||
class:inactive={cat.includeInUpdate === false}
|
||||
onclick={() => toggleCategoryFlag(cat.id, 'includeInUpdate')}
|
||||
title={cat.includeInUpdate !== false ? 'Included in updates — click to exclude' : 'Excluded from updates — click to include'}>
|
||||
{#if cat.includeInUpdate !== false}<ArrowsClockwise size={13} weight="bold" />{:else}<ArrowsCounterClockwise size={13} weight="light" />{/if}
|
||||
</button>
|
||||
<button class="s-btn-icon"
|
||||
class:active={cat.includeInDownload !== false}
|
||||
class:inactive={cat.includeInDownload === false}
|
||||
onclick={() => toggleCategoryFlag(cat.id, 'includeInDownload')}
|
||||
title={cat.includeInDownload !== false ? 'Included in auto-downloads — click to exclude' : 'Excluded from auto-downloads — click to include'}>
|
||||
<DownloadSimple size={13} weight={cat.includeInDownload !== false ? 'bold' : 'light'} />
|
||||
</button>
|
||||
<button class="s-btn-icon danger" onclick={() => deleteFolder(cat.id)} title="Delete folder">
|
||||
<Trash size={12} weight="light" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if categories.filter(c => c.id !== 0 && c.name !== 'Completed').length === 0}
|
||||
<p class="s-empty">No custom folders yet. Create one below.</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="s-folder-create">
|
||||
<input class="s-input full" placeholder="New folder name…" bind:value={newFolderName}
|
||||
onkeydown={(e) => e.key === 'Enter' && createFolder()} />
|
||||
<button class="s-btn s-btn-accent" onclick={createFolder} disabled={!newFolderName.trim()}>
|
||||
<Plus size={13} weight="bold" /> Create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.s-folder-list { display: contents; }
|
||||
|
||||
.s-folder-list.is-dragging,
|
||||
.s-folder-list.is-dragging * { user-select: none; -webkit-user-select: none; }
|
||||
|
||||
.s-folder-row { transition: opacity 0.15s, background 0.1s; position: relative; }
|
||||
.s-folder-row.dragging { opacity: 0.35; }
|
||||
|
||||
.s-folder-row.drop-above::before,
|
||||
.s-folder-row.drop-below::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 8px; right: 8px;
|
||||
height: 2px;
|
||||
background: var(--color-success, #4ade80);
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
}
|
||||
.s-folder-row.drop-above::before { top: -1px; }
|
||||
.s-folder-row.drop-below::after { bottom: -1px; }
|
||||
|
||||
.s-folder-identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text-primary);
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.s-folder-icon {
|
||||
display: grid;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.s-folder-icon > :global(*) { grid-area: 1 / 1; transition: opacity 0.12s; }
|
||||
.s-folder-icon > :global(*:last-child) { opacity: 0; }
|
||||
.s-folder-row:hover .s-folder-icon > :global(*:first-child) { opacity: 0; }
|
||||
.s-folder-row:hover .s-folder-icon > :global(*:last-child) { opacity: 1; }
|
||||
|
||||
.s-folder-name {
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.s-folder-name:hover { text-decoration: underline; text-underline-offset: 3px; }
|
||||
|
||||
.s-folder-actions { display: flex; align-items: center; gap: 2px; margin-left: auto; flex-shrink: 0; }
|
||||
|
||||
.s-folder-badge {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-faint);
|
||||
background: var(--bg-subtle);
|
||||
border: 1px solid var(--border-dim);
|
||||
border-radius: 3px;
|
||||
padding: 1px 5px;
|
||||
flex-shrink: 0;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.s-btn-icon.active { color: var(--accent, #6c8ef5); }
|
||||
.s-btn-icon.inactive { color: var(--color-error, #f87171); opacity: 0.75; }
|
||||
.s-btn-icon.inactive:hover { opacity: 1; }
|
||||
.s-btn-icon.muted { color: var(--text-faint); opacity: 0.5; }
|
||||
.s-btn-icon-lock { opacity: 0.25; cursor: not-allowed; }
|
||||
.s-btn-icon-lock:hover { opacity: 0.25; color: inherit; }
|
||||
</style>
|
||||
@@ -0,0 +1,204 @@
|
||||
<script lang="ts">
|
||||
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
|
||||
import { platformService } from '$lib/platform-service'
|
||||
|
||||
interface Props {
|
||||
selectOpen: string | null
|
||||
closingSelect: string | null
|
||||
toggleSelect: (id: string) => void
|
||||
anims: boolean
|
||||
}
|
||||
let { selectOpen, closingSelect, toggleSelect, anims }: Props = $props()
|
||||
|
||||
let triggerIdleTimeout = $state<HTMLButtonElement>(null!)
|
||||
let serverAdvancedOpen = $state(false)
|
||||
|
||||
async function pickServerBinary() {
|
||||
const path = await platformService.pickFolder()
|
||||
if (path) updateSettings({ serverBinary: path })
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="s-panel">
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Interface Scale</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-slider-row">
|
||||
<input type="range" min={50} max={200} step={5}
|
||||
value={Math.round((settingsState.settings.uiZoom ?? 1.0) * 100)}
|
||||
oninput={(e) => updateSettings({ uiZoom: Number(e.currentTarget.value) / 100 })}
|
||||
class="s-slider" />
|
||||
<input type="number" min={50} max={200} step={1} class="s-slider-val"
|
||||
value={Math.round((settingsState.settings.uiZoom ?? 1.0) * 100)}
|
||||
oninput={(e) => { const n = parseInt(e.currentTarget.value, 10); if (!isNaN(n) && n >= 50 && n <= 200) updateSettings({ uiZoom: n / 100 }) }}
|
||||
onblur={(e) => { const n = parseInt(e.currentTarget.value, 10); if (isNaN(n) || n < 50) { updateSettings({ uiZoom: 0.5 }); e.currentTarget.value = '50' } else if (n > 200) { updateSettings({ uiZoom: 2.0 }); e.currentTarget.value = '200' } }}
|
||||
/>
|
||||
<span class="s-slider-unit">%</span>
|
||||
<button class="s-btn-icon" onclick={() => updateSettings({ uiZoom: 1.0 })} disabled={(settingsState.settings.uiZoom ?? 1.0) === 1.0} title="Reset to 100%">↺</button>
|
||||
</div>
|
||||
<div class="s-presets">
|
||||
{#each [50,60,70,80,90,100,110,125,150,175,200] as v}
|
||||
<button class="s-preset" class:active={Math.round((settingsState.settings.uiZoom ?? 1.0) * 100) === v} onclick={() => updateSettings({ uiZoom: v / 100 })}>{v}%</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Server</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Server URL</span>
|
||||
<span class="s-desc">Base URL of your Suwayomi instance</span>
|
||||
</div>
|
||||
<div class="srv-url-group">
|
||||
<input class="s-input" value={settingsState.settings.serverUrl ?? 'http://localhost:4567'}
|
||||
oninput={(e) => updateSettings({ serverUrl: e.currentTarget.value })}
|
||||
placeholder="http://localhost:4567" spellcheck="false" />
|
||||
<button class="srv-adv-btn" class:open={serverAdvancedOpen}
|
||||
onclick={() => serverAdvancedOpen = !serverAdvancedOpen}
|
||||
title="Server launch options" aria-expanded={serverAdvancedOpen}>
|
||||
<svg width="10" height="6" viewBox="0 0 10 6" fill="none">
|
||||
<path d="M1 1l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Auto-start server</span><span class="s-desc">Launch tachidesk-server when Moku opens</span></div>
|
||||
<button role="switch" aria-checked={settingsState.settings.autoStartServer} aria-label="Auto-start server"
|
||||
class="s-toggle" class:on={settingsState.settings.autoStartServer}
|
||||
onclick={() => updateSettings({ autoStartServer: !settingsState.settings.autoStartServer })}>
|
||||
<span class="s-toggle-thumb"></span>
|
||||
</button>
|
||||
</label>
|
||||
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Suwayomi Web UI</span><span class="s-desc">Enable the built-in Suwayomi web interface alongside Moku</span></div>
|
||||
<button role="switch" aria-checked={settingsState.settings.suwayomiWebUI ?? false} aria-label="Suwayomi Web UI"
|
||||
class="s-toggle" class:on={settingsState.settings.suwayomiWebUI ?? false}
|
||||
onclick={() => updateSettings({ suwayomiWebUI: !(settingsState.settings.suwayomiWebUI ?? false) })}>
|
||||
<span class="s-toggle-thumb"></span>
|
||||
</button>
|
||||
</label>
|
||||
|
||||
{#if serverAdvancedOpen}
|
||||
<div class="srv-adv-panel">
|
||||
<div class="srv-adv-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Server binary</span>
|
||||
<span class="s-desc">Path to server executable — leave blank to use bundled</span>
|
||||
</div>
|
||||
<div class="srv-file-group">
|
||||
<input class="s-input srv-path-input" value={settingsState.settings.serverBinary ?? ''}
|
||||
oninput={(e) => updateSettings({ serverBinary: e.currentTarget.value })}
|
||||
placeholder="auto-detect" spellcheck="false" />
|
||||
<button class="srv-file-btn" onclick={pickServerBinary} title="Browse">
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M1.5 4.5h11v7a1 1 0 01-1 1h-9a1 1 0 01-1-1v-7z" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/>
|
||||
<path d="M1.5 4.5l1.8-2.5h3.4l1.3 2.5" stroke="currentColor" stroke-width="1.3" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Window</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Close button behavior</span><span class="s-desc">What happens when you click the X button</span></div>
|
||||
<div class="s-seg">
|
||||
{#each [['ask','Ask'],['tray','Tray'],['quit','Quit']] as [v, l]}
|
||||
<button class="s-seg-btn" class:active={(settingsState.settings.closeAction ?? 'ask') === v} onclick={() => updateSettings({ closeAction: v as 'ask' | 'tray' | 'quit' })}>{l}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Inactivity</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Idle screen timeout</span><span class="s-desc">Show the Moku idle splash after this much inactivity</span></div>
|
||||
<div class="s-select">
|
||||
<button bind:this={triggerIdleTimeout} class="s-select-btn" onclick={() => toggleSelect('idle-timeout')}>
|
||||
<span>{{ '0':'Never','1':'1 minute','2':'2 minutes','5':'5 minutes','10':'10 minutes','15':'15 minutes','30':'30 minutes' }[String(settingsState.settings.idleTimeoutMin ?? 5)] ?? `${settingsState.settings.idleTimeoutMin} min`}</span>
|
||||
<svg class="s-select-caret" class:open={selectOpen === 'idle-timeout'} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||
</button>
|
||||
{#if selectOpen === 'idle-timeout' || closingSelect === 'idle-timeout'}
|
||||
<div class="s-select-menu" class:anims class:closing={closingSelect === 'idle-timeout'}>
|
||||
{#each [['0','Never'],['1','1 minute'],['2','2 minutes'],['5','5 minutes'],['10','10 minutes'],['15','15 minutes'],['30','30 minutes']] as [v, l]}
|
||||
<button class="s-select-option" class:active={String(settingsState.settings.idleTimeoutMin ?? 5) === v} onclick={() => { updateSettings({ idleTimeoutMin: Number(v) }); toggleSelect('idle-timeout') }}>{l}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Integrations</p>
|
||||
<div class="s-section-body">
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Discord Rich Presence</span><span class="s-desc">Show what you're reading in your Discord status</span></div>
|
||||
<button role="switch" aria-checked={settingsState.settings.discordRpc} aria-label="Discord Rich Presence" class="s-toggle" class:on={settingsState.settings.discordRpc} onclick={() => updateSettings({ discordRpc: !settingsState.settings.discordRpc })}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Animations</p>
|
||||
<div class="s-section-body">
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">QOL Animations</span><span class="s-desc">Hover lifts, active-tab transitions, and icon micro-animations</span></div>
|
||||
<button role="switch" aria-checked={settingsState.settings.qolAnimations ?? true} aria-label="QOL Animations" class="s-toggle" class:on={settingsState.settings.qolAnimations ?? true} onclick={() => updateSettings({ qolAnimations: !(settingsState.settings.qolAnimations ?? true) })}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Language</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Preferred source language</span>
|
||||
<span class="s-desc">Used to pre-select languages in Search and deduplicate sources</span>
|
||||
</div>
|
||||
<input class="s-input" style="width:72px;text-align:center;text-transform:uppercase"
|
||||
value={settingsState.settings.preferredExtensionLang ?? ''}
|
||||
oninput={(e) => updateSettings({ preferredExtensionLang: e.currentTarget.value.trim().toLowerCase() })}
|
||||
placeholder="en" spellcheck="false" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.s-seg { display: flex; border: 1px solid var(--border-strong); border-radius: var(--radius-md); overflow: hidden; }
|
||||
.s-seg-btn { flex: 1; padding: var(--sp-1) var(--sp-3); font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-faint); background: transparent; cursor: pointer; transition: background var(--t-base), color var(--t-base); border: none; }
|
||||
.s-seg-btn:not(:last-child) { border-right: 1px solid var(--border-strong); }
|
||||
.s-seg-btn.active { background: var(--accent-muted); color: var(--accent-fg); }
|
||||
.s-seg-btn:not(.active):hover { background: var(--bg-raised); color: var(--text-secondary); }
|
||||
.srv-url-group { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
|
||||
.srv-adv-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; flex-shrink: 0; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-surface); color: var(--text-faint); cursor: pointer; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); }
|
||||
.srv-adv-btn:hover { background: var(--bg-overlay); color: var(--text-muted); border-color: var(--border-strong); }
|
||||
.srv-adv-btn.open { background: var(--bg-overlay); color: var(--text-secondary); border-color: var(--border-strong); }
|
||||
.srv-adv-btn svg { transition: transform var(--t-base); }
|
||||
.srv-adv-btn.open svg { transform: rotate(180deg); }
|
||||
.srv-adv-panel { border-top: 1px solid var(--border-dim); background: var(--bg-base); }
|
||||
.srv-adv-row { display: flex; align-items: center; justify-content: space-between; padding: 10px var(--sp-4); gap: var(--sp-4); }
|
||||
.srv-file-group { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
|
||||
.srv-path-input { width: 160px; }
|
||||
.srv-file-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; flex-shrink: 0; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-surface); color: var(--text-faint); cursor: pointer; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); }
|
||||
.srv-file-btn:hover { background: var(--bg-overlay); color: var(--text-muted); border-color: var(--border-strong); }
|
||||
</style>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script lang="ts">
|
||||
export { eventToKeybind, matchesKeybind } from './keybindEngine'
|
||||
export { DEFAULT_KEYBINDS, KEYBIND_LABELS } from './defaultBinds'
|
||||
export type { Keybinds } from './defaultBinds'
|
||||
|
||||
let listeningKey: keyof Keybinds | null = $state(null)
|
||||
|
||||
function startListen(key: keyof Keybinds) {
|
||||
listeningKey = listeningKey === key ? null : key
|
||||
}
|
||||
|
||||
function onKeyCapture(e: KeyboardEvent) {
|
||||
if (!listeningKey) return
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
const bind = eventToKeybind(e)
|
||||
if (!bind) return
|
||||
updateSettings({ keybinds: { ...settingsState.settings.keybinds, [listeningKey]: bind } })
|
||||
listeningKey = null
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (listeningKey) {
|
||||
window.addEventListener('keydown', onKeyCapture, true)
|
||||
return () => window.removeEventListener('keydown', onKeyCapture, true)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="s-panel">
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">
|
||||
Keyboard Shortcuts
|
||||
<button class="s-btn" onclick={resetKeybinds}>Reset all</button>
|
||||
</p>
|
||||
<p class="s-kb-hint">Click a binding to rebind, then press the new key combination.</p>
|
||||
<div class="s-section-body">
|
||||
{#each Object.keys(KEYBIND_LABELS) as key}
|
||||
{@const k = key as keyof Keybinds}
|
||||
{@const isListening = listeningKey === k}
|
||||
{@const isDefault = settingsState.settings.keybinds[k] === DEFAULT_KEYBINDS[k]}
|
||||
<div class="s-kb-row">
|
||||
<span class="s-kb-label">{KEYBIND_LABELS[k]}</span>
|
||||
<div class="s-kb-right">
|
||||
<button class="s-kb-bind" class:listening={isListening} onclick={() => startListen(k)}>
|
||||
{isListening ? 'Press key…' : settingsState.settings.keybinds[k]}
|
||||
</button>
|
||||
<button class="s-btn-icon"
|
||||
onclick={() => updateSettings({ keybinds: { ...settingsState.settings.keybinds, [k]: DEFAULT_KEYBINDS[k] } })}
|
||||
disabled={isDefault} title="Reset">↺</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
|
||||
import { homeState } from '$lib/state/home.svelte'
|
||||
import type { Settings } from '$lib/types/settings'
|
||||
|
||||
interface Props {
|
||||
selectOpen: string | null
|
||||
closingSelect?: string | null
|
||||
toggleSelect: (id: string) => void
|
||||
anims: boolean
|
||||
}
|
||||
let { selectOpen, toggleSelect, anims }: Props = $props()
|
||||
|
||||
let triggerSortDir = $state<HTMLButtonElement>(null!)
|
||||
|
||||
function clearHistory() {
|
||||
homeState.history = []
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="s-panel">
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Display</p>
|
||||
<div class="s-section-body">
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Always show card stats</span><span class="s-desc">Show unread and download counts without needing to hover</span></div>
|
||||
<button role="switch" aria-checked={settingsState.settings.libraryStatsAlways ?? false} aria-label="Always show card stats" class="s-toggle" class:on={settingsState.settings.libraryStatsAlways ?? false} onclick={() => updateSettings({ libraryStatsAlways: !(settingsState.settings.libraryStatsAlways ?? false) })}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Crop cover images</span><span class="s-desc">Fills the card with the cover art instead of letterboxing</span></div>
|
||||
<button role="switch" aria-checked={settingsState.settings.libraryCropCovers} aria-label="Crop cover images" class="s-toggle" class:on={settingsState.settings.libraryCropCovers} onclick={() => updateSettings({ libraryCropCovers: !settingsState.settings.libraryCropCovers })}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Show all in Saved tab</span><span class="s-desc">Include manga that are in folders — lets you see your whole library in one place</span></div>
|
||||
<button role="switch" aria-checked={settingsState.settings.libraryShowAllInSaved ?? true} aria-label="Show all manga in Saved tab" class="s-toggle" class:on={settingsState.settings.libraryShowAllInSaved ?? true} onclick={() => updateSettings({ libraryShowAllInSaved: !(settingsState.settings.libraryShowAllInSaved ?? true) })}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
{#if settingsState.settings.libraryShowAllInSaved ?? true}
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Hide completed in Saved tab</span><span class="s-desc">Keep manga in the Completed folder out of the Saved view</span></div>
|
||||
<button role="switch" aria-checked={settingsState.settings.libraryHideCompletedInSaved ?? false} aria-label="Hide completed manga in Saved tab" class="s-toggle" class:on={settingsState.settings.libraryHideCompletedInSaved ?? false} onclick={() => updateSettings({ libraryHideCompletedInSaved: !(settingsState.settings.libraryHideCompletedInSaved ?? false) })}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Chapters</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Default sort direction</span><span class="s-desc">Initial chapter list order when opening a manga</span></div>
|
||||
<div class="s-select">
|
||||
<button bind:this={triggerSortDir} class="s-select-btn" onclick={() => toggleSelect('sort-dir')}>
|
||||
<span>{{ 'desc':'Newest first','asc':'Oldest first' }[settingsState.settings.chapterSortDir]}</span>
|
||||
<svg class="s-select-caret" class:open={selectOpen === 'sort-dir'} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||
</button>
|
||||
{#if selectOpen === 'sort-dir'}
|
||||
<div class="s-select-menu" class:anims>
|
||||
{#each [['desc','Newest first'],['asc','Oldest first']] as [v, l]}
|
||||
<button class="s-select-option" class:active={settingsState.settings.chapterSortDir === v} onclick={() => { updateSettings({ chapterSortDir: v as Settings['chapterSortDir'] }); toggleSelect('sort-dir') }}>{l}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Series</p>
|
||||
<div class="s-section-body">
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Auto-link on open</span><span class="s-desc">When opening a manga, automatically link it to similarly-titled entries</span></div>
|
||||
<button role="switch" aria-checked={settingsState.settings.autoLinkOnOpen ?? false} aria-label="Auto-link on open" class="s-toggle" class:on={settingsState.settings.autoLinkOnOpen ?? false} onclick={() => updateSettings({ autoLinkOnOpen: !(settingsState.settings.autoLinkOnOpen ?? false) })}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Disable auto-complete</span><span class="s-desc">Don't move manga to the Completed folder when all chapters are read</span></div>
|
||||
<button role="switch" aria-checked={settingsState.settings.disableAutoComplete} aria-label="Disable auto-complete" class="s-toggle" class:on={settingsState.settings.disableAutoComplete} onclick={() => updateSettings({ disableAutoComplete: !settingsState.settings.disableAutoComplete })}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Reading history</span><span class="s-desc">{homeState.history.length} entries</span></div>
|
||||
<button class="s-btn s-btn-danger" onclick={clearHistory} disabled={homeState.history.length === 0}>Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,161 @@
|
||||
<script lang="ts">
|
||||
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
|
||||
import { cache } from '$lib/core/cache/queryCache'
|
||||
|
||||
interface PerfSnapshot {
|
||||
cacheEntries: number
|
||||
cacheKeys: string[]
|
||||
oldestEntryMs: number | null
|
||||
newestEntryMs: number | null
|
||||
}
|
||||
|
||||
let perfSnapshot = $state<PerfSnapshot | null>(null)
|
||||
let clearing = $state(false)
|
||||
let cleared = $state(false)
|
||||
|
||||
function refreshPerfMetrics() {
|
||||
let entries = 0, oldest: number | null = null, newest: number | null = null
|
||||
const foundKeys: string[] = []
|
||||
const checkKey = (k: string) => {
|
||||
const age = cache.ageOf(k)
|
||||
if (age !== undefined) {
|
||||
entries++
|
||||
foundKeys.push(k)
|
||||
const ts = Date.now() - age
|
||||
if (oldest === null || ts < oldest) oldest = ts
|
||||
if (newest === null || ts > newest) newest = ts
|
||||
}
|
||||
}
|
||||
['library', 'sources', 'popular'].forEach(checkKey)
|
||||
['Action','Romance','Fantasy','Comedy','Drama','Horror','Sci-Fi','Adventure','Thriller',
|
||||
'Isekai','Supernatural','Historical','Psychological','Sports','Mystery','Mecha',
|
||||
'Slice of Life','School Life','Martial Arts','Magic','Military'].forEach(g => checkKey(`genre:${g}`))
|
||||
perfSnapshot = { cacheEntries: entries, cacheKeys: foundKeys, oldestEntryMs: oldest, newestEntryMs: newest }
|
||||
}
|
||||
|
||||
function fmtAge(ts: number | null): string {
|
||||
if (ts === null) return '—'
|
||||
const secs = Math.floor((Date.now() - ts) / 1000)
|
||||
if (secs < 60) return `${secs}s ago`
|
||||
const mins = Math.floor(secs / 60)
|
||||
if (mins < 60) return `${mins}m ago`
|
||||
return `${Math.floor(mins / 60)}h ago`
|
||||
}
|
||||
|
||||
function handleClearCache() {
|
||||
clearing = true
|
||||
caches.keys()
|
||||
.then(names => Promise.all(names.map(n => caches.delete(n))))
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
clearing = false
|
||||
cleared = true
|
||||
setTimeout(() => cleared = false, 2500)
|
||||
refreshPerfMetrics()
|
||||
})
|
||||
}
|
||||
|
||||
$effect(() => { refreshPerfMetrics() })
|
||||
</script>
|
||||
|
||||
<div class="s-panel">
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Render Limit</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-slider-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Items per page</span>
|
||||
<span class="s-desc">Lower = faster on large libraries</span>
|
||||
</div>
|
||||
<div class="s-stepper">
|
||||
<button class="s-step-btn"
|
||||
onclick={() => updateSettings({ renderLimit: Math.max(12, (settingsState.settings.renderLimit ?? 48) - 12) })}
|
||||
disabled={(settingsState.settings.renderLimit ?? 48) <= 12}>−</button>
|
||||
<span class="s-step-val">{settingsState.settings.renderLimit ?? 48}</span>
|
||||
<button class="s-step-btn"
|
||||
onclick={() => updateSettings({ renderLimit: Math.min(200, (settingsState.settings.renderLimit ?? 48) + 12) })}
|
||||
disabled={(settingsState.settings.renderLimit ?? 48) >= 200}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="s-presets">
|
||||
{#each [12, 24, 48, 96, 200] as v}
|
||||
<button class="s-preset" class:active={(settingsState.settings.renderLimit ?? 48) === v} onclick={() => updateSettings({ renderLimit: v })}>{v}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Rendering</p>
|
||||
<div class="s-section-body">
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">GPU acceleration</span><span class="s-desc">Uses the GPU for rendering; disable if you see visual glitches</span></div>
|
||||
<button role="switch" aria-checked={settingsState.settings.gpuAcceleration} aria-label="GPU acceleration"
|
||||
class="s-toggle" class:on={settingsState.settings.gpuAcceleration}
|
||||
onclick={() => updateSettings({ gpuAcceleration: !settingsState.settings.gpuAcceleration })}>
|
||||
<span class="s-toggle-thumb"></span>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Idle / Splash Screen</p>
|
||||
<div class="s-section-body">
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Animated card background</span><span class="s-desc">Shows cover art cards floating in the background on the idle screen</span></div>
|
||||
<button role="switch" aria-checked={settingsState.settings.splashCards ?? true} aria-label="Animated card background"
|
||||
class="s-toggle" class:on={settingsState.settings.splashCards ?? true}
|
||||
onclick={() => updateSettings({ splashCards: !(settingsState.settings.splashCards ?? true) })}>
|
||||
<span class="s-toggle-thumb"></span>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Session Cache</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Cache entries</span>
|
||||
<span class="s-desc">In-memory, cleared on restart</span>
|
||||
</div>
|
||||
<div class="s-btn-row">
|
||||
<span class="s-step-val">{perfSnapshot?.cacheEntries ?? 0} entries</span>
|
||||
<button class="s-btn-icon" onclick={refreshPerfMetrics} title="Refresh">↺</button>
|
||||
</div>
|
||||
</div>
|
||||
{#if perfSnapshot && perfSnapshot.cacheEntries > 0}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Oldest entry</span></div>
|
||||
<span class="s-step-val">{fmtAge(perfSnapshot.oldestEntryMs)}</span>
|
||||
</div>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Newest entry</span></div>
|
||||
<span class="s-step-val">{fmtAge(perfSnapshot.newestEntryMs)}</span>
|
||||
</div>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Cached keys</span>
|
||||
<span class="s-desc">{perfSnapshot.cacheKeys.join(', ')}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Cache</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Image cache</span><span class="s-desc">Webview page image cache</span></div>
|
||||
<button class="s-btn s-btn-danger" onclick={handleClearCache} disabled={clearing}>
|
||||
{cleared ? 'Cleared' : clearing ? 'Clearing…' : 'Clear'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,128 @@
|
||||
<script lang="ts">
|
||||
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
|
||||
import type { Settings, FitMode } from '$lib/types/settings'
|
||||
|
||||
interface Props {
|
||||
selectOpen: string | null
|
||||
closingSelect?: string | null
|
||||
toggleSelect: (id: string) => void
|
||||
anims: boolean
|
||||
}
|
||||
let { selectOpen, toggleSelect, anims }: Props = $props()
|
||||
|
||||
let triggerPageStyle = $state<HTMLButtonElement>(null!)
|
||||
let triggerReadingDir = $state<HTMLButtonElement>(null!)
|
||||
let triggerFitMode = $state<HTMLButtonElement>(null!)
|
||||
</script>
|
||||
|
||||
<div class="s-panel">
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Page Layout</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Default layout</span><span class="s-desc">How chapters open by default</span></div>
|
||||
<div class="s-select">
|
||||
<button bind:this={triggerPageStyle} class="s-select-btn" onclick={() => toggleSelect('page-style')}>
|
||||
<span>{{ 'single':'Single page','longstrip':'Long strip' }[settingsState.settings.pageStyle === 'double' ? 'single' : settingsState.settings.pageStyle]}</span>
|
||||
<svg class="s-select-caret" class:open={selectOpen === 'page-style'} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||
</button>
|
||||
{#if selectOpen === 'page-style'}
|
||||
<div class="s-select-menu" class:anims>
|
||||
{#each [['single','Single page'],['longstrip','Long strip']] as [v, l]}
|
||||
<button class="s-select-option" class:active={(settingsState.settings.pageStyle === 'double' ? 'single' : settingsState.settings.pageStyle) === v} onclick={() => { updateSettings({ pageStyle: v as Settings['pageStyle'] }); toggleSelect('page-style') }}>{l}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Reading direction</span><span class="s-desc">Left-to-right for most manga, right-to-left for Japanese</span></div>
|
||||
<div class="s-select">
|
||||
<button bind:this={triggerReadingDir} class="s-select-btn" onclick={() => toggleSelect('reading-dir')}>
|
||||
<span>{{ 'ltr':'Left to right','rtl':'Right to left' }[settingsState.settings.readingDirection]}</span>
|
||||
<svg class="s-select-caret" class:open={selectOpen === 'reading-dir'} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||
</button>
|
||||
{#if selectOpen === 'reading-dir'}
|
||||
<div class="s-select-menu" class:anims>
|
||||
{#each [['ltr','Left to right'],['rtl','Right to left']] as [v, l]}
|
||||
<button class="s-select-option" class:active={settingsState.settings.readingDirection === v} onclick={() => { updateSettings({ readingDirection: v as Settings['readingDirection'] }); toggleSelect('reading-dir') }}>{l}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Page gap</span><span class="s-desc">Adds spacing between pages in single-page mode</span></div>
|
||||
<button role="switch" aria-checked={settingsState.settings.pageGap} aria-label="Page gap" class="s-toggle" class:on={settingsState.settings.pageGap} onclick={() => updateSettings({ pageGap: !settingsState.settings.pageGap })}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Overlay bars</span><span class="s-desc">Floats the nav and chapter bars over the page instead of pushing content</span></div>
|
||||
<button role="switch" aria-checked={settingsState.settings.overlayBars ?? false} aria-label="Overlay bars" class="s-toggle" class:on={settingsState.settings.overlayBars ?? false} onclick={() => updateSettings({ overlayBars: !(settingsState.settings.overlayBars ?? false) })}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Tap to toggle bar</span><span class="s-desc">Double-tap the center of the reader to show or hide the bars</span></div>
|
||||
<button role="switch" aria-checked={settingsState.settings.tapToToggleBar ?? false} aria-label="Tap to toggle bar" class="s-toggle" class:on={settingsState.settings.tapToToggleBar ?? false} onclick={() => updateSettings({ tapToToggleBar: !(settingsState.settings.tapToToggleBar ?? false) })}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Fit & Zoom</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Default fit mode</span><span class="s-desc">How pages are scaled to fill the reader on open</span></div>
|
||||
<div class="s-select">
|
||||
<button bind:this={triggerFitMode} class="s-select-btn" onclick={() => toggleSelect('fit-mode')}>
|
||||
<span>{{ 'width':'Fit width','height':'Fit height','screen':'Fit screen','original':'Original (1:1)' }[settingsState.settings.fitMode ?? 'width']}</span>
|
||||
<svg class="s-select-caret" class:open={selectOpen === 'fit-mode'} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||
</button>
|
||||
{#if selectOpen === 'fit-mode'}
|
||||
<div class="s-select-menu" class:anims>
|
||||
{#each [['width','Fit width'],['height','Fit height'],['screen','Fit screen'],['original','Original (1:1)']] as [v, l]}
|
||||
<button class="s-select-option" class:active={(settingsState.settings.fitMode ?? 'width') === v} onclick={() => { updateSettings({ fitMode: v as FitMode }); toggleSelect('fit-mode') }}>{l}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Optimize contrast</span><span class="s-desc">Sharpens dark lines on light pages; best for black-and-white manga</span></div>
|
||||
<button role="switch" aria-checked={settingsState.settings.optimizeContrast} aria-label="Optimize contrast" class="s-toggle" class:on={settingsState.settings.optimizeContrast} onclick={() => updateSettings({ optimizeContrast: !settingsState.settings.optimizeContrast })}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Behaviour</p>
|
||||
<div class="s-section-body">
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Auto-mark read</span><span class="s-desc">Marks a chapter as read when you reach the last page</span></div>
|
||||
<button role="switch" aria-checked={settingsState.settings.autoMarkRead} aria-label="Auto-mark chapters read" class="s-toggle" class:on={settingsState.settings.autoMarkRead} onclick={() => updateSettings({ autoMarkRead: !settingsState.settings.autoMarkRead })}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Auto-advance chapters</span><span class="s-desc">Automatically loads the next chapter when you pass the last page</span></div>
|
||||
<button role="switch" aria-checked={settingsState.settings.autoNextChapter ?? false} aria-label="Auto-advance chapters" class="s-toggle" class:on={settingsState.settings.autoNextChapter} onclick={() => updateSettings({ autoNextChapter: !(settingsState.settings.autoNextChapter ?? false) })}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
{#if !(settingsState.settings.autoNextChapter ?? false)}
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Mark read when skipping</span><span class="s-desc">Marks the current chapter read when you manually jump to the next</span></div>
|
||||
<button role="switch" aria-checked={settingsState.settings.markReadOnNext ?? true} aria-label="Mark read when skipping" class="s-toggle" class:on={settingsState.settings.markReadOnNext ?? true} onclick={() => updateSettings({ markReadOnNext: !(settingsState.settings.markReadOnNext ?? true) })}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
{/if}
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Auto-bookmark</span><span class="s-desc">Automatically saves your page position as you read</span></div>
|
||||
<button role="switch" aria-checked={settingsState.settings.autoBookmark ?? true} aria-label="Enable auto-bookmark" class="s-toggle" class:on={settingsState.settings.autoBookmark ?? true} onclick={() => updateSettings({ autoBookmark: !(settingsState.settings.autoBookmark ?? true) })}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Pages to preload</span><span class="s-desc">How many pages ahead to fetch in the background while reading</span></div>
|
||||
<div class="s-stepper">
|
||||
<button class="s-step-btn" onclick={() => updateSettings({ preloadPages: Math.max(0, settingsState.settings.preloadPages - 1) })} disabled={settingsState.settings.preloadPages <= 0}>−</button>
|
||||
<span class="s-step-val">{settingsState.settings.preloadPages}</span>
|
||||
<button class="s-step-btn" onclick={() => updateSettings({ preloadPages: Math.min(10, settingsState.settings.preloadPages + 1) })} disabled={settingsState.settings.preloadPages >= 10}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,340 @@
|
||||
<script lang="ts">
|
||||
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
|
||||
import { requestManager } from '$lib/request-manager'
|
||||
import { authSession, loginUI } from '$lib/core/auth'
|
||||
|
||||
interface Props { selectOpen: string | null; toggleSelect: (id: string) => void }
|
||||
let { selectOpen, toggleSelect }: Props = $props()
|
||||
|
||||
let showAuthPass = $state(false)
|
||||
let showSocksPass = $state(false)
|
||||
let secLoading = $state(false)
|
||||
let secError = $state<string | null>(null)
|
||||
let secSaved = $state<string | null>(null)
|
||||
let secLoaded = $state(false)
|
||||
|
||||
let authMode = $state(settingsState.settings.serverAuthMode ?? 'NONE')
|
||||
let authUsername = $state(settingsState.settings.serverAuthUser ?? '')
|
||||
let authPassword = $state('')
|
||||
|
||||
let socksEnabled = $state(settingsState.settings.socksProxyEnabled ?? false)
|
||||
let socksHost = $state(settingsState.settings.socksProxyHost ?? '')
|
||||
let socksPort = $state(settingsState.settings.socksProxyPort ?? '1080')
|
||||
let socksVersion = $state(settingsState.settings.socksProxyVersion ?? 5)
|
||||
let socksUsername = $state(settingsState.settings.socksProxyUsername ?? '')
|
||||
let socksPassword = $state(settingsState.settings.socksProxyPassword ?? '')
|
||||
|
||||
let flareEnabled = $state(settingsState.settings.flareSolverrEnabled ?? false)
|
||||
let flareUrl = $state(settingsState.settings.flareSolverrUrl ?? 'http://localhost:8191')
|
||||
let flareTimeout = $state(settingsState.settings.flareSolverrTimeout ?? 60)
|
||||
let flareSession = $state(settingsState.settings.flareSolverrSessionName ?? 'moku')
|
||||
let flareTtl = $state(settingsState.settings.flareSolverrSessionTtl ?? 15)
|
||||
let flareFallback = $state(settingsState.settings.flareSolverrAsResponseFallback ?? false)
|
||||
|
||||
function normalizeAuthMode(mode: string): 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' {
|
||||
if (mode === 'BASIC_AUTH' || mode === 'UI_LOGIN' || mode === 'NONE') return mode
|
||||
return 'NONE'
|
||||
}
|
||||
|
||||
function showSaved(key: string) {
|
||||
secSaved = key; secError = null
|
||||
setTimeout(() => { if (secSaved === key) secSaved = null }, 2000)
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!secLoaded) { secLoaded = true; authSession.clearTokens(); loadServerSecurity() }
|
||||
})
|
||||
|
||||
async function loadServerSecurity() {
|
||||
try {
|
||||
const s = await requestManager.extensions.getServerSecurity()
|
||||
const serverMode = normalizeAuthMode(s.authMode)
|
||||
if (serverMode !== 'UI_LOGIN') authSession.clearTokens()
|
||||
authMode = serverMode
|
||||
authUsername = s.authUsername || ''
|
||||
updateSettings({ serverAuthMode: serverMode, serverAuthUser: authUsername })
|
||||
socksEnabled = s.socksProxyEnabled; socksHost = s.socksProxyHost
|
||||
socksPort = s.socksProxyPort; socksVersion = s.socksProxyVersion
|
||||
socksUsername = s.socksProxyUsername
|
||||
flareEnabled = s.flareSolverrEnabled; flareUrl = s.flareSolverrUrl
|
||||
flareTimeout = s.flareSolverrTimeout; flareSession = s.flareSolverrSessionName
|
||||
flareTtl = s.flareSolverrSessionTtl; flareFallback = s.flareSolverrAsResponseFallback
|
||||
updateSettings({
|
||||
socksProxyEnabled: socksEnabled, socksProxyHost: socksHost, socksProxyPort: socksPort,
|
||||
socksProxyVersion: socksVersion, socksProxyUsername: socksUsername,
|
||||
flareSolverrEnabled: flareEnabled, flareSolverrUrl: flareUrl,
|
||||
flareSolverrTimeout: flareTimeout, flareSolverrSessionName: flareSession,
|
||||
flareSolverrSessionTtl: flareTtl, flareSolverrAsResponseFallback: flareFallback,
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function saveAuth() {
|
||||
if (authMode === 'NONE') { await clearAuth(); return }
|
||||
if (!authUsername.trim() || !authPassword.trim()) { secError = 'Username and password are required'; return }
|
||||
secLoading = true; secError = null
|
||||
const prev = { mode: settingsState.settings.serverAuthMode, user: settingsState.settings.serverAuthUser, pass: settingsState.settings.serverAuthPass }
|
||||
try {
|
||||
const newUser = authUsername.trim()
|
||||
const newPass = authPassword.trim()
|
||||
authSession.clearTokens()
|
||||
if (authMode === 'UI_LOGIN') {
|
||||
await loginUI(newUser, newPass)
|
||||
updateSettings({ serverAuthMode: 'UI_LOGIN', serverAuthUser: newUser, serverAuthPass: '' })
|
||||
} else {
|
||||
updateSettings({ serverAuthMode: 'BASIC_AUTH', serverAuthUser: newUser, serverAuthPass: newPass })
|
||||
}
|
||||
await requestManager.extensions.setServerAuth({ authMode, authUsername: newUser, authPassword: newPass })
|
||||
authPassword = ''
|
||||
showSaved('auth')
|
||||
} catch (e: any) {
|
||||
const msg = e?.message ?? 'Failed to save authentication settings'
|
||||
const authMismatch = /unauthorized|unauthenticated|authentication|401/i.test(msg)
|
||||
if (!authMismatch) {
|
||||
authSession.clearTokens()
|
||||
updateSettings({ serverAuthMode: prev.mode, serverAuthUser: prev.user, serverAuthPass: prev.pass })
|
||||
}
|
||||
secError = authMismatch
|
||||
? 'Saved local auth settings, but the server rejected the update. Verify your new credentials with the current server configuration.'
|
||||
: msg
|
||||
} finally { secLoading = false }
|
||||
}
|
||||
|
||||
async function clearAuth() {
|
||||
secLoading = true; secError = null
|
||||
const prev = { mode: settingsState.settings.serverAuthMode, user: settingsState.settings.serverAuthUser, pass: settingsState.settings.serverAuthPass }
|
||||
try {
|
||||
await requestManager.extensions.setServerAuth({ authMode: 'NONE', authUsername: '', authPassword: '' })
|
||||
updateSettings({ serverAuthMode: 'NONE', serverAuthUser: '', serverAuthPass: '' })
|
||||
authMode = 'NONE'; authUsername = ''; authPassword = ''
|
||||
authSession.clearTokens(); showSaved('auth')
|
||||
} catch (e: any) {
|
||||
updateSettings({ serverAuthMode: prev.mode, serverAuthUser: prev.user, serverAuthPass: prev.pass })
|
||||
secError = e?.message ?? 'Failed to disable authentication'
|
||||
} finally { secLoading = false }
|
||||
}
|
||||
|
||||
async function saveSocksProxy() {
|
||||
secLoading = true; secError = null
|
||||
try {
|
||||
await requestManager.extensions.setSocksProxy({
|
||||
socksProxyEnabled: socksEnabled, socksProxyHost: socksHost.trim(),
|
||||
socksProxyPort: socksPort.trim(), socksProxyVersion: socksVersion,
|
||||
socksProxyUsername: socksUsername.trim(), socksProxyPassword: socksPassword.trim(),
|
||||
})
|
||||
updateSettings({
|
||||
socksProxyEnabled: socksEnabled, socksProxyHost: socksHost,
|
||||
socksProxyPort: socksPort, socksProxyVersion: socksVersion,
|
||||
socksProxyUsername: socksUsername, socksProxyPassword: socksPassword,
|
||||
})
|
||||
showSaved('socks')
|
||||
} catch (e: any) {
|
||||
secError = e?.message ?? 'Failed to save SOCKS proxy'
|
||||
} finally { secLoading = false }
|
||||
}
|
||||
|
||||
async function saveFlareSolverr() {
|
||||
secLoading = true; secError = null
|
||||
try {
|
||||
await requestManager.extensions.setFlareSolverr({
|
||||
flareSolverrEnabled: flareEnabled, flareSolverrUrl: flareUrl.trim(),
|
||||
flareSolverrTimeout: flareTimeout, flareSolverrSessionName: flareSession.trim(),
|
||||
flareSolverrSessionTtl: flareTtl, flareSolverrAsResponseFallback: flareFallback,
|
||||
})
|
||||
updateSettings({
|
||||
flareSolverrEnabled: flareEnabled, flareSolverrUrl: flareUrl,
|
||||
flareSolverrTimeout: flareTimeout, flareSolverrSessionName: flareSession,
|
||||
flareSolverrSessionTtl: flareTtl, flareSolverrAsResponseFallback: flareFallback,
|
||||
})
|
||||
showSaved('flare')
|
||||
} catch (e: any) {
|
||||
secError = e?.message ?? 'Failed to save FlareSolverr'
|
||||
} finally { secLoading = false }
|
||||
}
|
||||
|
||||
function forceResetAuth() {
|
||||
authSession.clearTokens()
|
||||
authMode = 'NONE'
|
||||
authUsername = ''
|
||||
authPassword = ''
|
||||
updateSettings({ serverAuthMode: 'NONE', serverAuthUser: '', serverAuthPass: '' })
|
||||
showSaved('auth')
|
||||
}
|
||||
|
||||
const EyeOpen = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>`
|
||||
const EyeClose = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/></svg>`
|
||||
</script>
|
||||
|
||||
<div class="s-panel">
|
||||
|
||||
{#if secError}
|
||||
<div class="s-banner s-banner-error">{secError}</div>
|
||||
{/if}
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">
|
||||
Server Authentication
|
||||
<span class="s-pill" class:on={settingsState.settings.serverAuthMode === 'BASIC_AUTH' || settingsState.settings.serverAuthMode === 'UI_LOGIN'}>
|
||||
{settingsState.settings.serverAuthMode === 'BASIC_AUTH' ? 'Basic Auth' :
|
||||
settingsState.settings.serverAuthMode === 'UI_LOGIN' ? 'UI Login' : 'Disabled'}
|
||||
</span>
|
||||
</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Mode</span><span class="s-desc">How Moku authenticates with the server</span></div>
|
||||
<div class="s-segment">
|
||||
{#each [{ value: 'NONE', label: 'None' }, { value: 'BASIC_AUTH', label: 'Basic' }, { value: 'UI_LOGIN', label: 'UI Login' }] as opt}
|
||||
<button class="s-segment-btn" class:active={authMode === opt.value}
|
||||
onclick={() => authMode = opt.value as any} disabled={secLoading}>{opt.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{#if authMode !== 'NONE'}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Username</span></div>
|
||||
<input class="s-input" bind:value={authUsername} placeholder="Username" autocomplete="off" spellcheck="false" disabled={secLoading} />
|
||||
</div>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Password</span></div>
|
||||
<div class="s-field-wrap">
|
||||
<input class="s-input" type={showAuthPass ? 'text' : 'password'} bind:value={authPassword} placeholder="Password" autocomplete="off" spellcheck="false" disabled={secLoading} />
|
||||
<button class="s-eye-btn" onclick={() => showAuthPass = !showAuthPass} tabindex="-1" aria-label={showAuthPass ? 'Hide password' : 'Show password'}>{@html showAuthPass ? EyeClose : EyeOpen}</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if settingsState.settings.serverAuthMode === 'BASIC_AUTH'}
|
||||
<div class="s-row">
|
||||
<span class="s-desc">Images are proxied through Tauri when Basic Auth is active, which reduces loading speed.</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<button class="s-ghost-btn" onclick={forceResetAuth} disabled={secLoading} title="Force reset local auth state">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
<div class="s-btn-row">
|
||||
{#if settingsState.settings.serverAuthMode !== 'NONE'}
|
||||
<button class="s-btn s-btn-danger" onclick={clearAuth} disabled={secLoading}>
|
||||
{secLoading ? 'Saving…' : 'Disable'}
|
||||
</button>
|
||||
{/if}
|
||||
<button class="s-btn s-btn-accent" onclick={saveAuth}
|
||||
disabled={secLoading || ((authMode === 'BASIC_AUTH' || authMode === 'UI_LOGIN') && (!authUsername.trim() || !authPassword.trim()))}>
|
||||
{secLoading ? 'Saving…' : secSaved === 'auth' ? 'Saved ✓' : settingsState.settings.serverAuthMode === 'BASIC_AUTH' ? 'Update' : authMode === 'NONE' ? 'Save' : 'Enable'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">SOCKS Proxy</p>
|
||||
<div class="s-section-body">
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Enable SOCKS proxy</span><span class="s-desc">Route Suwayomi traffic through a SOCKS4/5 proxy</span></div>
|
||||
<button role="switch" aria-checked={socksEnabled} aria-label="Enable SOCKS proxy" class="s-toggle" class:on={socksEnabled}
|
||||
onclick={() => { socksEnabled = !socksEnabled; saveSocksProxy() }}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
{#if socksEnabled}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Version</span></div>
|
||||
<div class="s-select" id="socks-ver">
|
||||
<button class="s-select-btn" onclick={() => toggleSelect('socks-ver')}>
|
||||
<span>SOCKS{socksVersion}</span>
|
||||
<svg class="s-select-caret" class:open={selectOpen === 'socks-ver'} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||
</button>
|
||||
{#if selectOpen === 'socks-ver'}
|
||||
<div class="s-select-menu">
|
||||
{#each [[4, 'SOCKS4'], [5, 'SOCKS5']] as [v, l]}
|
||||
<button class="s-select-option" class:active={socksVersion === v} onclick={() => { socksVersion = v as number; toggleSelect('socks-ver') }}>{l}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Host</span></div>
|
||||
<input class="s-input" bind:value={socksHost} placeholder="127.0.0.1" autocomplete="off" spellcheck="false" />
|
||||
</div>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Port</span></div>
|
||||
<input class="s-input" style="width:80px" bind:value={socksPort} placeholder="1080" autocomplete="off" spellcheck="false" />
|
||||
</div>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Username</span><span class="s-desc">Optional</span></div>
|
||||
<input class="s-input" bind:value={socksUsername} placeholder="Username" autocomplete="off" spellcheck="false" />
|
||||
</div>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Password</span><span class="s-desc">Optional</span></div>
|
||||
<div class="s-field-wrap">
|
||||
<input class="s-input" type={showSocksPass ? 'text' : 'password'} bind:value={socksPassword} placeholder="Password" autocomplete="off" spellcheck="false" />
|
||||
<button class="s-eye-btn" onclick={() => showSocksPass = !showSocksPass} tabindex="-1" aria-label={showSocksPass ? 'Hide password' : 'Show password'}>{@html showSocksPass ? EyeClose : EyeOpen}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"></div>
|
||||
<button class="s-btn s-btn-accent" onclick={saveSocksProxy} disabled={secLoading}>
|
||||
{secLoading ? 'Saving…' : secSaved === 'socks' ? 'Saved ✓' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">FlareSolverr</p>
|
||||
<div class="s-section-body">
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Enable FlareSolverr</span><span class="s-desc">Bypass Cloudflare challenges for sources that require it</span></div>
|
||||
<button role="switch" aria-checked={flareEnabled} aria-label="Enable FlareSolverr" class="s-toggle" class:on={flareEnabled}
|
||||
onclick={() => { flareEnabled = !flareEnabled; saveFlareSolverr() }}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
{#if flareEnabled}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">URL</span><span class="s-desc">FlareSolverr instance address</span></div>
|
||||
<input class="s-input" bind:value={flareUrl} placeholder="http://localhost:8191" autocomplete="off" spellcheck="false" />
|
||||
</div>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Timeout</span><span class="s-desc">Max wait per request, in seconds</span></div>
|
||||
<div class="s-stepper">
|
||||
<button class="s-step-btn" onclick={() => flareTimeout = Math.max(10, flareTimeout - 10)}>−</button>
|
||||
<span class="s-step-val">{flareTimeout}s</span>
|
||||
<button class="s-step-btn" onclick={() => flareTimeout = Math.min(300, flareTimeout + 10)}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Session name</span><span class="s-desc">Reuse browser session across requests</span></div>
|
||||
<input class="s-input" bind:value={flareSession} placeholder="moku" autocomplete="off" spellcheck="false" />
|
||||
</div>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Session TTL</span><span class="s-desc">Minutes before session is refreshed</span></div>
|
||||
<div class="s-stepper">
|
||||
<button class="s-step-btn" onclick={() => flareTtl = Math.max(1, flareTtl - 1)}>−</button>
|
||||
<span class="s-step-val">{flareTtl}m</span>
|
||||
<button class="s-step-btn" onclick={() => flareTtl = Math.min(60, flareTtl + 1)}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Response fallback</span><span class="s-desc">Use FlareSolverr's response when the direct request fails</span></div>
|
||||
<button role="switch" aria-checked={flareFallback} aria-label="Response fallback" class="s-toggle" class:on={flareFallback}
|
||||
onclick={() => flareFallback = !flareFallback}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"></div>
|
||||
<button class="s-btn s-btn-accent" onclick={saveFlareSolverr} disabled={secLoading}>
|
||||
{secLoading ? 'Saving…' : secSaved === 'flare' ? 'Saved ✓' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.s-ghost-btn { display: inline-flex; align-items: center; gap: 5px; background: none; border: none; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); cursor: pointer; padding: 2px 0; transition: color 0.15s; }
|
||||
.s-ghost-btn:hover:not(:disabled) { color: var(--color-error); }
|
||||
.s-ghost-btn:disabled { opacity: 0.35; cursor: default; }
|
||||
</style>
|
||||
@@ -0,0 +1,869 @@
|
||||
<script lang="ts">
|
||||
import { Trash, ClockCounterClockwise } from 'phosphor-svelte'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { untrack } from 'svelte'
|
||||
import { appState } from '$lib/state/app.svelte'
|
||||
import { toast } from '$lib/state/notifications.svelte'
|
||||
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
|
||||
import { exportAppData, importAppData } from '$lib/core/backup'
|
||||
import { loadBackups, persistBackups, persistSettings, persistLibrary } from '$lib/core/persistence/persist'
|
||||
import type { BackupEntry } from '$lib/core/persistence/persist'
|
||||
import { DEFAULT_SETTINGS } from '$lib/types/settings'
|
||||
import { DEFAULT_READING_STATS } from '$lib/types/history'
|
||||
import { clearBlobCache } from '$lib/core/cache/imageCache'
|
||||
import { clearPageCache } from '$lib/request-manager'
|
||||
import { cache as queryCache } from '$lib/core/cache/queryCache'
|
||||
|
||||
type ResetState = 'idle' | 'busy' | 'done' | 'error'
|
||||
interface ResetItem { key: string; label: string; desc: string; state: ResetState; error: string | null; confirm: boolean }
|
||||
|
||||
let resetItems = $state<ResetItem[]>([
|
||||
{ key: 'all-cache', label: 'Clear all caches', desc: 'Flushes the image blob cache, page cache, query cache, Moku disk cache, Suwayomi disk cache, and server image/thumbnail cache in one pass.', state: 'idle', error: null, confirm: false },
|
||||
{ key: 'reading-history', label: 'Clear reading history', desc: 'Erases chapter history, read log, reading stats, and daily read counts.', state: 'idle', error: null, confirm: true },
|
||||
{ key: 'moku-settings', label: 'Reset Moku settings', desc: 'Restores all app settings to their defaults. Does not affect library data.', state: 'idle', error: null, confirm: true },
|
||||
{ key: 'suwayomi-data', label: 'Reset Suwayomi data', desc: 'Deletes the database, extensions, settings, and logs. Downloads and backups are preserved.', state: 'idle', error: null, confirm: true },
|
||||
])
|
||||
|
||||
let confirming = $state<string | null>(null)
|
||||
|
||||
function patchReset(key: string, update: Partial<ResetItem>) {
|
||||
resetItems = resetItems.map(i => i.key === key ? { ...i, ...update } : i)
|
||||
}
|
||||
|
||||
function showExitCountdown(): Promise<void> {
|
||||
return new Promise(resolve => {
|
||||
const backdrop = document.createElement('div')
|
||||
backdrop.className = 's-backdrop'
|
||||
backdrop.style.cssText = 'z-index:99999'
|
||||
const modal = document.createElement('div')
|
||||
modal.style.cssText = 'background:var(--bg-surface);border:1px solid var(--border-base);border-radius:var(--radius-2xl);box-shadow:0 0 0 1px rgba(255,255,255,0.04) inset,0 24px 80px rgba(0,0,0,0.7);width:min(400px,calc(100vw - 40px));display:flex;flex-direction:column;overflow:hidden;animation:s-scale-in 0.2s cubic-bezier(0.16,1,0.3,1) both'
|
||||
const header = document.createElement('div')
|
||||
header.style.cssText = 'padding:var(--sp-4) var(--sp-5) var(--sp-3);border-bottom:1px solid var(--border-dim)'
|
||||
const title = document.createElement('p')
|
||||
title.style.cssText = 'margin:0;font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--text-primary);letter-spacing:0.01em'
|
||||
title.textContent = 'Reset complete'
|
||||
header.appendChild(title)
|
||||
const body = document.createElement('div')
|
||||
body.style.cssText = 'padding:var(--sp-4) var(--sp-5);display:flex;flex-direction:column;gap:var(--sp-2)'
|
||||
const sub = document.createElement('p')
|
||||
sub.style.cssText = 'margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-faint);letter-spacing:var(--tracking-wide);line-height:var(--leading-snug)'
|
||||
sub.textContent = 'Moku will close so you can relaunch with the reset applied.'
|
||||
const counter = document.createElement('p')
|
||||
counter.style.cssText = 'margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-faint);letter-spacing:var(--tracking-wide)'
|
||||
counter.textContent = 'Closing in 3…'
|
||||
body.append(sub, counter)
|
||||
const footer = document.createElement('div')
|
||||
footer.style.cssText = 'padding:var(--sp-3) var(--sp-5);border-top:1px solid var(--border-dim);display:flex;justify-content:flex-end'
|
||||
const btn = document.createElement('button')
|
||||
btn.className = 's-btn s-btn-danger'
|
||||
btn.textContent = 'Close now'
|
||||
footer.appendChild(btn)
|
||||
modal.append(header, body, footer)
|
||||
backdrop.appendChild(modal)
|
||||
document.body.appendChild(backdrop)
|
||||
let secs = 3
|
||||
const tick = setInterval(() => {
|
||||
secs--
|
||||
counter.textContent = secs > 0 ? `Closing in ${secs}…` : 'Closing…'
|
||||
if (secs <= 0) { clearInterval(tick); backdrop.remove(); resolve() }
|
||||
}, 1000)
|
||||
btn.addEventListener('click', () => { clearInterval(tick); backdrop.remove(); resolve() })
|
||||
})
|
||||
}
|
||||
|
||||
async function clearAllCaches(): Promise<void> {
|
||||
clearBlobCache()
|
||||
clearPageCache()
|
||||
queryCache.clearAll()
|
||||
await Promise.all([
|
||||
invoke('clear_moku_cache'),
|
||||
invoke('clear_suwayomi_cache'),
|
||||
gql(`mutation { clearCachedImages(input: { cachedPages: true, cachedThumbnails: true, downloadedThumbnails: false }) { cachedPages cachedThumbnails } }`),
|
||||
])
|
||||
}
|
||||
|
||||
async function runReset(key: string) {
|
||||
confirming = null
|
||||
patchReset(key, { state: 'busy', error: null })
|
||||
try {
|
||||
switch (key) {
|
||||
case 'all-cache':
|
||||
await clearAllCaches()
|
||||
break
|
||||
case 'reading-history':
|
||||
await persistLibrary({ history: [], bookmarks: [], markers: [], readLog: [], readingStats: DEFAULT_READING_STATS, dailyReadCounts: {} })
|
||||
break
|
||||
case 'moku-settings':
|
||||
localStorage.clear()
|
||||
await persistSettings({ settings: DEFAULT_SETTINGS, storeVersion: 1 })
|
||||
patchReset(key, { state: 'done' })
|
||||
await showExitCountdown()
|
||||
invoke('exit_app')
|
||||
return
|
||||
case 'suwayomi-data':
|
||||
localStorage.clear()
|
||||
await invoke('reset_suwayomi_data')
|
||||
patchReset(key, { state: 'done' })
|
||||
await showExitCountdown()
|
||||
invoke('exit_app')
|
||||
return
|
||||
}
|
||||
patchReset(key, { state: 'done' })
|
||||
setTimeout(() => patchReset(key, { state: 'idle' }), 3000)
|
||||
} catch (e: any) {
|
||||
patchReset(key, { state: 'error', error: e?.message ?? String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
interface StorageInfo { manga_bytes: number; total_bytes: number; free_bytes: number; path: string }
|
||||
|
||||
const isExternalServer = $derived.by(() => {
|
||||
const url = (settingsState.settings.serverUrl ?? 'http://localhost:4567').toLowerCase().trim()
|
||||
try {
|
||||
const host = new URL(url).hostname
|
||||
return host !== 'localhost' && host !== '127.0.0.1' && host !== '::1'
|
||||
} catch { return false }
|
||||
})
|
||||
|
||||
let storageInfo = $state<StorageInfo | null>(null)
|
||||
let storageLoading = $state(false)
|
||||
let storageError = $state<string | null>(null)
|
||||
|
||||
let downloadsPathInput = $state(settingsState.settings.serverDownloadsPath ?? '')
|
||||
let localSourcePathInput = $state(settingsState.settings.serverLocalSourcePath ?? '')
|
||||
let pathsSaving = $state(false)
|
||||
let pathsError = $state<string | null>(null)
|
||||
let pathsFieldError = $state<{ dl?: string; loc?: string }>({})
|
||||
let pathsSaved = $state(false)
|
||||
|
||||
let defaultDownloadsPath = $state('')
|
||||
$effect(() => {
|
||||
if (!isExternalServer) {
|
||||
invoke<string>('get_default_downloads_path').then(p => { defaultDownloadsPath = p })
|
||||
} else {
|
||||
defaultDownloadsPath = ''
|
||||
}
|
||||
})
|
||||
|
||||
let confirmedDownloadsPath = $state(settingsState.settings.serverDownloadsPath ?? '')
|
||||
let confirmedLocalSourcePath = $state(settingsState.settings.serverLocalSourcePath ?? '')
|
||||
|
||||
let migrateFrom = $state<string | null>(null)
|
||||
let migrateTo = $state<string | null>(null)
|
||||
let migrating = $state(false)
|
||||
let migrateProgress = $state<{ done: number; total: number; current: string } | null>(null)
|
||||
let migrateError = $state<string | null>(null)
|
||||
let migrateUnlisten: (() => void) | null = null
|
||||
|
||||
let extraScanDirs = $state<string[]>([...(settingsState.settings.extraScanDirs ?? [])])
|
||||
let newScanDir = $state('')
|
||||
let multiStorageInfos = $state<(StorageInfo & { label: string })[]>([])
|
||||
let advStorageOpen = $state(false)
|
||||
let backupSectionOpen = $state(false)
|
||||
let resetSectionOpen = $state(false)
|
||||
|
||||
async function fetchStorage() {
|
||||
storageLoading = true; storageError = null
|
||||
try {
|
||||
const pathData = await gql<{ downloadsPath: string | null; localSourcePath: string | null }>(
|
||||
`{ downloadsPath localSourcePath }`
|
||||
)
|
||||
const dl = pathData.downloadsPath ?? ''
|
||||
const loc = pathData.localSourcePath ?? ''
|
||||
downloadsPathInput = dl; localSourcePathInput = loc
|
||||
confirmedDownloadsPath = dl; confirmedLocalSourcePath = loc
|
||||
updateSettings({ serverDownloadsPath: dl, serverLocalSourcePath: loc })
|
||||
if (isExternalServer) { multiStorageInfos = []; storageInfo = null; return }
|
||||
const effectiveDl = dl || defaultDownloadsPath
|
||||
const dirsToScan: { path: string; label: string }[] = []
|
||||
if (effectiveDl) dirsToScan.push({ path: effectiveDl, label: dl ? 'Downloads' : 'Downloads (default)' })
|
||||
if (loc && loc !== effectiveDl) dirsToScan.push({ path: loc, label: 'Local source' })
|
||||
for (const p of extraScanDirs) {
|
||||
if (p && !dirsToScan.find(d => d.path === p)) dirsToScan.push({ path: p, label: p })
|
||||
}
|
||||
if (dirsToScan.length === 0) { multiStorageInfos = []; storageInfo = null; return }
|
||||
const results = await Promise.allSettled(
|
||||
dirsToScan.map(d => invoke<StorageInfo>('get_storage_info', { downloadsPath: d.path }).then(info => ({ ...info, label: d.label })))
|
||||
)
|
||||
multiStorageInfos = results
|
||||
.filter((r): r is PromiseFulfilledResult<StorageInfo & { label: string }> => r.status === 'fulfilled')
|
||||
.map(r => r.value)
|
||||
storageInfo = multiStorageInfos[0] ?? null
|
||||
} catch (e: any) {
|
||||
storageError = e instanceof Error ? e.message : String(e)
|
||||
} finally { storageLoading = false }
|
||||
}
|
||||
|
||||
async function validatePath(path: string): Promise<string | null> {
|
||||
if (!path.trim()) return null
|
||||
if (isExternalServer) return null
|
||||
try {
|
||||
const exists = await invoke<boolean>('check_path_exists', { path: path.trim() })
|
||||
return exists ? null : 'Directory does not exist'
|
||||
} catch { return 'Could not check path' }
|
||||
}
|
||||
|
||||
async function createDirectory(path: string): Promise<void> {
|
||||
if (isExternalServer) throw new Error('Cannot create directories on an external server')
|
||||
await invoke('create_directory', { path })
|
||||
}
|
||||
|
||||
async function savePaths() {
|
||||
const dl = downloadsPathInput.trim()
|
||||
const loc = localSourcePathInput.trim()
|
||||
pathsError = null; pathsFieldError = {}
|
||||
const [dlErr, locErr] = await Promise.all([validatePath(dl), validatePath(loc)])
|
||||
if (dlErr || locErr) { pathsFieldError = { ...(dlErr ? { dl: dlErr } : {}), ...(locErr ? { loc: locErr } : {}) }; return }
|
||||
pathsSaving = true
|
||||
try {
|
||||
await gql(`mutation($path: String!) { setDownloadsPath(input: { location: $path }) { location } }`, { path: dl })
|
||||
if (loc) await gql(`mutation($path: String!) { setLocalSourcePath(input: { location: $path }) { location } }`, { path: loc })
|
||||
updateSettings({ serverDownloadsPath: dl, serverLocalSourcePath: loc })
|
||||
if (!isExternalServer) {
|
||||
const oldDl = confirmedDownloadsPath || defaultDownloadsPath
|
||||
const newDl = dl || defaultDownloadsPath
|
||||
if (newDl && oldDl && newDl !== oldDl) {
|
||||
const hadContent = await invoke<boolean>('check_path_exists', { path: oldDl })
|
||||
if (hadContent) { migrateFrom = oldDl; migrateTo = newDl }
|
||||
}
|
||||
}
|
||||
confirmedDownloadsPath = dl; confirmedLocalSourcePath = loc
|
||||
pathsSaved = true; setTimeout(() => pathsSaved = false, 2000)
|
||||
await fetchStorage()
|
||||
} catch (e: any) {
|
||||
pathsError = e?.message ?? 'Failed to save paths'
|
||||
} finally { pathsSaving = false }
|
||||
}
|
||||
|
||||
async function startMigration() {
|
||||
if (!migrateFrom || !migrateTo) return
|
||||
migrating = true; migrateError = null; migrateProgress = { done: 0, total: 0, current: '' }
|
||||
const { listen: tauriListen } = await import('@tauri-apps/api/event')
|
||||
migrateUnlisten = await tauriListen<{ done: number; total: number; current: string }>(
|
||||
'migrate_progress', e => { migrateProgress = e.payload }
|
||||
)
|
||||
try {
|
||||
await invoke('migrate_downloads', { src: migrateFrom, dst: migrateTo })
|
||||
migrateFrom = null; migrateTo = null; migrateProgress = null
|
||||
await fetchStorage()
|
||||
} catch (e: any) {
|
||||
migrateError = e?.message ?? 'Migration failed'
|
||||
} finally { migrating = false; migrateUnlisten?.(); migrateUnlisten = null }
|
||||
}
|
||||
|
||||
function dismissMigration() { migrateFrom = null; migrateTo = null; migrateError = null; migrateProgress = null }
|
||||
|
||||
async function browseDownloadsFolder() {
|
||||
const picked = await invoke<string | null>('pick_downloads_folder')
|
||||
if (picked) { downloadsPathInput = picked; pathsFieldError = { ...pathsFieldError, dl: undefined }; await savePaths() }
|
||||
}
|
||||
|
||||
async function browseLocalSourceFolder() {
|
||||
const picked = await invoke<string | null>('pick_downloads_folder')
|
||||
if (picked) { localSourcePathInput = picked; pathsFieldError = { ...pathsFieldError, loc: undefined }; await savePaths() }
|
||||
}
|
||||
|
||||
async function browseExtraScanDir() {
|
||||
const picked = await invoke<string | null>('pick_downloads_folder')
|
||||
if (picked) { newScanDir = picked; addExtraScanDir() }
|
||||
}
|
||||
|
||||
function addExtraScanDir() {
|
||||
const dir = newScanDir.trim()
|
||||
if (!dir || extraScanDirs.includes(dir)) return
|
||||
extraScanDirs = [...extraScanDirs, dir]
|
||||
updateSettings({ extraScanDirs }); newScanDir = ''; fetchStorage()
|
||||
}
|
||||
|
||||
function removeExtraScanDir(path: string) {
|
||||
extraScanDirs = extraScanDirs.filter(d => d !== path)
|
||||
updateSettings({ extraScanDirs }); fetchStorage()
|
||||
}
|
||||
|
||||
function fmtBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(i >= 2 ? 1 : 0)} ${units[i]}`
|
||||
}
|
||||
|
||||
let backupLoading = $state(false)
|
||||
let backupError = $state<string | null>(null)
|
||||
let backupList = $state<(BackupEntry & { deleting?: boolean })[]>([])
|
||||
|
||||
async function loadBackupList() {
|
||||
backupList = (await loadBackups()).map(b => ({ ...b }))
|
||||
}
|
||||
|
||||
async function saveBackupList() {
|
||||
await persistBackups(backupList.map(({ url, name }) => ({ url, name })))
|
||||
}
|
||||
|
||||
async function createBackup() {
|
||||
backupLoading = true; backupError = null
|
||||
try {
|
||||
const data = await gql<{ createBackup: { url: string } }>(`mutation { createBackup { url } }`)
|
||||
const { url } = data.createBackup
|
||||
const name = url.split('/').pop() ?? url
|
||||
backupList = [{ url, name }, ...backupList]
|
||||
await saveBackupList()
|
||||
} catch (e: any) { backupError = e?.message ?? 'Failed to create backup' }
|
||||
finally { backupLoading = false }
|
||||
}
|
||||
|
||||
async function deleteBackup(url: string) {
|
||||
backupList = backupList.map(b => b.url === url ? { ...b, deleting: true } : b)
|
||||
try {
|
||||
await fetch(`${serverUrl()}${url}`, { method: 'DELETE', headers: buildAuthHeaders() })
|
||||
backupList = backupList.filter(b => b.url !== url)
|
||||
await saveBackupList()
|
||||
} catch (e: any) {
|
||||
backupList = backupList.map(b => b.url === url ? { ...b, deleting: false } : b)
|
||||
backupError = e?.message ?? 'Failed to delete backup'
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadBackup(backup: BackupEntry) {
|
||||
try {
|
||||
const resp = await fetch(`${serverUrl()}${backup.url}`, { headers: buildAuthHeaders() })
|
||||
if (!resp.ok) throw new Error(`Server returned ${resp.status}`)
|
||||
const blob = await resp.blob()
|
||||
if ('showSaveFilePicker' in window) {
|
||||
try {
|
||||
const handle = await (window as any).showSaveFilePicker({
|
||||
suggestedName: backup.name,
|
||||
types: [{ description: 'Backup file', accept: { 'application/octet-stream': ['.tachibk', '.proto.gz'] } }],
|
||||
})
|
||||
const writable = await handle.createWritable()
|
||||
await writable.write(blob); await writable.close()
|
||||
toast({ kind: 'success', message: 'Backup saved', detail: backup.name }); return
|
||||
} catch (pickerErr: any) { if (pickerErr?.name === 'AbortError') return }
|
||||
}
|
||||
const objectUrl = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = objectUrl; a.download = backup.name
|
||||
document.body.appendChild(a); a.click(); document.body.removeChild(a)
|
||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 5000)
|
||||
toast({ kind: 'download', message: 'Backup downloaded', detail: backup.name })
|
||||
} catch (e: any) { backupError = e?.message ?? 'Failed to download backup' }
|
||||
}
|
||||
|
||||
let restoreLoading = $state(false)
|
||||
let restoreError = $state<string | null>(null)
|
||||
let restoreJobId = $state<string | null>(null)
|
||||
let restoreStatus = $state<{ mangaProgress: number; state: string; totalManga: number } | null>(null)
|
||||
let restorePollInterval = $state<ReturnType<typeof setInterval> | null>(null)
|
||||
let validateLoading = $state(false)
|
||||
let validateError = $state<string | null>(null)
|
||||
let validateResult = $state<{ missingSources: { id: string; name: string }[]; missingTrackers: { name: string }[] } | null>(null)
|
||||
let restoreFile = $state<File | null>(null)
|
||||
|
||||
function stopRestorePoll() {
|
||||
if (restorePollInterval) { clearInterval(restorePollInterval); restorePollInterval = null }
|
||||
}
|
||||
|
||||
async function pollRestoreStatus(id: string) {
|
||||
try {
|
||||
const data = await gql<{ restoreStatus: { mangaProgress: number; state: string; totalManga: number } }>(
|
||||
`query($id: String!) { restoreStatus(id: $id) { mangaProgress state totalManga } }`,
|
||||
{ id }
|
||||
)
|
||||
const status = data.restoreStatus
|
||||
restoreStatus = status
|
||||
if (status?.state === 'SUCCESS' || status?.state === 'FAILURE') stopRestorePoll()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function buildBackupFormData(file: File, query: string, variables: Record<string, unknown>) {
|
||||
const form = new FormData()
|
||||
form.append('operations', JSON.stringify({ query, variables }))
|
||||
form.append('map', JSON.stringify({ '0': ['variables.backup'] }))
|
||||
form.append('0', file, file.name)
|
||||
return form
|
||||
}
|
||||
|
||||
function buildAuthHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = { Accept: 'application/json' }
|
||||
const pass = settingsState.settings.serverAuthPass ?? '', user = settingsState.settings.serverAuthUser ?? ''
|
||||
if (settingsState.settings.serverAuthMode === 'BASIC_AUTH' && user && pass)
|
||||
headers['Authorization'] = 'Basic ' + btoa(`${user}:${pass}`)
|
||||
return headers
|
||||
}
|
||||
|
||||
function serverUrl(): string {
|
||||
return (settingsState.settings.serverUrl ?? 'http://localhost:4567').replace(/\/$/, '')
|
||||
}
|
||||
|
||||
async function gql<T = unknown>(query: string, variables?: Record<string, unknown>): Promise<T> {
|
||||
const res = await fetch(`${serverUrl()}/api/graphql`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...buildAuthHeaders() },
|
||||
body: JSON.stringify({ query, variables }),
|
||||
})
|
||||
const json = await res.json()
|
||||
if (json.errors?.length) throw new Error(json.errors[0].message)
|
||||
return json.data as T
|
||||
}
|
||||
|
||||
async function submitRestore() {
|
||||
if (!restoreFile) return
|
||||
restoreLoading = true; restoreError = null; restoreStatus = null; restoreJobId = null
|
||||
stopRestorePoll()
|
||||
try {
|
||||
const resp = await fetch(`${serverUrl()}/api/graphql`, { method: 'POST', headers: buildAuthHeaders(), body: form })
|
||||
const json = await resp.json()
|
||||
if (json.errors?.length) throw new Error(json.errors[0].message)
|
||||
const result = json.data.restoreBackup
|
||||
restoreJobId = result.id; restoreStatus = result.status
|
||||
if (result.status?.state !== 'SUCCESS' && result.status?.state !== 'FAILURE')
|
||||
restorePollInterval = setInterval(() => pollRestoreStatus(result.id), 1500)
|
||||
} catch (e: any) { restoreError = e?.message ?? 'Failed to start restore' }
|
||||
finally { restoreLoading = false }
|
||||
}
|
||||
|
||||
async function submitValidate() {
|
||||
if (!restoreFile) return
|
||||
validateLoading = true; validateError = null; validateResult = null
|
||||
try {
|
||||
const form = buildBackupFormData(
|
||||
restoreFile,
|
||||
`query ValidateBackup($backup: Upload!) { validateBackup(input: { backup: $backup }) { missingSources { id name } missingTrackers { name } } }`,
|
||||
{ backup: null }
|
||||
)
|
||||
const resp = await fetch(`${serverUrl()}/api/graphql`, { method: 'POST', headers: buildAuthHeaders(), body: form })
|
||||
const json = await resp.json()
|
||||
if (json.errors?.length) throw new Error(json.errors[0].message)
|
||||
validateResult = json.data.validateBackup
|
||||
} catch (e: any) { validateError = e?.message ?? 'Failed to validate backup' }
|
||||
finally { validateLoading = false }
|
||||
}
|
||||
|
||||
let appDataExporting = $state(false)
|
||||
let appDataImporting = $state(false)
|
||||
let appDataError = $state<string | null>(null)
|
||||
let appDataMsg = $state<string | null>(null)
|
||||
let appDataBackupDir = $state<string | null>(null)
|
||||
|
||||
$effect(() => {
|
||||
invoke<string>('get_auto_backup_dir').then(d => { appDataBackupDir = d }).catch(() => {})
|
||||
})
|
||||
|
||||
async function handleExportAppData() {
|
||||
appDataExporting = true; appDataError = null; appDataMsg = null
|
||||
try {
|
||||
await exportAppData()
|
||||
appDataMsg = 'Backup saved.'
|
||||
setTimeout(() => appDataMsg = null, 3000)
|
||||
} catch (e: any) {
|
||||
if (String(e).includes('Cancelled')) return
|
||||
appDataError = e?.message ?? String(e)
|
||||
} finally { appDataExporting = false }
|
||||
}
|
||||
|
||||
async function handleImportAppData() {
|
||||
appDataImporting = true; appDataError = null; appDataMsg = null
|
||||
try {
|
||||
await importAppData()
|
||||
} catch (e: any) {
|
||||
if (String(e).includes('Cancelled')) { appDataImporting = false; return }
|
||||
appDataError = e?.message ?? String(e)
|
||||
appDataImporting = false
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => { untrack(() => { loadBackupList(); fetchStorage() }) })
|
||||
$effect(() => { return () => stopRestorePoll() })
|
||||
</script>
|
||||
|
||||
<div class="s-panel">
|
||||
|
||||
{#if migrateFrom && !isExternalServer}
|
||||
<div class="s-migrate-banner">
|
||||
<div class="s-migrate-body">
|
||||
<span class="s-migrate-title">Manga found at previous path — move to new location?</span>
|
||||
<span class="s-migrate-paths">{migrateFrom} → {migrateTo}</span>
|
||||
{#if migrateProgress && migrateProgress.total > 0}
|
||||
<div class="s-migrate-bar"><div class="s-migrate-fill" style="width:{Math.round((migrateProgress.done/migrateProgress.total)*100)}%"></div></div>
|
||||
<span class="s-migrate-paths">{migrateProgress.current} · {migrateProgress.done} / {migrateProgress.total}</span>
|
||||
{/if}
|
||||
{#if migrateError}<span class="s-desc" style="color:var(--color-error)">{migrateError}</span>{/if}
|
||||
</div>
|
||||
<div class="s-migrate-actions">
|
||||
<button class="s-btn s-btn-accent" onclick={startMigration} disabled={migrating}>
|
||||
{migrating ? (migrateProgress ? `Moving… ${migrateProgress.done}/${migrateProgress.total}` : 'Starting…') : 'Move files'}
|
||||
</button>
|
||||
<button class="s-btn" onclick={dismissMigration} disabled={migrating}>Skip</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">
|
||||
Disk Usage
|
||||
<button class="s-btn" onclick={fetchStorage} disabled={storageLoading}>{storageLoading ? '…' : '↻'}</button>
|
||||
</p>
|
||||
<div class="s-section-body">
|
||||
{#if storageLoading}
|
||||
<p class="s-empty">Reading filesystem…</p>
|
||||
{:else if storageError}
|
||||
<p class="s-empty" style="color:var(--color-error)">{storageError}</p>
|
||||
{:else if isExternalServer}
|
||||
<p class="s-empty">Disk usage is unavailable for external servers — filesystem access requires a local connection.</p>
|
||||
{:else if multiStorageInfos.length > 0}
|
||||
{#each multiStorageInfos as info}
|
||||
{@const limitGb = settingsState.settings.storageLimitGb ?? null}
|
||||
{@const limitBytes = limitGb !== null ? limitGb * 1024 ** 3 : null}
|
||||
{@const available = info.manga_bytes + info.free_bytes}
|
||||
{@const cap = limitBytes !== null ? Math.min(limitBytes, available) : available}
|
||||
{@const pct = cap > 0 ? Math.min(100, (info.manga_bytes / cap) * 100) : 0}
|
||||
<div class="s-storage-wrap">
|
||||
<div class="s-storage-header">
|
||||
<span class="s-storage-label">{info.label}</span>
|
||||
<span class="s-storage-used">{fmtBytes(info.manga_bytes)} of {fmtBytes(cap)}</span>
|
||||
</div>
|
||||
<div class="s-storage-bar">
|
||||
<div class="s-storage-fill" class:critical={pct > 90} class:warn={pct > 75 && pct <= 90} style="width:{pct}%"></div>
|
||||
</div>
|
||||
<div class="s-storage-footer">
|
||||
<span>{info.path}</span>
|
||||
<span>{fmtBytes(info.free_bytes)} free</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<p class="s-empty">No download path configured.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Downloads Path</p>
|
||||
<div class="s-section-body">
|
||||
{#if isExternalServer}
|
||||
<div class="s-row">
|
||||
<span class="s-desc">Connected to an external server. The path below is read from the server — changes here will update the server's config directly.</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="s-row" style="gap:var(--sp-2)">
|
||||
<input class="s-input full" class:error={!!pathsFieldError.dl}
|
||||
bind:value={downloadsPathInput}
|
||||
placeholder={isExternalServer ? 'Server default' : (defaultDownloadsPath || 'Default location')}
|
||||
spellcheck="false"
|
||||
onkeydown={(e) => e.key === 'Enter' && savePaths()}
|
||||
oninput={() => { pathsFieldError = { ...pathsFieldError, dl: undefined } }} />
|
||||
{#if !isExternalServer}
|
||||
<button class="s-btn" onclick={browseDownloadsFolder}>Browse</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
{#if pathsFieldError.dl}
|
||||
<span class="s-desc" style="color:var(--color-error)">{pathsFieldError.dl}</span>
|
||||
{/if}
|
||||
{#if pathsError}
|
||||
<span class="s-desc" style="color:var(--color-error)">{pathsError}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="s-btn-row">
|
||||
{#if pathsFieldError.dl && !isExternalServer}
|
||||
<button class="s-btn" onclick={async () => {
|
||||
try { await createDirectory(downloadsPathInput.trim()); pathsFieldError = { ...pathsFieldError, dl: undefined } }
|
||||
catch (e: any) { pathsFieldError = { ...pathsFieldError, dl: e?.message ?? 'Failed' } }
|
||||
}}>Create</button>
|
||||
{/if}
|
||||
{#if downloadsPathInput.trim() !== confirmedDownloadsPath}
|
||||
<button class="s-btn s-btn-accent" onclick={savePaths} disabled={pathsSaving}>
|
||||
{pathsSaved ? 'Saved ✓' : pathsSaving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Storage Limit</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Warn when limit is reached</span>
|
||||
<span class="s-desc">{settingsState.settings.storageLimitGb === null ? 'No limit set' : `Warn above ${settingsState.settings.storageLimitGb} GB`}</span>
|
||||
</div>
|
||||
{#if settingsState.settings.storageLimitGb === null}
|
||||
<button class="s-btn" onclick={() => updateSettings({ storageLimitGb: 10 })}>Set limit</button>
|
||||
{:else}
|
||||
<div class="s-stepper">
|
||||
<button class="s-step-btn" onclick={() => updateSettings({ storageLimitGb: Math.max(1, (settingsState.settings.storageLimitGb ?? 10) - 1) })} disabled={(settingsState.settings.storageLimitGb ?? 10) <= 1}>−</button>
|
||||
<input type="number" min="1" step="1" class="s-slider-val" style="width:52px"
|
||||
value={settingsState.settings.storageLimitGb}
|
||||
oninput={(e) => { const n = parseFloat(e.currentTarget.value); if (!isNaN(n) && n > 0) updateSettings({ storageLimitGb: n }) }} />
|
||||
<span class="s-slider-unit">GB</span>
|
||||
<button class="s-step-btn" onclick={() => updateSettings({ storageLimitGb: (settingsState.settings.storageLimitGb ?? 10) + 1 })}>+</button>
|
||||
<button class="s-btn-icon" title="Remove limit" onclick={() => updateSettings({ storageLimitGb: null })}>↺</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<button class="s-collapsible-trigger" onclick={() => advStorageOpen = !advStorageOpen}>
|
||||
<span class="s-label">Advanced</span>
|
||||
<svg class="s-collapsible-caret" class:open={advStorageOpen} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||
</button>
|
||||
{#if advStorageOpen}
|
||||
<div class="s-collapsible-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Local source path</span>
|
||||
<span class="s-desc">Read manga already on disk without an extension. Leave blank if unused.</span>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px">
|
||||
<div class="s-btn-row">
|
||||
<input class="s-input mono" class:error={!!pathsFieldError.loc}
|
||||
bind:value={localSourcePathInput} placeholder="Optional" spellcheck="false"
|
||||
onkeydown={(e) => e.key === 'Enter' && savePaths()}
|
||||
oninput={() => { pathsFieldError = { ...pathsFieldError, loc: undefined } }} />
|
||||
{#if !isExternalServer}
|
||||
<button class="s-btn" onclick={browseLocalSourceFolder}>Browse</button>
|
||||
{/if}
|
||||
{#if pathsFieldError.loc && !isExternalServer}
|
||||
<button class="s-btn" onclick={async () => {
|
||||
try { await createDirectory(localSourcePathInput.trim()); pathsFieldError = { ...pathsFieldError, loc: undefined } }
|
||||
catch (e: any) { pathsFieldError = { ...pathsFieldError, loc: e?.message ?? 'Failed' } }
|
||||
}}>Create</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if pathsFieldError.loc}<span class="s-desc" style="color:var(--color-error)">{pathsFieldError.loc}</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#each extraScanDirs as dir}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label mono" style="font-family:monospace;font-size:var(--text-xs)">{dir}</span>
|
||||
<span class="s-desc">Extra scan directory</span>
|
||||
</div>
|
||||
<button class="s-btn s-btn-danger" onclick={() => removeExtraScanDir(dir)}>Remove</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Additional scan path</span>
|
||||
<span class="s-desc">Include an extra directory in disk usage readings</span>
|
||||
</div>
|
||||
<div class="s-btn-row">
|
||||
<input class="s-input mono" bind:value={newScanDir} placeholder="/path/to/dir" spellcheck="false"
|
||||
onkeydown={(e) => e.key === 'Enter' && addExtraScanDir()} />
|
||||
{#if !isExternalServer}
|
||||
<button class="s-btn" onclick={browseExtraScanDir}>Browse</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<button class="s-collapsible-trigger" onclick={() => backupSectionOpen = !backupSectionOpen}>
|
||||
<span class="s-label">Backup</span>
|
||||
<svg class="s-collapsible-caret" class:open={backupSectionOpen} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||
</button>
|
||||
{#if backupSectionOpen}
|
||||
<div class="s-collapsible-body">
|
||||
|
||||
<p class="s-subsection-title">Library backup</p>
|
||||
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Create backup</span>
|
||||
<span class="s-desc">Snapshot your library, categories, and tracker links</span>
|
||||
</div>
|
||||
<button class="s-btn s-btn-accent" onclick={createBackup} disabled={backupLoading}>
|
||||
{backupLoading ? 'Creating…' : 'Create backup'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if backupError}
|
||||
<div class="s-banner s-banner-error">{backupError}</div>
|
||||
{/if}
|
||||
|
||||
{#if backupList.length === 0}
|
||||
<p class="s-empty">No backups yet — create one above.</p>
|
||||
{:else}
|
||||
{#each backupList as backup}
|
||||
<div class="s-folder-row">
|
||||
<ClockCounterClockwise size={14} weight="light" style="color:var(--text-faint);flex-shrink:0" />
|
||||
<span class="s-folder-name" style="font-family:monospace;font-size:var(--text-xs)">{backup.name}</span>
|
||||
<button class="s-btn-icon" onclick={() => downloadBackup(backup)} title="Download">↓</button>
|
||||
<button class="s-btn-icon danger" onclick={() => deleteBackup(backup.url)} disabled={backup.deleting} title="Delete">
|
||||
<Trash size={12} weight="light" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Restore from file</span>
|
||||
<span class="s-desc">{restoreFile ? restoreFile.name : 'Select a .tachibk file'}</span>
|
||||
</div>
|
||||
<label class="s-btn" style="cursor:pointer">
|
||||
Browse
|
||||
<input type="file" accept=".tachibk,.proto.gz" style="display:none"
|
||||
onchange={(e) => {
|
||||
const f = (e.currentTarget as HTMLInputElement).files?.[0] ?? null
|
||||
restoreFile = f; restoreStatus = null; restoreError = null; validateResult = null; validateError = null
|
||||
}} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if restoreFile}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"></div>
|
||||
<div class="s-btn-row">
|
||||
<button class="s-btn" onclick={submitValidate} disabled={validateLoading || restoreLoading}>
|
||||
{validateLoading ? 'Checking…' : 'Validate'}
|
||||
</button>
|
||||
<button class="s-btn s-btn-accent" onclick={submitRestore} disabled={restoreLoading || validateLoading}>
|
||||
{restoreLoading ? 'Restoring…' : 'Restore'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if validateError}
|
||||
<div class="s-banner s-banner-error">{validateError}</div>
|
||||
{/if}
|
||||
|
||||
{#if validateResult}
|
||||
{#if validateResult.missingSources.length === 0 && validateResult.missingTrackers.length === 0}
|
||||
<div class="s-row"><span class="s-desc" style="color:var(--color-success,#4caf50)">✓ All sources and trackers present</span></div>
|
||||
{:else}
|
||||
{#if validateResult.missingSources.length > 0}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label" style="color:var(--color-error)">Missing sources</span>
|
||||
<span class="s-desc">{validateResult.missingSources.map(s => s.name).join(', ')}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if validateResult.missingTrackers.length > 0}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label" style="color:var(--color-error)">Missing trackers</span>
|
||||
<span class="s-desc">{validateResult.missingTrackers.map(t => t.name).join(', ')}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if restoreError}
|
||||
<div class="s-banner s-banner-error">{restoreError}</div>
|
||||
{/if}
|
||||
|
||||
{#if restoreStatus}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">
|
||||
{restoreStatus.state === 'SUCCESS' ? '✓ Restore complete' :
|
||||
restoreStatus.state === 'FAILURE' ? '✗ Restore failed' : 'Restoring…'}
|
||||
</span>
|
||||
{#if restoreStatus.totalManga > 0}
|
||||
<span class="s-desc">{restoreStatus.mangaProgress} / {restoreStatus.totalManga} manga</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if restoreStatus.state !== 'SUCCESS' && restoreStatus.state !== 'FAILURE' && restoreStatus.totalManga > 0}
|
||||
<div class="s-storage-bar" style="width:160px;flex-shrink:0">
|
||||
<div class="s-storage-fill" style="width:{Math.round((restoreStatus.mangaProgress / restoreStatus.totalManga) * 100)}%"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="s-subsection-title">App data backup</p>
|
||||
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Export settings</span>
|
||||
<span class="s-desc">Save all Moku app settings to a .zip via a native save dialog.</span>
|
||||
</div>
|
||||
<button class="s-btn s-btn-accent" onclick={handleExportAppData} disabled={appDataExporting}>
|
||||
{appDataExporting ? 'Saving…' : 'Export'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Import settings</span>
|
||||
<span class="s-desc">Restore from a previously exported .zip file. Reloads the app immediately.</span>
|
||||
</div>
|
||||
<button class="s-btn" onclick={handleImportAppData} disabled={appDataImporting}>
|
||||
{appDataImporting ? 'Importing…' : 'Import'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if appDataError}
|
||||
<div class="s-banner s-banner-error">{appDataError}</div>
|
||||
{/if}
|
||||
|
||||
{#if appDataMsg}
|
||||
<div class="s-row">
|
||||
<span class="s-desc" style="color:var(--color-success,#4caf50)">{appDataMsg}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if appDataBackupDir}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Auto-backup location</span>
|
||||
<span class="s-desc">Pre-update snapshots are kept here (last 5).</span>
|
||||
</div>
|
||||
<button class="s-btn" onclick={() => invoke('open_path', { path: appDataBackupDir })}>Open folder</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<button class="s-collapsible-trigger" onclick={() => resetSectionOpen = !resetSectionOpen}>
|
||||
<span class="s-label">Reset</span>
|
||||
<svg class="s-collapsible-caret" class:open={resetSectionOpen} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||
</button>
|
||||
{#if resetSectionOpen}
|
||||
<div class="s-collapsible-body">
|
||||
{#each resetItems as item}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">{item.label}</span>
|
||||
<span class="s-desc">{item.desc}</span>
|
||||
{#if item.error}<span class="s-desc" style="color:var(--color-error)">{item.error}</span>{/if}
|
||||
</div>
|
||||
<div class="s-btn-row">
|
||||
{#if item.state === 'done'}
|
||||
<span class="s-pill on">Done</span>
|
||||
{:else if item.state === 'busy'}
|
||||
<button class="s-btn" disabled>Working…</button>
|
||||
{:else if confirming === item.key}
|
||||
<span class="s-desc" style="color:var(--text-muted)">Sure?</span>
|
||||
<button class="s-btn s-btn-danger" onclick={() => runReset(item.key)}>Confirm</button>
|
||||
<button class="s-btn" onclick={() => confirming = null}>Cancel</button>
|
||||
{:else}
|
||||
<button
|
||||
class="s-btn"
|
||||
class:s-btn-danger={item.confirm}
|
||||
onclick={() => item.confirm ? (confirming = item.key) : runReset(item.key)}
|
||||
>Reset</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,274 @@
|
||||
<script lang="ts">
|
||||
import { settingsState, updateSettings } from "$lib/state/settings.svelte";
|
||||
import { toast } from "$lib/state/notifications.svelte";
|
||||
import { getAdapter } from "$lib/request-manager";
|
||||
import { syncBackFromTracker } from "$lib/state/tracking.svelte";
|
||||
import type { Tracker, TrackRecord } from "$lib/types/index";
|
||||
|
||||
let trackers = $state<Tracker[]>([]);
|
||||
let trackersLoading = $state(false);
|
||||
let trackersError = $state<string | null>(null);
|
||||
let oauthTrackerId = $state<number | null>(null);
|
||||
let oauthCallbackInput = $state("");
|
||||
let oauthSubmitting = $state(false);
|
||||
let oauthError = $state<string | null>(null);
|
||||
let credsTrackerId = $state<number | null>(null);
|
||||
let credsUsername = $state("");
|
||||
let credsPassword = $state("");
|
||||
let credsSubmitting = $state(false);
|
||||
let credsError = $state<string | null>(null);
|
||||
let loggingOut = $state<number | null>(null);
|
||||
let syncing = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (trackers.length === 0 && !trackersLoading) loadTrackers();
|
||||
});
|
||||
|
||||
async function loadTrackers() {
|
||||
trackersLoading = true; trackersError = null;
|
||||
try {
|
||||
trackers = await getAdapter().getTrackers();
|
||||
} catch (e: any) {
|
||||
trackersError = e?.message ?? "Failed to load trackers";
|
||||
} finally { trackersLoading = false; }
|
||||
}
|
||||
|
||||
async function startOAuth(tracker: Tracker) {
|
||||
if (!tracker.authUrl) return;
|
||||
oauthTrackerId = tracker.id; oauthCallbackInput = "";
|
||||
window.open(tracker.authUrl, "_blank");
|
||||
}
|
||||
|
||||
async function submitOAuth() {
|
||||
if (!oauthTrackerId || !oauthCallbackInput.trim()) return;
|
||||
oauthSubmitting = true;
|
||||
try {
|
||||
await getAdapter().loginTrackerOAuth(oauthTrackerId, oauthCallbackInput.trim());
|
||||
await loadTrackers();
|
||||
oauthTrackerId = null; oauthCallbackInput = "";
|
||||
} catch (e: any) {
|
||||
oauthError = e?.message ?? "Login failed";
|
||||
} finally { oauthSubmitting = false; }
|
||||
}
|
||||
|
||||
function cancelOAuth() { oauthTrackerId = null; oauthCallbackInput = ""; oauthError = null; }
|
||||
|
||||
function startCredentials(tracker: Tracker) { credsTrackerId = tracker.id; credsUsername = ""; credsPassword = ""; }
|
||||
|
||||
async function submitCredentials() {
|
||||
if (!credsTrackerId || !credsUsername.trim() || !credsPassword.trim()) return;
|
||||
credsSubmitting = true;
|
||||
try {
|
||||
await getAdapter().loginTrackerCredentials(credsTrackerId, credsUsername.trim(), credsPassword.trim());
|
||||
await loadTrackers();
|
||||
credsTrackerId = null; credsUsername = ""; credsPassword = "";
|
||||
} catch (e: any) {
|
||||
credsError = e?.message ?? "Login failed";
|
||||
} finally { credsSubmitting = false; }
|
||||
}
|
||||
|
||||
function cancelCredentials() { credsTrackerId = null; credsUsername = ""; credsPassword = ""; credsError = null; }
|
||||
|
||||
async function logoutTracker(trackerId: number) {
|
||||
loggingOut = trackerId;
|
||||
try {
|
||||
await getAdapter().logoutTracker(trackerId);
|
||||
await loadTrackers();
|
||||
} catch (e: any) {
|
||||
trackersError = e?.message ?? "Logout failed";
|
||||
} finally { loggingOut = null; }
|
||||
}
|
||||
|
||||
function focusEl(node: HTMLElement) { setTimeout(() => node.focus(), 0); }
|
||||
|
||||
async function runSyncAll() {
|
||||
syncing = true;
|
||||
try {
|
||||
const adapter = getAdapter();
|
||||
const allTrackers = await adapter.getTrackersWithRecords();
|
||||
const loggedIn = allTrackers.filter((t: any) => t.isLoggedIn);
|
||||
const settings = settingsState.settings;
|
||||
let totalMarked = 0;
|
||||
|
||||
for (const tracker of loggedIn) {
|
||||
for (const record of tracker.trackRecords.nodes as TrackRecord[]) {
|
||||
if (!record.manga?.id) continue;
|
||||
const mangaId = record.manga.id;
|
||||
const chapters = await adapter.getChapters(mangaId);
|
||||
const prefs = settings.mangaPrefs?.[mangaId] ?? {};
|
||||
|
||||
const marked = await syncBackFromTracker(
|
||||
[record],
|
||||
chapters,
|
||||
{
|
||||
threshold: settings.trackerSyncBackThreshold ?? null,
|
||||
respectScanlatorFilter: settings.trackerRespectScanlatorFilter ?? true,
|
||||
chapterPrefs: prefs,
|
||||
},
|
||||
adapter.markChaptersRead.bind(adapter),
|
||||
);
|
||||
totalMarked += marked.length;
|
||||
}
|
||||
}
|
||||
|
||||
toast({ kind: "success", message: "Sync complete", detail: `${totalMarked} chapter${totalMarked !== 1 ? "s" : ""} marked read` });
|
||||
} catch (e: any) {
|
||||
toast({ kind: "error", message: "Sync failed", detail: e?.message });
|
||||
} finally { syncing = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="s-panel">
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Connected Trackers</p>
|
||||
<div class="s-section-body">
|
||||
{#if trackersError}
|
||||
<div class="s-banner s-banner-error s-banner-dismissible" onclick={() => trackersError = null} role="button" tabindex="0" onkeydown={(e) => e.key === "Enter" && (trackersError = null)}>{trackersError}</div>
|
||||
{/if}
|
||||
{#if trackersLoading}
|
||||
<p class="s-empty">Loading trackers…</p>
|
||||
{:else}
|
||||
{#each trackers as tracker}
|
||||
<div class="s-tracker-row" class:expanded={oauthTrackerId === tracker.id || credsTrackerId === tracker.id}>
|
||||
<div class="s-tracker-identity">
|
||||
<img src={tracker.icon} alt={tracker.name} class="s-tracker-logo" />
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">{tracker.name}</span>
|
||||
<div class="s-tracker-status-row">
|
||||
<span class="s-pill" class:on={tracker.isLoggedIn && !tracker.isTokenExpired}>
|
||||
{tracker.isLoggedIn ? "Connected" : "Not connected"}
|
||||
</span>
|
||||
{#if tracker.isLoggedIn && tracker.isTokenExpired}
|
||||
<span class="s-pill s-pill-warn">Token expired — reconnect</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="s-tracker-action">
|
||||
{#if tracker.isLoggedIn && tracker.isTokenExpired}
|
||||
<button class="s-btn s-btn-accent" onclick={() => tracker.authUrl ? startOAuth(tracker) : startCredentials(tracker)}>
|
||||
Reconnect
|
||||
</button>
|
||||
<button class="s-btn s-btn-danger" onclick={() => logoutTracker(tracker.id)} disabled={loggingOut === tracker.id}>
|
||||
{loggingOut === tracker.id ? "Disconnecting…" : "Disconnect"}
|
||||
</button>
|
||||
{:else if tracker.isLoggedIn}
|
||||
<button class="s-btn s-btn-danger" onclick={() => logoutTracker(tracker.id)} disabled={loggingOut === tracker.id}>
|
||||
{loggingOut === tracker.id ? "Disconnecting…" : "Disconnect"}
|
||||
</button>
|
||||
{:else if oauthTrackerId !== tracker.id && credsTrackerId !== tracker.id}
|
||||
<button class="s-btn" onclick={() => tracker.authUrl ? startOAuth(tracker) : startCredentials(tracker)}>
|
||||
{tracker.authUrl ? "Connect via browser →" : "Connect"}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if oauthTrackerId === tracker.id}
|
||||
<div class="s-tracker-expand">
|
||||
{#if oauthError}
|
||||
<div class="s-banner s-banner-error s-banner-dismissible" onclick={() => oauthError = null} role="button" tabindex="0" onkeydown={(e) => e.key === "Enter" && (oauthError = null)}>{oauthError}</div>
|
||||
{/if}
|
||||
<p class="s-oauth-hint">Browser opened {tracker.name} login — authorise then paste the callback URL below.</p>
|
||||
<input class="s-input full" placeholder="https://suwayomi.org/tracker-oauth#access_token=…"
|
||||
bind:value={oauthCallbackInput}
|
||||
onkeydown={(e) => { if (e.key === "Enter") submitOAuth(); if (e.key === "Escape") cancelOAuth(); }}
|
||||
use:focusEl />
|
||||
<div class="s-oauth-btns">
|
||||
<button class="s-btn s-btn-accent" onclick={submitOAuth} disabled={oauthSubmitting || !oauthCallbackInput.trim()}>
|
||||
{oauthSubmitting ? "Connecting…" : "Connect"}
|
||||
</button>
|
||||
<button class="s-btn" onclick={cancelOAuth}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if credsTrackerId === tracker.id}
|
||||
<div class="s-tracker-expand">
|
||||
{#if credsError}
|
||||
<div class="s-banner s-banner-error s-banner-dismissible" onclick={() => credsError = null} role="button" tabindex="0" onkeydown={(e) => e.key === "Enter" && (credsError = null)}>{credsError}</div>
|
||||
{/if}
|
||||
<input class="s-input full" placeholder="Username / Email" bind:value={credsUsername}
|
||||
onkeydown={(e) => e.key === "Escape" && cancelCredentials()} use:focusEl />
|
||||
<input class="s-input full" type="password" placeholder="Password" bind:value={credsPassword}
|
||||
onkeydown={(e) => { if (e.key === "Enter") submitCredentials(); if (e.key === "Escape") cancelCredentials(); }} />
|
||||
<div class="s-oauth-btns">
|
||||
<button class="s-btn s-btn-accent" onclick={submitCredentials} disabled={credsSubmitting || !credsUsername.trim() || !credsPassword.trim()}>
|
||||
{credsSubmitting ? "Connecting…" : "Connect"}
|
||||
</button>
|
||||
<button class="s-btn" onclick={cancelCredentials}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Sync back from tracker</p>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Enable sync back</span>
|
||||
<span class="s-desc">Mark chapters read locally based on tracker progress</span>
|
||||
</div>
|
||||
<button class="s-toggle" class:on={settings.trackerSyncBack}
|
||||
onclick={() => updateSettings({ trackerSyncBack: !settings.trackerSyncBack })}
|
||||
role="switch" aria-checked={settings.trackerSyncBack} aria-label="Enable sync back">
|
||||
<span class="s-toggle-thumb"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if settings.trackerSyncBack}
|
||||
<label class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Chapter number tolerance</span>
|
||||
<span class="s-desc">Allow source and tracker chapter numbers to differ by up to the set amount. When off, the tracker number is used as-is with no range check.</span>
|
||||
</div>
|
||||
<button role="switch" aria-checked={settings.trackerSyncBackThreshold !== null} aria-label="Chapter number tolerance" class="s-toggle" class:on={settings.trackerSyncBackThreshold !== null}
|
||||
onclick={() => updateSettings({ trackerSyncBackThreshold: settings.trackerSyncBackThreshold !== null ? null : 20 })}>
|
||||
<span class="s-toggle-thumb"></span>
|
||||
</button>
|
||||
</label>
|
||||
{#if settings.trackerSyncBackThreshold !== null}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Tolerance</span><span class="s-desc">Max chapter number difference allowed (1–20)</span></div>
|
||||
<div class="s-stepper">
|
||||
<button class="s-step-btn" onclick={() => updateSettings({ trackerSyncBackThreshold: Math.max(1, (settings.trackerSyncBackThreshold ?? 20) - 1) })}>−</button>
|
||||
<span class="s-step-val">{settings.trackerSyncBackThreshold}</span>
|
||||
<button class="s-step-btn" onclick={() => updateSettings({ trackerSyncBackThreshold: Math.min(20, (settings.trackerSyncBackThreshold ?? 20) + 1) })}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Respect scanlator filter</span>
|
||||
<span class="s-desc">Only mark chapters matching the series' active scanlator filter</span>
|
||||
</div>
|
||||
<button class="s-toggle" class:on={settings.trackerRespectScanlatorFilter}
|
||||
onclick={() => updateSettings({ trackerRespectScanlatorFilter: !settings.trackerRespectScanlatorFilter })}
|
||||
role="switch" aria-checked={settings.trackerRespectScanlatorFilter} aria-label="Respect scanlator filter">
|
||||
<span class="s-toggle-thumb"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Sync now</span>
|
||||
<span class="s-desc">Apply tracker progress to all linked manga in your library</span>
|
||||
</div>
|
||||
<button class="s-btn" onclick={runSyncAll} disabled={syncing}>
|
||||
{syncing ? "Syncing…" : "Sync all"}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.s-tracker-status-row { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
|
||||
.s-pill-warn { background: color-mix(in srgb, var(--color-warn, #c97c2b) 15%, transparent); color: var(--color-warn, #c97c2b); border-color: color-mix(in srgb, var(--color-warn, #c97c2b) 35%, transparent); }
|
||||
.s-banner-dismissible { cursor: pointer; max-height: 8rem; overflow-y: auto; }
|
||||
.s-banner-dismissible:hover { opacity: 0.85; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user