Chore: Port over Settings (Barely Works)

This commit is contained in:
Youwes09
2026-05-24 20:31:46 -05:00
parent ae5d9748c7
commit d9a9427e3b
87 changed files with 8821 additions and 615 deletions
+3
View File
@@ -29,7 +29,10 @@
}, },
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.0.0", "@tauri-apps/api": "^2.0.0",
"@tauri-apps/plugin-http": "^2.5.9",
"@tauri-apps/plugin-os": "^2.3.2", "@tauri-apps/plugin-os": "^2.3.2",
"@tauri-apps/plugin-shell": "^2.3.5",
"@tauri-apps/plugin-store": "^2.4.3",
"phosphor-svelte": "^3.1.0" "phosphor-svelte": "^3.1.0"
} }
} }
+30
View File
@@ -11,9 +11,18 @@ importers:
'@tauri-apps/api': '@tauri-apps/api':
specifier: ^2.0.0 specifier: ^2.0.0
version: 2.11.0 version: 2.11.0
'@tauri-apps/plugin-http':
specifier: ^2.5.9
version: 2.5.9
'@tauri-apps/plugin-os': '@tauri-apps/plugin-os':
specifier: ^2.3.2 specifier: ^2.3.2
version: 2.3.2 version: 2.3.2
'@tauri-apps/plugin-shell':
specifier: ^2.3.5
version: 2.3.5
'@tauri-apps/plugin-store':
specifier: ^2.4.3
version: 2.4.3
phosphor-svelte: phosphor-svelte:
specifier: ^3.1.0 specifier: ^3.1.0
version: 3.1.0(svelte@5.55.5(@typescript-eslint/types@8.57.1))(vite@8.0.10) version: 3.1.0(svelte@5.55.5(@typescript-eslint/types@8.57.1))(vite@8.0.10)
@@ -477,9 +486,18 @@ packages:
engines: {node: '>= 10'} engines: {node: '>= 10'}
hasBin: true hasBin: true
'@tauri-apps/plugin-http@2.5.9':
resolution: {integrity: sha512-lCiY0+vs4HvIUSvZrBs8TC3TiCB0MOPRmiUjTq4prW7SlcJE2jdLeT6KBsJrT9Tlplufl7W1pY6SFAO3gCWxDA==}
'@tauri-apps/plugin-os@2.3.2': '@tauri-apps/plugin-os@2.3.2':
resolution: {integrity: sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A==} resolution: {integrity: sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A==}
'@tauri-apps/plugin-shell@2.3.5':
resolution: {integrity: sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==}
'@tauri-apps/plugin-store@2.4.3':
resolution: {integrity: sha512-9LWPj9yMphRi9czEtUv87XHbl1b6xgd9EXpPrUnq6nG7+nbtoF84d4Kwz9xhAv/Hf30sr58pq7EOlyI936y8qw==}
'@tybys/wasm-util@0.10.1': '@tybys/wasm-util@0.10.1':
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
@@ -1145,10 +1163,22 @@ snapshots:
'@tauri-apps/cli-win32-ia32-msvc': 2.11.2 '@tauri-apps/cli-win32-ia32-msvc': 2.11.2
'@tauri-apps/cli-win32-x64-msvc': 2.11.2 '@tauri-apps/cli-win32-x64-msvc': 2.11.2
'@tauri-apps/plugin-http@2.5.9':
dependencies:
'@tauri-apps/api': 2.11.0
'@tauri-apps/plugin-os@2.3.2': '@tauri-apps/plugin-os@2.3.2':
dependencies: dependencies:
'@tauri-apps/api': 2.11.0 '@tauri-apps/api': 2.11.0
'@tauri-apps/plugin-shell@2.3.5':
dependencies:
'@tauri-apps/api': 2.11.0
'@tauri-apps/plugin-store@2.4.3':
dependencies:
'@tauri-apps/api': 2.11.0
'@tybys/wasm-util@0.10.1': '@tybys/wasm-util@0.10.1':
dependencies: dependencies:
tslib: 2.8.1 tslib: 2.8.1
+2
View File
@@ -1,3 +1,5 @@
@import '$lib/components/settings/Settings.css';
:root { :root {
--bg-void: #080808; --bg-void: #080808;
--bg-base: #0c0c0c; --bg-base: #0c0c0c;
@@ -1,35 +1,10 @@
<script lang="ts"> <script lang="ts">
import logoUrl from '$lib/assets/moku-icon-splash.svg' import logoUrl from '$lib/assets/moku-icon-splash.svg'
import { appState } from '$lib/state/app.svelte' import { appState } from '$lib/state/app.svelte'
import { loginUI, loginBasic, configureAuth } from '$lib/core/auth' import { boot, submitLogin, bypassBoot } from '$lib/state/boot.svelte'
let loginUser = $state('')
let loginPass = $state('')
let loginBusy = $state(false)
let loginError = $state<string | null>(null)
async function handleLogin() {
if (!loginUser.trim() || !loginPass.trim()) return
loginBusy = true
loginError = null
try {
if (appState.authMode === 'UI_LOGIN') {
await loginUI(loginUser.trim(), loginPass.trim())
} else {
await loginBasic(loginUser.trim(), loginPass.trim())
}
appState.authenticated = true
appState.status = 'ready'
} catch (e) {
loginError = e instanceof Error ? e.message : String(e)
} finally {
loginBusy = false
}
}
function handleBypass() { function handleBypass() {
appState.authenticated = false bypassBoot(appState.authMode, boot.loginUser)
appState.status = 'ready'
} }
</script> </script>
@@ -43,8 +18,8 @@
</span> </span>
<p class="host">{appState.serverUrl || 'localhost:4567'}</p> <p class="host">{appState.serverUrl || 'localhost:4567'}</p>
{#if loginError} {#if boot.loginError}
<p class="error">{loginError}</p> <p class="error">{boot.loginError}</p>
{/if} {/if}
<div class="fields"> <div class="fields">
@@ -52,28 +27,28 @@
class="input" class="input"
type="text" type="text"
placeholder="Username" placeholder="Username"
bind:value={loginUser} bind:value={boot.loginUser}
disabled={loginBusy} disabled={boot.loginBusy}
autocomplete="username" autocomplete="username"
onkeydown={(e) => e.key === 'Enter' && handleLogin()} onkeydown={(e) => e.key === 'Enter' && submitLogin()}
/> />
<input <input
class="input" class="input"
type="password" type="password"
placeholder="Password" placeholder="Password"
bind:value={loginPass} bind:value={boot.loginPass}
disabled={loginBusy} disabled={boot.loginBusy}
autocomplete="current-password" autocomplete="current-password"
onkeydown={(e) => e.key === 'Enter' && handleLogin()} onkeydown={(e) => e.key === 'Enter' && submitLogin()}
/> />
</div> </div>
<button <button
class="btn" class="btn"
onclick={handleLogin} onclick={submitLogin}
disabled={loginBusy || !loginUser.trim() || !loginPass.trim()} disabled={boot.loginBusy || !boot.loginUser.trim() || !boot.loginPass.trim()}
> >
{loginBusy ? 'Signing in…' : 'Sign in'} {boot.loginBusy ? 'Signing in…' : 'Sign in'}
</button> </button>
<button class="btn btn--ghost" onclick={handleBypass}>Skip</button> <button class="btn btn--ghost" onclick={handleBypass}>Skip</button>
</div> </div>
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores' import { page } from '$app/stores'
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import { app } from '$lib/state/app.svelte'
import { import {
House, Books, MagnifyingGlass, ClockCounterClockwise, House, Books, MagnifyingGlass, ClockCounterClockwise,
DownloadSimple, PuzzlePiece, GearSix, ChartLineUp, DownloadSimple, PuzzlePiece, GearSix, ChartLineUp,
@@ -51,7 +52,7 @@
</nav> </nav>
<div class="bottom"> <div class="bottom">
<button class="settings-btn" onclick={() => goto('/settings')} title="Settings"> <button class="settings-btn" onclick={() => app.setSettingsOpen(true)} title="Settings">
<GearSix size={18} weight="light" /> <GearSix size={18} weight="light" />
</button> </button>
</div> </div>
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte' import { onMount } from 'svelte'
import logoUrl from '$lib/assets/moku-icon-splash.svg' import logoUrl from '$lib/assets/moku-icon-splash.svg'
import { mountCardCanvas, ringGeometry, animateRingProgress } from '$lib/ui/chrome/splashCanvas' import { mountCardCanvas, ringGeometry, animateRingProgress } from '$lib/components/chrome/splashCanvas'
interface Props { interface Props {
mode?: 'loading' | 'idle' mode?: 'loading' | 'idle'
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte' import { onMount } from 'svelte'
import { detectOs } from '$lib/ui/chrome/titlebarOs' import { detectOs } from '$lib/components/chrome/titlebarOs'
import type { OsKind } from '$lib/ui/chrome/titlebarOs' import type { OsKind } from '$lib/components/chrome/titlebarOs'
let { onClose }: { onClose: () => void } = $props() let { onClose }: { onClose: () => void } = $props()
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Play, ArrowRight, BookOpen, Clock } from 'phosphor-svelte' import { Play, ArrowRight, BookOpen, Clock } from 'phosphor-svelte'
import { timeAgo } from '$lib/ui/home/homeHelpers' import { timeAgo } from '$lib/components/home/homeHelpers'
import type { Manga } from '$lib/types' import type { Manga } from '$lib/types'
import type { HistoryEntry } from '$lib/state/home.svelte' import type { HistoryEntry } from '$lib/state/home.svelte'
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Play, ArrowRight, ArrowLeft, BookOpen, Clock, ListBullets, PushPin, X as XIcon } from 'phosphor-svelte' import { Play, ArrowRight, ArrowLeft, BookOpen, Clock, ListBullets, PushPin, X as XIcon } from 'phosphor-svelte'
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import { timeAgo } from '$lib/ui/home/homeHelpers' import { timeAgo } from '$lib/components/home/homeHelpers'
import type { Manga } from '$lib/types' import type { Manga } from '$lib/types'
import type { Chapter } from '$lib/types' import type { Chapter } from '$lib/types'
import type { HistoryEntry } from '$lib/state/home.svelte' import type { HistoryEntry } from '$lib/state/home.svelte'
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Fire, BookOpen, Clock, TrendUp, Bell, CalendarBlank } from 'phosphor-svelte' import { Fire, BookOpen, Clock, TrendUp, Bell, CalendarBlank } from 'phosphor-svelte'
import { formatReadTime } from '$lib/ui/home/homeHelpers' import { formatReadTime } from '$lib/components/home/homeHelpers'
import type { ReadingStats } from '$lib/state/home.svelte' import type { ReadingStats } from '$lib/state/home.svelte'
let { let {
File diff suppressed because it is too large Load Diff
+190
View File
@@ -0,0 +1,190 @@
<script lang="ts">
import { tick } from 'svelte'
import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Wrench, PaintBrush, ListChecks, Lock, ShieldCheck, Robot } from 'phosphor-svelte'
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
import { eventToKeybind } from '$lib/core/keybinds/keybindEngine'
import type { Keybinds } from '$lib/core/keybinds/defaultBinds'
import GeneralSettings from './sections/GeneralSettings.svelte'
import AppearanceSettings from './sections/AppearanceSettings.svelte'
import ReaderSettings from './sections/ReaderSettings.svelte'
import LibrarySettings from './sections/LibrarySettings.svelte'
import AutomationSettings from './sections/AutomationSettings.svelte'
import PerformanceSettings from './sections/PerformanceSettings.svelte'
import KeybindsSettings from './sections/KeybindsSettings.svelte'
import StorageSettings from './sections/StorageSettings.svelte'
import FoldersSettings from './sections/FoldersSettings.svelte'
import TrackingSettings from './sections/TrackingSettings.svelte'
import SecuritySettings from './sections/SecuritySettings.svelte'
import ContentSettings from './sections/ContentSettings.svelte'
import AboutSettings from './sections/AboutSettings.svelte'
import DevtoolsSettings from './sections/DevToolsSettings.svelte'
interface Props { onclose?: () => void; onOpenThemeEditor?: (id?: string | null) => void }
let { onclose, onOpenThemeEditor }: Props = $props()
type Tab = 'general'|'appearance'|'reader'|'library'|'automation'|'performance'|'keybinds'|'storage'|'folders'|'tracking'|'security'|'content'|'about'|'devtools'
const TABS: { id: Tab; label: string; icon: any }[] = [
{ id: 'general', label: 'General', icon: Gear },
{ id: 'appearance', label: 'Appearance', icon: PaintBrush },
{ id: 'reader', label: 'Reader', icon: Book },
{ id: 'library', label: 'Library', icon: Image },
{ id: 'automation', label: 'Automation', icon: Robot },
{ id: 'performance', label: 'Performance', icon: Sliders },
{ id: 'keybinds', label: 'Keybinds', icon: Keyboard },
{ id: 'storage', label: 'Storage', icon: HardDrives },
{ id: 'folders', label: 'Folders', icon: FolderSimple },
{ id: 'tracking', label: 'Tracking', icon: ListChecks },
{ id: 'security', label: 'Security', icon: Lock },
{ id: 'content', label: 'Content', icon: ShieldCheck },
{ id: 'about', label: 'About', icon: Info },
{ id: 'devtools', label: 'Dev Tools', icon: Wrench },
]
const anims = $derived(settingsState.settings.qolAnimations ?? true)
let tab: Tab = $state('general')
let prevTabIndex = $state(0)
let tabSlideDir = $state<'up'|'down'>('down')
let tabIconKey = $state(0)
let contentBodyEl: HTMLDivElement
$effect(() => { tab; tick().then(() => contentBodyEl?.scrollTo({ top: 0 })) })
function setTab(id: Tab) {
if (anims) {
const next = TABS.findIndex(t => t.id === id)
tabSlideDir = next > prevTabIndex ? 'down' : 'up'
prevTabIndex = next
tabIconKey++
}
tab = id
}
function close() { onclose?.() }
let listeningKey: keyof Keybinds | null = $state(null)
$effect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape' && !listeningKey) { e.stopPropagation(); close() } }
window.addEventListener('keydown', onKey, true)
return () => window.removeEventListener('keydown', onKey, true)
})
$effect(() => {
if (!listeningKey) return
const capture = (e: KeyboardEvent) => {
e.preventDefault(); e.stopPropagation()
const bind = eventToKeybind(e)
if (!bind) return
updateSettings({ keybinds: { ...settingsState.settings.keybinds, [listeningKey!]: bind } })
listeningKey = null
}
window.addEventListener('keydown', capture, true)
return () => window.removeEventListener('keydown', capture, true)
})
let selectOpen: string | null = $state(null)
let closingSelect: string | null = $state(null)
const CLOSE_ANIM_MS = 120
function closeSelect() {
if (!selectOpen) return
closingSelect = selectOpen
selectOpen = null
setTimeout(() => { closingSelect = null }, CLOSE_ANIM_MS)
}
function toggleSelect(id: string) {
if (selectOpen === id) { closeSelect() }
else { closingSelect = null; selectOpen = id }
}
$effect(() => {
const handler = (e: MouseEvent) => {
if (!selectOpen) return
const t = e.target as HTMLElement
if (t.closest('.s-select') || t.closest('.s-select-menu')) return
closeSelect()
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
})
</script>
<div class="s-backdrop" role="presentation" tabindex="-1"
onclick={(e) => { if (e.target === e.currentTarget) close() }}
onkeydown={(e) => { if (e.key === 'Escape') { e.stopPropagation(); close() } }}>
<div class="s-modal" role="dialog" aria-label="Settings">
<div class="s-sidebar">
<p class="s-sidebar-title">Settings</p>
<nav>
{#each TABS as t}
<button class="s-nav-item" class:active={tab === t.id} class:anims onclick={() => setTab(t.id)}>
<span class="s-nav-icon"
class:slide-down={anims && tab === t.id && tabSlideDir === 'down'}
class:slide-up={anims && tab === t.id && tabSlideDir === 'up'}>
{#key anims && tab === t.id ? tabIconKey : 0}
<t.icon size={14} weight={tab === t.id ? 'regular' : 'light'} />
{/key}
</span>
<span>{t.label}</span>
</button>
{/each}
</nav>
</div>
<div class="s-content">
<div class="s-content-header">
<div class="s-content-header-left">
<span class="s-header-icon"
class:slide-down={anims && tabSlideDir === 'down'}
class:slide-up={anims && tabSlideDir === 'up'}>
{#key tabIconKey}
{#each TABS as t}
{#if t.id === tab}
<t.icon size={13} weight="light" />
{/if}
{/each}
{/key}
</span>
<p class="s-content-title">{TABS.find(t => t.id === tab)?.label}</p>
</div>
<button class="s-close-btn" aria-label="Close settings" onclick={close}><X size={15} weight="light" /></button>
</div>
<div class="s-content-body" bind:this={contentBodyEl}>
{#if tab === 'general'}
<GeneralSettings {selectOpen} {closingSelect} {toggleSelect} {anims} />
{:else if tab === 'appearance'}
<AppearanceSettings {selectOpen} {closingSelect} {toggleSelect} {anims} {onOpenThemeEditor} />
{:else if tab === 'reader'}
<ReaderSettings {selectOpen} {closingSelect} {toggleSelect} {anims} />
{:else if tab === 'library'}
<LibrarySettings {selectOpen} {closingSelect} {toggleSelect} {anims} />
{:else if tab === 'automation'}
<AutomationSettings />
{:else if tab === 'performance'}
<PerformanceSettings />
{:else if tab === 'keybinds'}
<KeybindsSettings bind:listeningKey />
{:else if tab === 'storage'}
<StorageSettings {selectOpen} {closingSelect} {toggleSelect} />
{:else if tab === 'folders'}
<FoldersSettings />
{:else if tab === 'tracking'}
<TrackingSettings />
{:else if tab === 'security'}
<SecuritySettings {selectOpen} {toggleSelect} />
{:else if tab === 'content'}
<ContentSettings />
{:else if tab === 'about'}
<AboutSettings />
{:else if tab === 'devtools'}
<DevtoolsSettings />
{/if}
</div>
</div>
</div>
</div>
@@ -0,0 +1,508 @@
<script lang="ts">
import { X, FloppyDisk, UploadSimple, DownloadSimple, ArrowLeft, Trash } from "phosphor-svelte";
import { settingsState, updateSettings } from '$lib/state/settings.svelte';
import type { CustomTheme, ThemeTokens } from "$lib/types/settings";
import { DEFAULT_THEME_TOKENS } from "$lib/types/settings";
interface Props {
editingId?: string | null;
onClose: () => void;
}
let { editingId = $bindable(null), onClose }: Props = $props();
const TOKEN_GROUPS: { label: string; tokens: (keyof ThemeTokens)[] }[] = [
{ label: "Backgrounds", tokens: ["bg-void", "bg-base", "bg-surface", "bg-raised", "bg-overlay", "bg-subtle"] },
{ label: "Borders", tokens: ["border-dim", "border-base", "border-strong", "border-focus"] },
{ label: "Text", tokens: ["text-primary", "text-secondary", "text-muted", "text-faint", "text-disabled"] },
{ label: "Accent", tokens: ["accent", "accent-dim", "accent-muted", "accent-fg", "accent-bright"] },
{ label: "Semantic", tokens: ["color-error", "color-error-bg", "color-success", "color-info", "color-info-bg"] },
];
const TOKEN_LABELS: Record<keyof ThemeTokens, string> = {
"bg-void": "Void (deepest bg)",
"bg-base": "Base",
"bg-surface": "Surface",
"bg-raised": "Raised",
"bg-overlay": "Overlay",
"bg-subtle": "Subtle",
"border-dim": "Dim border",
"border-base": "Base border",
"border-strong": "Strong border",
"border-focus": "Focus ring",
"text-primary": "Primary text",
"text-secondary": "Secondary text",
"text-muted": "Muted text",
"text-faint": "Faint text",
"text-disabled": "Disabled text",
"accent": "Accent",
"accent-dim": "Accent dim",
"accent-muted": "Accent muted",
"accent-fg": "Accent foreground",
"accent-bright": "Accent bright",
"color-error": "Error",
"color-error-bg": "Error background",
"color-success": "Success",
"color-info": "Info",
"color-info-bg": "Info background",
};
function loadInitial(): { name: string; tokens: ThemeTokens } {
if (editingId) {
const existing = settingsState.settings.customThemes.find(t => t.id === editingId);
if (existing) return { name: existing.name, tokens: { ...existing.tokens } };
}
return { name: "My Theme", tokens: { ...DEFAULT_THEME_TOKENS } };
}
const initial = loadInitial();
let themeName: string = $state(initial.name);
let tokens: ThemeTokens = $state(initial.tokens);
let saveStatus: "idle" | "saved" = $state("idle");
let importError: string | null = $state(null);
function toCssVars(t: ThemeTokens): string {
return Object.entries(t).map(([k, v]) => `--${k}: ${v};`).join(" ");
}
function saveCustomTheme(theme: CustomTheme) {
const existing = settingsState.settings.customThemes.findIndex(t => t.id === theme.id);
const next = [...settingsState.settings.customThemes];
if (existing >= 0) next[existing] = theme;
else next.push(theme);
updateSettings({ customThemes: next });
}
function deleteCustomTheme(id: string) {
updateSettings({ customThemes: settingsState.settings.customThemes.filter(t => t.id !== id) });
}
function handleSave() {
const name = themeName.trim() || "Untitled Theme";
const id = editingId ?? `custom:${Math.random().toString(36).slice(2, 10)}`;
saveCustomTheme({ id, name, tokens: { ...tokens } });
updateSettings({ theme: id });
editingId = id;
saveStatus = "saved";
setTimeout(() => (saveStatus = "idle"), 1800);
}
function handleDelete() {
if (!editingId) { onClose(); return; }
if (!confirm(`Delete theme "${themeName}"? This cannot be undone.`)) return;
deleteCustomTheme(editingId);
onClose();
}
async function handleExport() {
const data: CustomTheme = {
id: editingId ?? "custom:export",
name: themeName.trim() || "Untitled Theme",
tokens: { ...tokens },
};
const filename = `${data.name.replace(/[^a-z0-9]/gi, "-").toLowerCase()}-theme.json`;
const json = JSON.stringify(data, null, 2);
try {
const handle = await (window as any).showSaveFilePicker({
suggestedName: filename,
types: [{ description: "Theme JSON", accept: { "application/json": [".json"] } }],
});
const writable = await handle.createWritable();
await writable.write(json);
await writable.close();
} catch (e: any) {
if (e?.name === "AbortError") return;
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
}
}
function handleImport() {
const inp = document.createElement("input");
inp.type = "file";
inp.accept = ".json";
inp.onchange = async () => {
const file = inp.files?.[0];
if (!file) return;
try {
const text = await file.text();
const data = JSON.parse(text);
if (!data.tokens || typeof data.tokens !== "object") throw new Error("Invalid theme file — missing tokens");
if (typeof data.name === "string") themeName = data.name;
tokens = { ...DEFAULT_THEME_TOKENS, ...data.tokens };
importError = null;
} catch (e: any) {
importError = e.message ?? "Could not parse theme file";
setTimeout(() => (importError = null), 3000);
}
};
inp.click();
}
function resetToDefaults() { tokens = { ...DEFAULT_THEME_TOKENS }; }
function onKey(e: KeyboardEvent) { if (e.key === "Escape") onClose(); }
</script>
<svelte:window onkeydown={onKey} />
<div class="backdrop" role="button" tabindex="-1" aria-label="Close theme editor" onclick={onClose} onkeydown={(e) => e.key === "Escape" && onClose()}>
<div class="shell" role="dialog" aria-label="Theme editor" tabindex="0" style={toCssVars(tokens)} onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
<header class="header">
<div class="header-left">
<button class="icon-btn" onclick={onClose} title="Close editor">
<ArrowLeft size={14} weight="bold" />
</button>
<input bind:value={themeName} class="name-input" placeholder="Theme name" maxlength={40} spellcheck={false} />
</div>
<div class="header-actions">
{#if importError}
<span class="import-err">{importError}</span>
{/if}
<button class="action-btn" onclick={handleImport} title="Import from JSON">
<UploadSimple size={13} /><span>Import</span>
</button>
<button class="action-btn" onclick={handleExport} title="Export as JSON">
<DownloadSimple size={13} /><span>Export</span>
</button>
<button class="action-btn ghost" onclick={resetToDefaults} title="Reset all to dark defaults">Reset</button>
{#if editingId}
<button class="action-btn danger" onclick={handleDelete} title="Delete theme">
<Trash size={13} />
</button>
{/if}
<button class="save-btn" class:saved={saveStatus === "saved"} onclick={handleSave}>
<FloppyDisk size={13} /><span>{saveStatus === "saved" ? "Saved!" : "Save Theme"}</span>
</button>
<button class="icon-btn" onclick={onClose} title="Close">
<X size={14} weight="bold" />
</button>
</div>
</header>
<div class="body">
<aside class="preview-pane">
<div class="pane-label">Live Preview</div>
<div class="preview-ui" style={toCssVars(tokens)}>
<div class="prv-sidebar">
{#each [true, false, false, false] as active}
<div class="prv-sb-dot" class:active></div>
{/each}
</div>
<div class="prv-main">
<div class="prv-titlebar">
<div class="prv-win-dots"><span></span><span></span><span></span></div>
<div class="prv-win-title">Moku</div>
</div>
<div class="prv-content">
<div class="prv-row">
<div class="prv-bar" style="width:52px;background:var(--text-secondary);opacity:0.45"></div>
<div class="prv-bar" style="width:18px;background:var(--accent)"></div>
</div>
<div class="prv-grid">
{#each Array(6) as _, i}
<div class="prv-card" class:active-card={i === 0}>
<div class="prv-cover"></div>
<div class="prv-card-line"></div>
</div>
{/each}
</div>
<div class="prv-reader"><div class="prv-page"></div></div>
<div class="prv-toast">
<div class="prv-toast-dot"></div>
<div class="prv-toast-lines">
<div class="prv-bar" style="width:80%;background:var(--text-secondary)"></div>
<div class="prv-bar" style="width:55%;background:var(--text-faint);margin-top:3px"></div>
</div>
</div>
</div>
</div>
</div>
<div class="swatches" style={toCssVars(tokens)}>
{#each ["bg-base","bg-surface","accent","accent-fg","text-primary","text-muted","color-error"] as v}
<div class="swatch" style="background:var(--{v})" title={v}></div>
{/each}
</div>
</aside>
<div class="editor-pane">
{#each TOKEN_GROUPS as group}
<div class="group">
<div class="group-label">{group.label}</div>
<div class="token-list">
{#each group.tokens as token}
<div class="token-row">
<label class="color-swatch" style="background:{tokens[token]}" title="Pick colour">
<input
type="color"
class="color-picker"
value={tokens[token].length === 7 ? tokens[token] : tokens[token].slice(0, 7)}
oninput={(e) => { tokens = { ...tokens, [token]: (e.target as HTMLInputElement).value }; }}
/>
</label>
<span class="token-name">{TOKEN_LABELS[token]}</span>
<span class="token-key">{token}</span>
<input
type="text"
class="hex-input"
value={tokens[token]}
spellcheck={false}
oninput={(e) => {
const v = (e.target as HTMLInputElement).value.trim();
if (/^#[0-9a-fA-F]{3,8}$/.test(v)) tokens = { ...tokens, [token]: v };
}}
onblur={(e) => {
const v = (e.target as HTMLInputElement).value.trim();
if (!/^#[0-9a-fA-F]{3,8}$/.test(v)) (e.target as HTMLInputElement).value = tokens[token];
}}
/>
</div>
{/each}
</div>
</div>
{/each}
</div>
</div>
</div>
</div>
<style>
.backdrop {
position: fixed; inset: 0;
background: rgba(0,0,0,0.72);
z-index: 200;
display: flex; align-items: center; justify-content: center;
animation: backdropIn 0.14s ease both;
}
@keyframes backdropIn { from { opacity: 0 } to { opacity: 1 } }
.shell {
width: calc(100% - var(--sp-12)); max-width: 1100px;
height: calc(100% - var(--sp-12)); max-height: 760px;
display: flex; flex-direction: column;
background: var(--bg-base);
border: 1px solid var(--border-base);
border-radius: var(--radius-xl);
overflow: hidden;
animation: shellIn 0.2s cubic-bezier(0.22,1,0.36,1) both;
}
@keyframes shellIn {
from { transform: translateY(10px) scale(0.99); opacity: 0 }
to { transform: translateY(0) scale(1); opacity: 1 }
}
.header {
display: flex; align-items: center; justify-content: space-between;
gap: var(--sp-3); padding: 0 var(--sp-4); height: 46px;
border-bottom: 1px solid var(--border-dim);
background: var(--bg-surface);
flex-shrink: 0;
}
.header-left { display: flex; align-items: center; gap: var(--sp-2); flex: 1; min-width: 0; }
.header-actions { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
.icon-btn {
display: flex; align-items: center; justify-content: center;
width: 26px; height: 26px;
border-radius: var(--radius-md);
color: var(--text-muted);
transition: color var(--t-base), background var(--t-base);
flex-shrink: 0;
}
.icon-btn:hover { color: var(--text-primary); background: var(--bg-overlay); }
.name-input {
flex: 1; min-width: 0;
background: none; border: none; outline: none;
font-family: var(--font-sans); font-size: var(--text-sm); font-weight: var(--weight-medium);
color: var(--text-primary);
border-bottom: 1px solid transparent;
padding: 3px 0;
transition: border-color var(--t-base);
}
.name-input:focus { border-color: var(--border-focus); }
.name-input::placeholder { color: var(--text-faint); }
.import-err {
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
color: var(--color-error); flex-shrink: 0;
}
.action-btn {
display: flex; align-items: center; gap: var(--sp-1);
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
padding: 4px var(--sp-2); border-radius: var(--radius-sm);
border: 1px solid var(--border-dim);
background: none; color: var(--text-muted);
cursor: pointer; flex-shrink: 0;
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.action-btn:hover { color: var(--text-secondary); border-color: var(--border-strong); }
.action-btn.ghost { border-color: transparent; }
.action-btn.ghost:hover { border-color: var(--border-dim); }
.action-btn.danger { color: var(--color-error); border-color: transparent; }
.action-btn.danger:hover { background: var(--color-error-bg); border-color: var(--color-error); }
.save-btn {
display: flex; align-items: center; gap: var(--sp-1);
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
padding: 5px var(--sp-3); border-radius: var(--radius-sm);
border: 1px solid var(--accent-dim);
background: var(--accent-muted); color: var(--accent-fg);
cursor: pointer; flex-shrink: 0;
transition: filter var(--t-base), background var(--t-base);
}
.save-btn:hover { filter: brightness(1.12); }
.save-btn.saved { background: var(--accent-dim); border-color: var(--accent); }
.body { flex: 1; overflow: hidden; display: flex; min-height: 0; }
.preview-pane {
width: 260px; flex-shrink: 0;
border-right: 1px solid var(--border-dim);
background: var(--bg-void);
display: flex; flex-direction: column;
padding: var(--sp-4); gap: var(--sp-3);
}
.pane-label {
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider); text-transform: uppercase;
color: var(--text-faint); flex-shrink: 0;
}
.preview-ui {
flex: 1; min-height: 0;
border-radius: var(--radius-lg); overflow: hidden;
border: 1px solid var(--border-base);
display: flex; background: var(--bg-void);
}
.prv-sidebar {
width: 34px; flex-shrink: 0;
background: var(--bg-surface);
border-right: 1px solid var(--border-dim);
display: flex; flex-direction: column;
align-items: center; padding: var(--sp-3) 0; gap: var(--sp-2);
}
.prv-sb-dot {
width: 10px; height: 10px; border-radius: 50%;
background: var(--text-faint); opacity: 0.4;
transition: background var(--t-base), opacity var(--t-base);
}
.prv-sb-dot.active { background: var(--accent); opacity: 1; }
.prv-main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.prv-titlebar {
height: 26px; flex-shrink: 0;
background: var(--bg-raised);
border-bottom: 1px solid var(--border-dim);
display: flex; align-items: center; padding: 0 var(--sp-2); gap: var(--sp-2);
}
.prv-win-dots { display: flex; gap: var(--sp-1); }
.prv-win-dots span { width: 6px; height: 6px; border-radius: 50%; background: var(--border-strong); }
.prv-win-title { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); color: var(--text-faint); }
.prv-content {
flex: 1; overflow: hidden;
padding: var(--sp-2); display: flex; flex-direction: column; gap: var(--sp-2);
background: var(--bg-base);
}
.prv-row { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
.prv-bar { height: 3px; border-radius: 2px; }
.prv-grid { display: grid; grid-template-columns: repeat(3,1fr); gap: var(--sp-1); flex-shrink: 0; }
.prv-card {
border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
background: var(--bg-raised); overflow: hidden;
transition: border-color var(--t-base);
}
.prv-card.active-card { border-color: var(--accent); }
.prv-cover { height: 34px; background: var(--bg-overlay); }
.prv-card-line { height: 3px; margin: 4px; border-radius: 2px; background: var(--text-faint); opacity: 0.5; }
.prv-reader {
flex: 1; min-height: 0;
border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
background: var(--bg-overlay);
display: flex; align-items: center; justify-content: center;
}
.prv-page { width: 68%; height: 86%; background: var(--bg-subtle); border-radius: 2px; }
.prv-toast {
flex-shrink: 0;
display: flex; align-items: center; gap: var(--sp-2);
padding: var(--sp-2);
border-radius: var(--radius-md);
background: var(--bg-overlay); border: 1px solid var(--accent-dim);
}
.prv-toast-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex-shrink: 0; }
.prv-toast-lines { flex: 1; }
.swatches { display: flex; gap: var(--sp-1); flex-wrap: wrap; flex-shrink: 0; }
.swatch { width: 22px; height: 22px; border-radius: var(--radius-sm); border: 1px solid rgba(255,255,255,0.07); flex-shrink: 0; }
.editor-pane {
flex: 1; overflow-y: auto;
padding: var(--sp-4) var(--sp-5);
display: flex; flex-direction: column; gap: var(--sp-6);
}
.group { display: flex; flex-direction: column; gap: var(--sp-1); }
.group-label {
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider); text-transform: uppercase;
color: var(--text-faint);
padding-bottom: var(--sp-2); margin-bottom: var(--sp-1);
border-bottom: 1px solid var(--border-dim);
}
.token-list { display: flex; flex-direction: column; gap: 1px; }
.token-row {
display: flex; align-items: center; gap: var(--sp-3);
padding: 5px var(--sp-2); border-radius: var(--radius-md);
transition: background var(--t-base);
}
.token-row:hover { background: var(--bg-raised); }
.color-swatch {
width: 36px; height: 18px; border-radius: var(--radius-md);
flex-shrink: 0;
border: 1px solid rgba(255,255,255,0.12);
box-shadow: 0 0 0 1px rgba(0,0,0,0.2);
cursor: pointer; position: relative; overflow: hidden; display: block;
}
.color-swatch:hover { box-shadow: 0 0 0 2px var(--border-focus); }
.color-picker {
position: absolute; inset: 0;
width: 100%; height: 100%;
opacity: 0; cursor: pointer; padding: 0; border: none;
}
.token-name { flex: 1; font-size: var(--text-xs); color: var(--text-secondary); }
.token-key {
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
color: var(--text-faint); flex-shrink: 0;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 160px;
}
.hex-input {
width: 82px; flex-shrink: 0;
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
color: var(--text-muted);
background: var(--bg-overlay);
border: 1px solid var(--border-dim);
border-radius: var(--radius-sm); padding: 3px var(--sp-2);
outline: none;
transition: border-color var(--t-base), color var(--t-base);
}
.hex-input:focus { border-color: var(--border-focus); color: var(--text-primary); }
</style>
@@ -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 &amp; 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 (120)</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>
@@ -0,0 +1,839 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import {
X, BookmarkSimple, ArrowSquareOut, Play, CircleNotch,
Books, CaretDown, FolderSimplePlus, Folder, LinkSimpleHorizontalBreak, Image,
} from "phosphor-svelte";
import Thumbnail from "$lib/components/manga/Thumbnail.svelte";
import { appState } from "$lib/state/app.svelte";
import { settings } from "$lib/state/settings.svelte";
import { requestManager } from "$lib/request-manager/index";
import { queryCache } from "$lib/core/cache/queryCache";
import { resolvedCover } from "$lib/core/cover/coverResolver";
import { autoLinkLibrary } from "$lib/core/cover/autoLink";
import { toast } from "$lib/state/notifications.svelte";
import { addBookmark } from "$lib/state/app.svelte";
import CoverPickerPanel from "$lib/components/series/CoverPickerPanel.svelte";
import SeriesLinkPanel from "$lib/components/series/SeriesLinkPanel.svelte";
import type { Manga, Chapter, Category } from "$lib/types/index";
let manga: Manga | null = $state(null);
let chapters: Chapter[] = $state([]);
let loadingDetail = $state(false);
let loadingChapters = $state(false);
let togglingLib = $state(false);
let descExpanded = $state(false);
let folderOpen = $state(false);
let newFolderName = $state("");
let creatingFolder = $state(false);
let allCategories: Category[] = $state([]);
let mangaCategories: Category[] = $state([]);
let catsLoading = $state(false);
let queueingAll = $state(false);
let fetchError: string | null = $state(null);
let folderRef: HTMLDivElement = $state() as HTMLDivElement;
let linkPickerOpen = $state(false);
let allMangaForLink: Manga[] = $state([]);
let loadingLinkList = $state(false);
let coverPickerOpen = $state(false);
let originNavPage = appState.navPage;
const linkedIds = $derived(
appState.previewManga ? (settings.mangaLinks?.[appState.previewManga.id] ?? []) : [],
);
const hasCoverOverride = $derived(
!!settings.mangaPrefs?.[appState.previewManga?.id ?? -1]?.coverUrl
);
const displayManga = $derived(manga ?? appState.previewManga);
const totalCount = $derived(chapters.length);
const readCount = $derived(chapters.filter((c) => c.isRead).length);
const unreadCount = $derived(totalCount - readCount);
const downloadedCount = $derived(chapters.filter((c) => c.isDownloaded).length);
const bookmarkCount = $derived(chapters.filter((c) => c.isBookmarked).length);
const inLibrary = $derived(manga?.inLibrary ?? appState.previewManga?.inLibrary ?? false);
const scanlators = $derived(
[...new Set(chapters.map((c) => c.scanlator).filter((s): s is string => !!s?.trim()))],
);
const uploadDates = $derived(
chapters
.map((c) => (c.uploadDate ? new Date(c.uploadDate).getTime() : null))
.filter((d): d is number => d !== null && !isNaN(d)),
);
const statusLabel = $derived(
displayManga?.status
? displayManga.status.charAt(0) + displayManga.status.slice(1).toLowerCase()
: null,
);
const assignedFolders = $derived(mangaCategories.filter((c) => c.id !== 0));
const continueChapter = $derived.by(() => {
if (!chapters.length) return null;
const asc = [...chapters];
const anyRead = asc.some((c) => c.isRead);
const bookmark = displayManga
? appState.bookmarks.find((b) => b.mangaId === displayManga!.id)
: null;
if (bookmark) {
const ch = asc.find((c) => c.id === bookmark.chapterId);
if (ch) {
const isLastChapter = asc[asc.length - 1]?.id === ch.id;
const allRead = asc.every((c) => c.isRead);
if (!(isLastChapter && allRead))
return { ch, type: "continue" as const, resumePage: bookmark.pageNumber };
}
}
const inProgress = asc.find((c) => !c.isRead && (c.lastPageRead ?? 0) > 0);
if (inProgress) return { ch: inProgress, type: "continue" as const, resumePage: inProgress.lastPageRead! };
const firstUnread = asc.find((c) => !c.isRead);
if (firstUnread) return { ch: firstUnread, type: (anyRead ? "continue" : "start") as const, resumePage: null };
return { ch: asc[0], type: "reread" as const, resumePage: null };
});
const continueLabel = $derived.by(() => {
if (!continueChapter) return "";
const { ch, type, resumePage } = continueChapter;
if (type === "reread") return "Read again";
if (type === "start") return `Start · Ch.${ch.chapterNumber}`;
return `Continue · Ch.${ch.chapterNumber}${resumePage ? ` p.${resumePage}` : ""}`;
});
let detailAbort: AbortController | null = null;
let chapterAbort: AbortController | null = null;
function close() {
detailAbort?.abort();
chapterAbort?.abort();
appState.previewManga = null;
manga = null; chapters = []; descExpanded = false;
folderOpen = false; creatingFolder = false; newFolderName = ""; fetchError = null;
}
async function openLinkPicker() {
linkPickerOpen = true;
if (allMangaForLink.length) return;
loadingLinkList = true;
requestManager.getAllManga()
.then((d) => { allMangaForLink = d; })
.catch(console.error)
.finally(() => { loadingLinkList = false; });
}
async function openCoverPicker() {
coverPickerOpen = true;
if (allMangaForLink.length) return;
loadingLinkList = true;
requestManager.getAllManga()
.then((d) => { allMangaForLink = d; })
.catch(console.error)
.finally(() => { loadingLinkList = false; });
}
$effect(() => {
const focal = appState.previewManga;
if (focal) {
originNavPage = appState.navPage;
load(focal.id);
loadCategories(focal.id);
if (settings.autoLinkOnOpen) {
if (allMangaForLink.length) {
autoLinkLibrary(focal, allMangaForLink)
.then(n => { if (n > 0) toast({ kind: "success", title: "Series linked", body: `${n} new link${n === 1 ? "" : "s"} found` }); });
} else {
loadingLinkList = true;
requestManager.getAllManga()
.then((nodes) => {
allMangaForLink = nodes;
return autoLinkLibrary(focal, nodes);
})
.then(n => { if (n > 0) toast({ kind: "success", title: "Series linked", body: `${n} new link${n === 1 ? "" : "s"} found` }); })
.catch(console.error)
.finally(() => { loadingLinkList = false; });
}
}
}
});
async function load(id: number) {
detailAbort?.abort(); chapterAbort?.abort();
const dCtrl = new AbortController(), cCtrl = new AbortController();
detailAbort = dCtrl; chapterAbort = cCtrl;
manga = appState.previewManga as Manga;
chapters = []; descExpanded = false; fetchError = null;
loadingDetail = true; loadingChapters = true;
requestManager.fetchManga(id, dCtrl.signal)
.then((fullManga) => {
if (dCtrl.signal.aborted) return;
manga = fullManga; loadingDetail = false;
})
.catch((e) => {
if (e?.name === "AbortError") return;
manga = appState.previewManga as Manga;
fetchError = "Could not load full details — showing cached data";
loadingDetail = false;
});
requestManager.getChapters(id, cCtrl.signal)
.then(async (nodes) => {
if (cCtrl.signal.aborted) return;
let sorted = [...nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
if (sorted.length === 0) {
try {
const fetched = await requestManager.fetchChapters(id, cCtrl.signal);
if (!cCtrl.signal.aborted)
sorted = [...fetched].sort((a, b) => a.sourceOrder - b.sourceOrder);
} catch (e: any) {
if (e?.name === "AbortError") return;
}
}
if (!cCtrl.signal.aborted) {
chapters = sorted;
if (sorted.length > 0) checkAndMarkCompleted(id, sorted);
}
})
.catch(() => {})
.finally(() => { if (!cCtrl.signal.aborted) loadingChapters = false; });
}
async function toggleLibrary() {
if (!manga) return;
togglingLib = true;
const next = !manga.inLibrary;
await requestManager.updateManga(manga.id, { inLibrary: next }).catch(console.error);
manga = { ...manga, inLibrary: next };
queryCache.clear(`manga:${manga.id}`);
queryCache.clear("library");
togglingLib = false;
toast({ kind: "success", title: next ? "Added to library" : "Removed from library" });
}
async function downloadAll() {
const ids = chapters.filter((c) => !c.isDownloaded && !c.isRead).map((c) => c.id);
if (!ids.length) return;
queueingAll = true;
await requestManager.enqueueChaptersDownload(ids).catch(console.error);
toast({ kind: "download", title: "Downloading", body: `${ids.length} chapters queued` });
queueingAll = false;
}
function openSeriesDetail() {
if (!displayManga) return;
appState.activeManga = displayManga;
appState.navPage = originNavPage;
close();
}
function loadCategories(mangaId: number) {
catsLoading = true;
requestManager.getCategories()
.then((cats) => {
allCategories = cats.filter((c: Category) => c.id !== 0);
mangaCategories = allCategories.filter((c: Category) => c.mangas?.nodes.some((m: Manga) => m.id === mangaId));
})
.catch(console.error)
.finally(() => { catsLoading = false; });
}
async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) {
const mangaStatus = (manga ?? displayManga)?.status;
const isOngoing = mangaStatus === "ONGOING";
if (chaps.length && !isOngoing) {
const allRead = chaps.every((c) => c.isRead);
const completed = allCategories.find((c) => c.name === "Completed");
if (completed) {
const inCompleted = mangaCategories.some((c) => c.id === completed.id);
if (allRead && !inCompleted) mangaCategories = [...mangaCategories, completed];
else if (!allRead && inCompleted) mangaCategories = mangaCategories.filter((c) => c.id !== completed.id);
}
}
}
async function toggleCategory(cat: Category) {
if (!appState.previewManga) return;
const mangaId = appState.previewManga.id;
const inCat = mangaCategories.some((c) => c.id === cat.id);
await requestManager.updateMangaCategories(mangaId, inCat ? [] : [cat.id], inCat ? [cat.id] : []).catch(console.error);
if (!inCat && !inLibrary) {
await requestManager.updateManga(mangaId, { inLibrary: true }).catch(console.error);
if (manga) manga = { ...manga, inLibrary: true };
queryCache.clear("library");
}
mangaCategories = inCat
? mangaCategories.filter((c) => c.id !== cat.id)
: [...mangaCategories, cat];
}
async function handleFolderCreate() {
const name = newFolderName.trim();
if (!name || !appState.previewManga) return;
try {
const cat = await requestManager.createCategory(name);
allCategories = [...allCategories, cat];
await requestManager.updateMangaCategories(appState.previewManga.id, [cat.id], []);
if (!inLibrary) {
await requestManager.updateManga(appState.previewManga.id, { inLibrary: true }).catch(console.error);
if (manga) manga = { ...manga, inLibrary: true };
queryCache.clear("library");
}
mangaCategories = [...mangaCategories, cat];
} catch (e) { console.error(e); }
newFolderName = ""; creatingFolder = false;
}
function handleFolderOutside(e: MouseEvent) {
if (folderRef && !folderRef.contains(e.target as Node)) {
folderOpen = false; creatingFolder = false; newFolderName = "";
}
}
$effect(() => {
if (folderOpen) {
setTimeout(() => document.addEventListener("mousedown", handleFolderOutside), 0);
return () => document.removeEventListener("mousedown", handleFolderOutside);
}
});
function onKey(e: KeyboardEvent) { if (e.key === "Escape") close(); }
onMount(() => window.addEventListener("keydown", onKey));
onDestroy(() => {
window.removeEventListener("keydown", onKey);
detailAbort?.abort();
chapterAbort?.abort();
});
function focusAction(node: HTMLElement) { node.focus(); }
</script>
{#if appState.previewManga}
<div
class="backdrop"
role="button"
tabindex="-1"
aria-label="Close preview"
onclick={(e) => { if (e.target === e.currentTarget) close(); }}
onkeydown={(e) => { if (e.key === "Escape") close(); }}
>
<div class="modal" role="dialog" aria-label="Manga preview">
<div class="cover-col">
<div class="cover-wrap">
<Thumbnail src={resolvedCover(appState.previewManga.id, appState.previewManga.thumbnailUrl)} alt={displayManga?.title} class="cover" />
{#if loadingDetail}
<div class="cover-spinner">
<CircleNotch size={18} weight="light" class="anim-spin" />
</div>
{/if}
</div>
<div class="cover-actions">
<button
class="action-btn"
class:active={inLibrary}
onclick={toggleLibrary}
disabled={togglingLib || loadingDetail}
>
<span class="action-icon">
<BookmarkSimple size={13} weight={inLibrary ? "fill" : "light"} />
</span>
<span class="action-label">
{togglingLib ? "…" : inLibrary ? "In Library" : "Add to Library"}
</span>
</button>
<button class="action-btn" onclick={openSeriesDetail}>
<span class="action-icon"><Books size={13} weight="light" /></span>
<span class="action-label">Series Detail</span>
</button>
<div class="folder-wrap" bind:this={folderRef}>
<button
class="action-btn"
class:active={assignedFolders.length > 0}
onclick={() => folderOpen = !folderOpen}
>
<span class="action-icon">
<FolderSimplePlus size={13} weight={assignedFolders.length > 0 ? "fill" : "light"} />
</span>
<span class="action-label">
{assignedFolders.length > 0 ? assignedFolders.map((f) => f.name).join(", ") : "Add to folder"}
</span>
</button>
{#if folderOpen}
<div class="folder-menu">
{#if catsLoading}
<p class="folder-empty">Loading…</p>
{:else if allCategories.length === 0 && !creatingFolder}
<p class="folder-empty">No folders yet</p>
{/if}
{#each allCategories as cat}
{@const isIn = mangaCategories.some((c) => c.id === cat.id)}
<button class="folder-item" class:folder-item-on={isIn} onclick={() => toggleCategory(cat)}>
<Folder size={12} weight={isIn ? "fill" : "light"} />
{isIn ? "✓ " : ""}{cat.name}
</button>
{/each}
<div class="folder-divider"></div>
{#if creatingFolder}
<div class="folder-create-row">
<input
class="folder-input"
placeholder="Folder name…"
bind:value={newFolderName}
onkeydown={(e) => {
if (e.key === "Enter") handleFolderCreate();
if (e.key === "Escape") { creatingFolder = false; newFolderName = ""; }
}}
use:focusAction
/>
<button class="folder-ok" onclick={handleFolderCreate} disabled={!newFolderName.trim()}>
Add
</button>
</div>
{:else}
<button class="folder-new" onclick={() => creatingFolder = true}>+ New folder</button>
{/if}
</div>
{/if}
</div>
<button
class="action-btn"
class:active={linkedIds.length > 0}
onclick={openLinkPicker}
>
<span class="action-icon">
<LinkSimpleHorizontalBreak size={13} weight={linkedIds.length > 0 ? "fill" : "light"} />
</span>
<span class="action-label">
{linkedIds.length > 0 ? `Series Link (${linkedIds.length})` : "Series Link"}
</span>
</button>
<button
class="action-btn"
class:active={hasCoverOverride}
onclick={openCoverPicker}
>
<span class="action-icon">
<Image size={13} weight={hasCoverOverride ? "fill" : "light"} />
</span>
<span class="action-label">Cover Image</span>
</button>
</div>
</div>
<div class="content">
<div class="content-header">
<div class="title-block">
<h2 class="title">{displayManga?.title}</h2>
{#if loadingDetail}
<div class="sk-byline"></div>
{:else if displayManga?.author || displayManga?.artist}
<p class="byline">
{[displayManga?.author, displayManga?.artist]
.filter(Boolean)
.filter((v, i, a) => a.indexOf(v) === i)
.join(" · ")}
</p>
{/if}
</div>
<button class="close-btn" onclick={close}><X size={15} weight="light" /></button>
</div>
<div class="content-body">
{#if fetchError}
<div class="error-banner">{fetchError}</div>
{/if}
{#if loadingDetail}
<div class="sk-row">
<div class="sk-badge"></div>
<div class="sk-badge" style="width:72px"></div>
</div>
{:else}
<div class="badges">
{#if statusLabel}
<span class="badge" class:badge-green={displayManga?.status === "ONGOING"}>{statusLabel}</span>
{/if}
{#if displayManga?.source}
<span class="badge">{displayManga.source.displayName}</span>
{/if}
{#if inLibrary}
<span class="badge badge-accent">In Library</span>
{/if}
{#if !loadingChapters && unreadCount > 0}
<span class="badge badge-unread">{unreadCount} unread</span>
{/if}
{#if !loadingChapters && bookmarkCount > 0}
<span class="badge">{bookmarkCount} bookmarked</span>
{/if}
</div>
{/if}
<div class="chapter-box">
{#if loadingChapters}
<div class="chapter-loading">
<CircleNotch size={13} weight="light" class="anim-spin" style="color:var(--text-faint)" />
<span class="chapter-loading-label">Loading chapters…</span>
</div>
{:else if totalCount > 0}
<div class="chapter-meta">
<span class="chapter-label">
{totalCount} {totalCount === 1 ? "chapter" : "chapters"}
{readCount > 0 ? ` · ${readCount} read` : ""}
{unreadCount > 0 && readCount > 0 ? ` · ${unreadCount} left` : ""}
{downloadedCount > 0 ? ` · ${downloadedCount} dl` : ""}
</span>
{#if unreadCount > 0}
<button class="dl-all-btn" onclick={downloadAll} disabled={queueingAll}>
{#if queueingAll}<CircleNotch size={11} weight="light" class="anim-spin" />{/if}
{queueingAll ? "Queuing…" : "Download unread"}
</button>
{/if}
</div>
{#if readCount > 0}
<div class="progress-track">
<div class="progress-fill" style="width:{(readCount / totalCount) * 100}%"></div>
</div>
{/if}
{#if continueChapter}
<button class="read-btn" onclick={() => {
const { ch, type, resumePage } = continueChapter!;
if (type === "continue" && resumePage && resumePage > 1) {
const existing = appState.bookmarks.find((b) => b.chapterId === ch.id);
if (!existing || existing.pageNumber < resumePage) {
addBookmark({
mangaId: displayManga!.id,
mangaTitle: displayManga!.title,
thumbnailUrl: displayManga!.thumbnailUrl,
chapterId: ch.id,
chapterName: ch.name,
pageNumber: resumePage,
});
}
}
appState.openReader(ch, chapters, displayManga);
close();
}}>
<Play size={12} weight="fill" />{continueLabel}
</button>
{/if}
{:else if !loadingDetail}
<span class="chapter-label" style="color:var(--text-faint)">No chapters in local library</span>
{/if}
</div>
{#if loadingDetail}
<div class="sk-desc">
<div class="sk-line" style="width:100%"></div>
<div class="sk-line" style="width:88%"></div>
<div class="sk-line" style="width:70%"></div>
</div>
{:else if displayManga?.description}
<div class="desc-block">
<p class="desc" class:desc-open={descExpanded}>{displayManga.description}</p>
{#if displayManga.description.length > 220}
<button class="desc-toggle" onclick={() => descExpanded = !descExpanded}>
{descExpanded ? "Show less" : "Show more"}
<CaretDown
size={10}
weight="light"
style="transform:{descExpanded ? 'rotate(180deg)' : 'none'};transition:transform 0.15s ease"
/>
</button>
{/if}
</div>
{/if}
{#if !loadingDetail && displayManga?.genre?.length}
<div class="genres">
{#each displayManga.genre as g}
<button
class="genre-tag"
onclick={() => { appState.genreFilter = g; appState.navPage = "search"; close(); }}
>{g}</button>
{/each}
</div>
{/if}
{#if !loadingDetail}
<div class="meta-table">
<div class="meta-grid">
<div class="meta-col">
<div class="meta-row">
<span class="meta-key">Status</span>
<span class="meta-val">{statusLabel ?? "N/A"}</span>
</div>
<div class="meta-row">
<span class="meta-key">Source</span>
<span class="meta-val">{displayManga?.source?.displayName ?? "N/A"}</span>
</div>
<div class="meta-row">
<span class="meta-key">Link</span>
{#if displayManga?.realUrl}
<a href={displayManga.realUrl} target="_blank" rel="noreferrer" class="meta-link">
Open <ArrowSquareOut size={11} weight="light" />
</a>
{:else}
<span class="meta-val">N/A</span>
{/if}
</div>
</div>
<div class="meta-col">
<div class="meta-row">
<span class="meta-key">Author</span>
<span class="meta-val">{displayManga?.author ?? "N/A"}</span>
</div>
<div class="meta-row">
<span class="meta-key">Artist</span>
<span class="meta-val">
{displayManga?.artist && displayManga.artist !== displayManga.author
? displayManga.artist
: (displayManga?.author ?? "N/A")}
</span>
</div>
<div class="meta-row">
<span class="meta-key">Scanlator</span>
<span class="meta-val">
{!loadingChapters && scanlators.length > 0 ? scanlators[0] : "N/A"}
</span>
</div>
</div>
</div>
</div>
{/if}
</div>
</div>
</div>
</div>
{/if}
{#if linkPickerOpen && appState.previewManga}
<SeriesLinkPanel
manga={displayManga ?? appState.previewManga}
allManga={allMangaForLink}
onClose={() => linkPickerOpen = false}
/>
{/if}
{#if coverPickerOpen && appState.previewManga}
<CoverPickerPanel
manga={displayManga ?? appState.previewManga}
allManga={allMangaForLink}
onClose={() => coverPickerOpen = false}
/>
{/if}
<style>
.backdrop {
position: fixed; inset: 0;
background: rgba(0,0,0,0.72);
z-index: var(--z-settings);
display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
animation: fadeIn 0.12s ease both;
}
.modal {
width: min(800px, calc(100vw - 48px));
height: min(560px, calc(100vh - 80px));
display: flex; flex-direction: row;
background: var(--bg-surface);
border: 1px solid var(--border-base);
border-radius: var(--radius-xl);
overflow: hidden;
box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6);
animation: scaleIn 0.16s ease both;
}
.cover-col {
width: 190px; flex-shrink: 0;
background: var(--bg-raised);
border-right: 1px solid var(--border-dim);
display: flex; flex-direction: column;
padding: var(--sp-5) var(--sp-4) var(--sp-4);
gap: var(--sp-3); overflow: hidden;
}
.cover-wrap { position: relative; width: 100%; }
:global(.cover) {
width: 100%; aspect-ratio: 2/3;
object-fit: cover; border-radius: var(--radius-md);
border: 1px solid var(--border-dim); display: block;
}
.cover-spinner {
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.35); border-radius: var(--radius-md);
color: var(--text-faint);
}
.cover-actions { display: flex; flex-direction: column; gap: var(--sp-2); }
.action-btn {
display: flex; align-items: center; gap: var(--sp-2);
width: 100%; padding: 7px var(--sp-3);
border-radius: var(--radius-md);
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
border: 1px solid var(--border-strong); background: none; color: var(--text-muted);
cursor: pointer; text-align: left;
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.action-btn:hover:not(:disabled) { color: var(--accent-fg); border-color: var(--accent); background: var(--accent-muted); }
.action-btn:disabled { opacity: 0.4; cursor: default; }
.action-btn.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
.action-icon { display: flex; align-items: center; justify-content: center; width: 16px; flex-shrink: 0; }
.action-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.folder-wrap { position: relative; width: 100%; }
.folder-menu {
position: absolute; bottom: calc(100% + 4px); left: 0; right: 0;
background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-md);
padding: var(--sp-1); display: flex; flex-direction: column; gap: 1px;
box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 10;
animation: scaleIn 0.1s ease both; transform-origin: bottom center;
}
.folder-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-2) var(--sp-3); }
.folder-item {
display: flex; align-items: center; gap: var(--sp-2);
padding: 6px var(--sp-3); border-radius: var(--radius-sm);
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-muted); background: none; border: none; cursor: pointer; text-align: left;
transition: background var(--t-fast), color var(--t-fast);
}
.folder-item:hover { background: var(--bg-overlay); color: var(--text-primary); }
.folder-item.folder-item-on { color: var(--accent-fg); }
.folder-divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) 0; }
.folder-create-row { display: flex; gap: var(--sp-1); padding: var(--sp-1); }
.folder-input {
flex: 1; min-width: 0;
background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
padding: 4px 8px; color: var(--text-secondary);
font-family: var(--font-ui); font-size: var(--text-xs); outline: none;
}
.folder-input:focus { border-color: var(--border-focus); }
.folder-ok {
font-family: var(--font-ui); font-size: var(--text-xs);
padding: 4px 8px; border-radius: var(--radius-sm);
border: 1px solid var(--border-strong); background: none; color: var(--text-muted);
cursor: pointer; flex-shrink: 0; transition: color var(--t-base);
}
.folder-ok:disabled { opacity: 0.4; cursor: default; }
.folder-ok:not(:disabled):hover { color: var(--accent-fg); border-color: var(--accent); }
.folder-new {
padding: 6px var(--sp-3); border-radius: var(--radius-sm);
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint);
background: none; border: none; cursor: pointer; text-align: left; width: 100%;
transition: color var(--t-fast);
}
.folder-new:hover { color: var(--accent-fg); }
.content { flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden; }
.content-header {
display: flex; align-items: flex-start; justify-content: space-between;
gap: var(--sp-4); padding: var(--sp-5) var(--sp-6) var(--sp-4);
border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
}
.title-block { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-1); }
.title { font-size: var(--text-lg); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); line-height: var(--leading-tight); }
.byline { font-size: var(--text-sm); color: var(--text-muted); line-height: var(--leading-snug); }
.sk-byline { height: 14px; width: 55%; background: var(--bg-overlay); border-radius: var(--radius-sm); animation: pulse 1.4s ease infinite; }
.close-btn {
display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px; flex-shrink: 0;
border-radius: var(--radius-sm); color: var(--text-faint);
background: none; border: none; cursor: pointer;
transition: color var(--t-base), background var(--t-base);
}
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
.content-body {
flex: 1; min-height: 0; overflow-y: auto;
padding: var(--sp-5) var(--sp-6);
display: flex; flex-direction: column; gap: var(--sp-4);
scrollbar-width: none;
}
.content-body::-webkit-scrollbar { display: none; }
.error-banner {
font-family: var(--font-ui); font-size: var(--text-xs);
color: #f59e0b; background: rgba(245,158,11,0.1);
border: 1px solid rgba(245,158,11,0.25); border-radius: var(--radius-sm);
padding: 6px var(--sp-3);
}
.sk-row { display: flex; gap: var(--sp-2); align-items: center; }
.sk-badge { height: 20px; width: 54px; background: var(--bg-overlay); border-radius: var(--radius-sm); animation: pulse 1.4s ease infinite; }
.sk-desc { display: flex; flex-direction: column; gap: 8px; padding: var(--sp-2) 0; }
.sk-line { height: 13px; background: var(--bg-overlay); border-radius: var(--radius-sm); animation: pulse 1.4s ease infinite; }
.badges { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
.badge {
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide); text-transform: uppercase;
padding: 3px 8px; border-radius: var(--radius-sm);
border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint);
}
.badge-green { background: rgba(34,197,94,0.12); border-color: rgba(34,197,94,0.3); color: #22c55e; }
.badge-accent { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
.badge-unread { background: rgba(245,158,11,0.12); border-color: rgba(245,158,11,0.3); color: #f59e0b; }
.chapter-box { display: flex; flex-direction: column; gap: var(--sp-3); padding: var(--sp-4); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); }
.chapter-loading { display: flex; align-items: center; gap: var(--sp-2); }
.chapter-loading-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.chapter-meta { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
.chapter-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
.dl-all-btn {
display: flex; align-items: center; gap: var(--sp-1); flex-shrink: 0;
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
padding: 3px 10px; border-radius: var(--radius-sm);
border: 1px solid var(--border-dim); background: none; color: var(--text-faint);
cursor: pointer; transition: color var(--t-base), border-color var(--t-base);
}
.dl-all-btn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); }
.dl-all-btn:disabled { opacity: 0.5; cursor: default; }
.progress-track { height: 3px; background: var(--bg-overlay); border-radius: var(--radius-full); overflow: hidden; }
.progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
.read-btn {
display: flex; align-items: center; gap: var(--sp-2); align-self: flex-start;
padding: 8px var(--sp-4); border-radius: var(--radius-md);
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg);
cursor: pointer; transition: filter var(--t-base);
}
.read-btn:hover { filter: brightness(1.1); }
.desc-block { display: flex; flex-direction: column; gap: var(--sp-2); border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); }
.desc { font-size: var(--text-sm); color: var(--text-muted); line-height: var(--leading-base); display: -webkit-box; -webkit-line-clamp: 5; -webkit-box-orient: vertical; overflow: hidden; }
.desc.desc-open { display: block; -webkit-line-clamp: unset; overflow: visible; }
.desc-toggle {
display: flex; align-items: center; gap: var(--sp-1); align-self: flex-start;
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint);
background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base);
}
.desc-toggle:hover { color: var(--accent-fg); }
.genres { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
.genre-tag {
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
padding: 3px 8px; border-radius: var(--radius-sm);
border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint);
cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.genre-tag:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.meta-table { display: flex; flex-direction: column; gap: 1px; border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); }
.meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 var(--sp-4); }
.meta-col { display: flex; flex-direction: column; }
.meta-row { display: flex; align-items: baseline; gap: var(--sp-3); padding: 5px 0; }
.meta-key { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-transform: uppercase; min-width: 56px; flex-shrink: 0; }
.meta-val { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.meta-link { display: inline-flex; align-items: center; gap: 4px; font-size: var(--text-sm); color: var(--accent-fg); text-decoration: none; transition: opacity var(--t-base); }
.meta-link:hover { opacity: 0.75; }
:global(.anim-spin) { animation: anim-spin 0.8s linear infinite; }
@keyframes anim-spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
@keyframes pulse { 0%, 100% { opacity: 0.4 } 50% { opacity: 0.8 } }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
</style>
@@ -0,0 +1,195 @@
<script lang="ts">
import { ArrowLeft, MagnifyingGlass, ArrowLeft as Prev, ArrowRight as Next, BookmarkSimple, FolderSimplePlus, Folder } from "phosphor-svelte";
import Thumbnail from "$lib/components/manga/Thumbnail.svelte";
import { appState } from "$lib/state/app.svelte";
import { requestManager } from "$lib/request-manager/index";
import { toast } from "$lib/state/notifications.svelte";
import type { Manga, Category } from "$lib/types/index";
import ContextMenu, { type MenuEntry } from "$lib/components/common/ContextMenu.svelte";
type BrowseType = "POPULAR" | "LATEST" | "SEARCH";
let mangas: Manga[] = $state([]);
let loading = $state(true);
let page = $state(1);
let hasNextPage = $state(false);
let browseType: BrowseType = $state("POPULAR");
let search = $state("");
let searchInput = $state("");
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
let categories: Category[] = $state([]);
let catsLoaded = false;
async function fetchMangas(type: BrowseType, p: number, q: string) {
if (!appState.activeSource) return;
loading = true; mangas = [];
requestManager.fetchSourceManga(appState.activeSource.id, type, p, q || null)
.then((d) => { mangas = d.mangas; hasNextPage = d.hasNextPage; })
.catch(console.error)
.finally(() => loading = false);
}
$effect(() => { if (appState.activeSource) fetchMangas(browseType, page, search); });
function submitSearch() { search = searchInput.trim(); browseType = "SEARCH"; page = 1; }
function setMode(mode: BrowseType) {
if (mode === browseType) return;
browseType = mode; search = ""; searchInput = ""; page = 1;
}
function openCtx(e: MouseEvent, m: Manga) {
e.preventDefault(); e.stopPropagation();
ctx = { x: e.clientX, y: e.clientY, manga: m };
if (!catsLoaded) {
catsLoaded = true;
requestManager.getCategories()
.then(d => { categories = d.filter((c: Category) => c.id !== 0); })
.catch(console.error);
}
}
function buildCtxItems(m: Manga): MenuEntry[] {
return [
{
label: m.inLibrary ? "In Library" : "Add to library",
icon: BookmarkSimple,
disabled: m.inLibrary,
onClick: () => requestManager.updateManga(m.id, { inLibrary: true })
.then(() => {
mangas = mangas.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x);
toast({ kind: "success", title: "Added to library", body: m.title });
})
.catch((e) => {
toast({ kind: "error", title: "Failed to add to library", body: m.title });
console.error(e);
}),
},
...(categories.length > 0 ? [
{ separator: true } as MenuEntry,
...categories.map((cat): MenuEntry => ({
label: (cat.mangas?.nodes ?? []).some((x: Manga) => x.id === m.id) ? `✓ ${cat.name}` : cat.name,
icon: Folder,
onClick: () => requestManager.updateMangaCategories(m.id, [cat.id], []).catch(console.error),
})),
] : []),
{ separator: true },
{
label: "New folder & add",
icon: FolderSimplePlus,
onClick: async () => {
const name = prompt("Folder name:");
if (!name?.trim()) return;
const cat = await requestManager.createCategory(name.trim()).catch(console.error);
if (cat) {
categories = [...categories, cat];
await requestManager.updateMangaCategories(m.id, [cat.id], []).catch(console.error);
}
},
},
];
}
</script>
{#if appState.activeSource}
<div class="root">
<div class="header">
<button class="back" onclick={() => appState.activeSource = null}>
<ArrowLeft size={13} weight="light" /><span>Sources</span>
</button>
<span class="source-name">{appState.activeSource.displayName}</span>
</div>
<div class="toolbar">
<div class="tabs">
{#each (["POPULAR", "LATEST"] as BrowseType[]) as mode}
<button class="tab" class:active={browseType === mode && !search} onclick={() => setMode(mode)}>
{mode.charAt(0) + mode.slice(1).toLowerCase()}
</button>
{/each}
{#if search}<button class="tab active">Search</button>{/if}
</div>
<div class="search-wrap">
<MagnifyingGlass size={12} class="search-icon" weight="light" />
<input class="search" placeholder="Search source…" bind:value={searchInput}
onkeydown={(e) => e.key === "Enter" && submitSearch()} />
</div>
</div>
{#if loading}
<div class="loading-grid">
{#each Array(18) as _}
<div class="card-skeleton"><div class="cover-skeleton skeleton"></div><div class="title-skeleton skeleton"></div></div>
{/each}
</div>
{:else if mangas.length === 0}
<div class="empty">No results.</div>
{:else}
<div class="grid">
{#each mangas as m (m.id)}
<button class="card" onclick={() => { appState.activeManga = m; appState.navPage = "library"; }}
oncontextmenu={(e) => openCtx(e, m)}>
<div class="cover-wrap">
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
{#if m.inLibrary}<span class="in-library-badge">In Library</span>{/if}
</div>
<p class="title">{m.title}</p>
</button>
{/each}
</div>
{/if}
{#if !loading && (page > 1 || hasNextPage)}
<div class="pagination">
<button class="page-btn" onclick={() => page = Math.max(1, page - 1)} disabled={page === 1}>
<Prev size={13} weight="light" /> Prev
</button>
<span class="page-num">{page}</span>
<button class="page-btn" onclick={() => page++} disabled={!hasNextPage}>
Next <Next size={13} weight="light" />
</button>
</div>
{/if}
</div>
{/if}
{#if ctx}
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => ctx = null} />
{/if}
<style>
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
.header { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.back { display: flex; align-items: center; gap: var(--sp-2); color: var(--text-muted); font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); text-transform: uppercase; transition: color var(--t-base); flex-shrink: 0; }
.back:hover { color: var(--text-secondary); }
.source-name { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); letter-spacing: var(--tracking-tight); }
.toolbar { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); padding: var(--sp-3) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; flex-wrap: wrap; }
.tabs { display: flex; gap: 2px; }
.tab { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); border: none; background: none; color: var(--text-muted); cursor: pointer; transition: background var(--t-base), color var(--t-base); }
.tab:hover { background: var(--bg-raised); color: var(--text-secondary); }
.tab.active { background: var(--accent-muted); color: var(--accent-fg); }
.search-wrap { position: relative; display: flex; align-items: center; }
.search-wrap :global(.search-icon) { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px 5px 26px; color: var(--text-primary); font-size: var(--text-sm); width: 200px; outline: none; transition: border-color var(--t-base); }
.search::placeholder { color: var(--text-faint); }
.search:focus { border-color: var(--border-strong); }
.grid, .loading-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(100px,14vw,140px),1fr)); gap: var(--sp-4); padding: var(--sp-5) var(--sp-6); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; -webkit-overflow-scrolling: touch; contain: layout style; }
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
.card:hover :global(.cover) { filter: brightness(1.06); }
.card:hover .title { color: var(--text-primary); }
.cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); }
:global(.cover) { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); will-change: filter; }
.in-library-badge { position: absolute; bottom: var(--sp-1); left: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 2px 5px; border-radius: var(--radius-sm); }
.title { margin-top: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); }
.card-skeleton { padding: 0; }
.cover-skeleton { aspect-ratio: 2/3; border-radius: var(--radius-md); }
.title-skeleton { height: 11px; margin-top: var(--sp-2); width: 75%; }
.pagination { display: flex; align-items: center; justify-content: center; gap: var(--sp-4); padding: var(--sp-4); border-top: 1px solid var(--border-dim); flex-shrink: 0; }
.page-btn { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-muted); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 12px; background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.page-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); background: var(--bg-raised); }
.page-btn:disabled { opacity: 0.3; cursor: default; }
.page-num { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wider); min-width: 24px; text-align: center; }
.empty { display: flex; align-items: center; justify-content: center; flex: 1; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style>
@@ -0,0 +1,130 @@
<script lang="ts">
import type { Snippet } from "svelte";
interface Props {
children: Snippet;
class?: string;
}
let { children, class: cls = "" }: Props = $props();
</script>
<div class="hover-3d {cls}">
<div class="hover-3d-content">
{@render children()}
<div class="shine"></div>
<div class="edge-top"></div>
<div class="edge-left"></div>
</div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
</div>
<style>
.hover-3d {
display: inline-grid;
perspective: 600px;
--tx: 0;
--ty: 0;
--shine-x: 50%;
--shine-y: 50%;
--shadow-x: 0px;
--shadow-y: 0px;
--ease-out: linear(0, 0.931 13.8%, 1.196 21.4%, 1.343 29.8%, 1.378 36%, 1.365 43.2%, 1.059 78%, 1);
--ease-hover: linear(0, 0.708 15.2%, 0.927 23.6%, 1.067 33%, 1.12 41%, 1.13 50.2%, 1.019 83.2%, 1);
}
.hover-3d > :nth-child(n + 2) {
isolation: isolate;
z-index: 1;
scale: 1.2;
}
.hover-3d > :nth-child(2) { grid-area: 1/1/2/2; }
.hover-3d > :nth-child(3) { grid-area: 1/2/2/3; }
.hover-3d > :nth-child(4) { grid-area: 1/3/2/4; }
.hover-3d > :nth-child(5) { grid-area: 2/1/3/2; }
.hover-3d > :nth-child(6) { grid-area: 2/3/3/4; }
.hover-3d > :nth-child(7) { grid-area: 3/1/4/2; }
.hover-3d > :nth-child(8) { grid-area: 3/2/4/3; }
.hover-3d > :nth-child(9) { grid-area: 3/3/4/4; }
.hover-3d-content {
grid-area: 1/1/4/4;
overflow: hidden;
border-radius: inherit;
position: relative;
transform: rotate3d(var(--tx), var(--ty), 0, 12deg);
transform-style: preserve-3d;
transition:
transform var(--ease-out) 500ms,
scale var(--ease-out) 500ms,
box-shadow ease-out 450ms;
box-shadow:
calc(var(--shadow-x) * 0.6) calc(var(--shadow-y) * 0.6) 8px rgba(0,0,0,0.18),
calc(var(--shadow-x) * 1.0) calc(var(--shadow-y) * 1.0) 18px rgba(0,0,0,0.14),
calc(var(--shadow-x) * 1.6) calc(var(--shadow-y) * 1.6) 36px rgba(0,0,0,0.10),
0 2px 6px rgba(0,0,0,0.12);
}
.hover-3d:hover > .hover-3d-content {
--ease-out: var(--ease-hover);
scale: 1.055;
}
.shine {
pointer-events: none;
position: absolute;
inset: 0;
z-index: 2;
opacity: 0;
border-radius: inherit;
background-image: radial-gradient(
ellipse 80% 60% at var(--shine-x) var(--shine-y),
rgba(255,255,255,0.22) 0%,
rgba(255,255,255,0.08) 30%,
transparent 65%
);
transition: opacity ease-out 350ms;
mix-blend-mode: screen;
}
.hover-3d:hover .shine { opacity: 1; }
.edge-top {
pointer-events: none;
position: absolute;
top: 0; left: 0; right: 0;
height: 1px;
z-index: 3;
background: linear-gradient(90deg, transparent 10%, rgba(255,255,255,0.18) 50%, transparent 90%);
opacity: 0;
transition: opacity ease-out 350ms;
}
.edge-left {
pointer-events: none;
position: absolute;
top: 0; left: 0; bottom: 0;
width: 1px;
z-index: 3;
background: linear-gradient(180deg, transparent 10%, rgba(255,255,255,0.12) 50%, transparent 90%);
opacity: 0;
transition: opacity ease-out 350ms;
}
.hover-3d:hover .edge-top,
.hover-3d:hover .edge-left { opacity: 1; }
.hover-3d:has(> :nth-child(2):hover) { --tx: -1; --ty: 1; --shine-x: 15%; --shine-y: 15%; --shadow-x: -8px; --shadow-y: -8px; }
.hover-3d:has(> :nth-child(3):hover) { --tx: -1; --ty: 0; --shine-x: 50%; --shine-y: 10%; --shadow-x: 0px; --shadow-y: -8px; }
.hover-3d:has(> :nth-child(4):hover) { --tx: -1; --ty: -1; --shine-x: 85%; --shine-y: 15%; --shadow-x: 8px; --shadow-y: -8px; }
.hover-3d:has(> :nth-child(5):hover) { --tx: 0; --ty: 1; --shine-x: 10%; --shine-y: 50%; --shadow-x: -8px; --shadow-y: 0px; }
.hover-3d:has(> :nth-child(6):hover) { --tx: 0; --ty: -1; --shine-x: 90%; --shine-y: 50%; --shadow-x: 8px; --shadow-y: 0px; }
.hover-3d:has(> :nth-child(7):hover) { --tx: 1; --ty: 1; --shine-x: 15%; --shine-y: 85%; --shadow-x: -8px; --shadow-y: 8px; }
.hover-3d:has(> :nth-child(8):hover) { --tx: 1; --ty: 0; --shine-x: 50%; --shine-y: 90%; --shadow-x: 0px; --shadow-y: 8px; }
.hover-3d:has(> :nth-child(9):hover) { --tx: 1; --ty: -1; --shine-x: 85%; --shine-y: 85%; --shadow-x: 8px; --shadow-y: 8px; }
</style>
@@ -0,0 +1,52 @@
<script lang="ts">
import { settings } from "$lib/state/settings.svelte";
import { getBlobUrl } from "$lib/core/cache/imageCache";
import { platformService } from "$lib/platform-service/index";
let {
src,
alt = "",
class: cls = "",
loading = "lazy",
decoding = "async",
priority = 0,
onerror = undefined,
...rest
}: {
src: string;
alt?: string;
class?: string;
loading?: string;
decoding?: string;
priority?: number;
onerror?: ((e: Event) => void) | undefined;
[key: string]: any;
} = $props();
const isAuth = $derived((settings.serverAuthMode ?? "NONE") !== "NONE");
let blobUrl = $state("");
let reqId = 0;
$effect(() => {
const _src = src;
const _priority = priority;
const _isAuth = isAuth;
if (!_isAuth || !_src) { blobUrl = ""; return; }
const id = ++reqId;
const bareUrl = _src.startsWith("http") ? _src : `${platformService.getServerUrl()}${_src}`;
getBlobUrl(bareUrl, _priority)
.then(u => { if (id === reqId) blobUrl = u; })
.catch(() => { if (id === reqId) blobUrl = ""; });
});
const resolved = $derived(
isAuth
? (blobUrl || undefined)
: (src ? platformService.plainThumbUrl(src) : undefined)
);
</script>
<img src={resolved} {alt} class={cls} {loading} {decoding} {onerror} {...rest} />
+2 -2
View File
@@ -1,5 +1,5 @@
export { shouldHideNsfw, shouldHideSource, dedupeSourcesByLang } from "@core/util"; export { shouldHideNsfw, shouldHideSource, dedupeSourcesByLang } from '$lib/core/util'
export function buildFilter<T>(...predicates: ((item: T) => boolean)[]): (item: T) => boolean { export function buildFilter<T>(...predicates: ((item: T) => boolean)[]): (item: T) => boolean {
return (item) => predicates.every((p) => p(item)); return item => predicates.every(p => p(item))
} }
+9 -15
View File
@@ -1,11 +1,11 @@
export interface PaginationState { export interface PaginationState {
visible: number; visible: number
} }
export interface PaginationResult<T> { export interface PaginationResult<T> {
items: T[]; items: T[]
hasMore: boolean; hasMore: boolean
remaining: number; remaining: number
} }
export function createPaginator<T>(pageSize: number) { export function createPaginator<T>(pageSize: number) {
@@ -15,15 +15,9 @@ export function createPaginator<T>(pageSize: number) {
items: all.slice(0, visible), items: all.slice(0, visible),
hasMore: all.length > visible, hasMore: all.length > visible,
remaining: Math.max(0, all.length - visible), remaining: Math.max(0, all.length - visible),
}; }
}, },
nextVisible(current: number): number { return current + pageSize },
nextVisible(current: number): number { reset(): number { return pageSize },
return current + pageSize; }
}, }
reset(): number {
return pageSize;
},
};
}
+16 -19
View File
@@ -1,29 +1,26 @@
export interface AsyncQueue<T> { export interface AsyncQueue<T> {
enqueue(item: T): void; enqueue(item: T): void
drain(): void; drain(): void
clear(): void; clear(): void
size(): number; size(): number
} }
export function createAsyncQueue<T>( export function createAsyncQueue<T>(worker: (item: T) => Promise<void>, concurrency = 1): AsyncQueue<T> {
worker: (item: T) => Promise<void>, const queue: T[] = []
concurrency = 1, let active = 0
): AsyncQueue<T> {
const queue: T[] = [];
let active = 0;
function next() { function next() {
while (active < concurrency && queue.length > 0) { while (active < concurrency && queue.length > 0) {
const item = queue.shift()!; const item = queue.shift()!
active++; active++
worker(item).finally(() => { active--; next(); }); worker(item).finally(() => { active--; next() })
} }
} }
return { return {
enqueue(item) { queue.push(item); next(); }, enqueue(item) { queue.push(item); next() },
drain() { next(); }, drain() { next() },
clear() { queue.length = 0; }, clear() { queue.length = 0 },
size() { return queue.length; }, size() { return queue.length },
}; }
} }
+15 -24
View File
@@ -1,33 +1,24 @@
export interface SearchResult<T> { export interface SearchResult<T> {
item: T; item: T
score: number; score: number
} }
export function searchItems<T>( export function searchItems<T>(items: T[], query: string, getField: (item: T) => string): T[] {
items: T[], const q = query.trim().toLowerCase()
query: string, if (!q) return items
getField: (item: T) => string, return items.filter(item => getField(item).toLowerCase().includes(q))
): T[] {
const q = query.trim().toLowerCase();
if (!q) return items;
return items.filter(item => getField(item).toLowerCase().includes(q));
} }
export function searchWithScore<T>( export function searchWithScore<T>(items: T[], query: string, getField: (item: T) => string): SearchResult<T>[] {
items: T[], const q = query.trim().toLowerCase()
query: string, if (!q) return items.map(item => ({ item, score: 0 }))
getField: (item: T) => string,
): SearchResult<T>[] {
const q = query.trim().toLowerCase();
if (!q) return items.map(item => ({ item, score: 0 }));
return items return items
.map(item => { .map(item => {
const field = getField(item).toLowerCase(); const field = getField(item).toLowerCase()
if (!field.includes(q)) return null; if (!field.includes(q)) return null
const score = field === q ? 2 : field.startsWith(q) ? 1 : 0; const score = field === q ? 2 : field.startsWith(q) ? 1 : 0
return { item, score }; return { item, score }
}) })
.filter((r): r is SearchResult<T> => r !== null) .filter((r): r is SearchResult<T> => r !== null)
.sort((a, b) => b.score - a.score); .sort((a, b) => b.score - a.score)
} }
+16 -17
View File
@@ -1,32 +1,31 @@
export type SortDir = "asc" | "desc"; export type SortDir = 'asc' | 'desc'
export interface SortField<T> { export interface SortField<T> {
key: string; key: string
comparator: (a: T, b: T, context?: Record<string, unknown>) => number; comparator: (a: T, b: T, context?: Record<string, unknown>) => number
} }
export interface SortConfig<T> { export interface SortConfig<T> {
fields: SortField<T>[]; fields: SortField<T>[]
defaultField: string; defaultField: string
defaultDir: SortDir; defaultDir: SortDir
} }
export interface Sorter<T> { export interface Sorter<T> {
sort(items: T[], field: string, dir: SortDir, context?: Record<string, unknown>): T[]; sort(items: T[], field: string, dir: SortDir, context?: Record<string, unknown>): T[]
} }
export function createSorter<T>(config: SortConfig<T>): Sorter<T> { export function createSorter<T>(config: SortConfig<T>): Sorter<T> {
const fieldMap = new Map(config.fields.map(f => [f.key, f])); const fieldMap = new Map(config.fields.map(f => [f.key, f]))
return { return {
sort(items, field, dir, context) { sort(items, field, dir, context) {
const f = fieldMap.get(field) ?? fieldMap.get(config.defaultField); const f = fieldMap.get(field) ?? fieldMap.get(config.defaultField)
if (!f) return [...items]; if (!f) return [...items]
const d = dir ?? config.defaultDir; const d = dir ?? config.defaultDir
return [...items].sort((a, b) => { return [...items].sort((a, b) => {
const cmp = f.comparator(a, b, context); const cmp = f.comparator(a, b, context)
return d === "asc" ? cmp : -cmp; return d === 'asc' ? cmp : -cmp
}); })
}, },
}; }
} }
+35
View File
@@ -0,0 +1,35 @@
const _inflight = new Map<string, Promise<unknown>>()
export async function runConcurrent<T>(
items: T[],
fn: (item: T) => Promise<void>,
signal: AbortSignal,
concurrency = 6,
): Promise<void> {
let i = 0
async function worker() {
while (i < items.length) {
if (signal.aborted) return
const item = items[i++]
await fn(item).catch(() => {})
}
}
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, worker))
}
export function dedupeRequest<T>(key: string, factory: () => Promise<T>): Promise<T>
export function dedupeRequest<T>(fn: (key: string) => Promise<T>): (key: string) => Promise<T>
export function dedupeRequest<T>(
keyOrFn: string | ((key: string) => Promise<T>),
factory?: () => Promise<T>,
): Promise<T> | ((key: string) => Promise<T>) {
if (typeof keyOrFn === 'function') {
const fn = keyOrFn
return (key: string) => dedupeRequest(key, () => fn(key))
}
const key = keyOrFn
if (_inflight.has(key)) return _inflight.get(key) as Promise<T>
const p = factory!().finally(() => _inflight.delete(key))
_inflight.set(key, p)
return p
}
@@ -0,0 +1,22 @@
export interface PaginatedQuery<T> {
fetchPage(page: number): Promise<T[]>
reset(): void
hasMore(): boolean
}
export interface PaginatedQueryConfig<T> {
fetcher: (page: number) => Promise<{ items: T[]; hasNextPage: boolean }>
}
export function createPaginatedQuery<T>(config: PaginatedQueryConfig<T>): PaginatedQuery<T> {
let _hasMore = true
return {
async fetchPage(page) {
const { items, hasNextPage } = await config.fetcher(page)
_hasMore = hasNextPage
return items
},
reset() { _hasMore = true },
hasMore() { return _hasMore },
}
}
+31
View File
@@ -0,0 +1,31 @@
export interface RetryOptions {
maxAttempts?: number
baseDelayMs?: number
maxDelayMs?: number
shouldRetry?: (err: unknown, attempt: number) => boolean
}
export async function fetchWithRetry<T>(
fetcher: () => Promise<T>,
options: RetryOptions = {},
): Promise<T> {
const {
maxAttempts = 3,
baseDelayMs = 500,
maxDelayMs = 10_000,
shouldRetry = () => true,
} = options
let lastErr: unknown
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fetcher()
} catch (err) {
lastErr = err
if (attempt === maxAttempts || !shouldRetry(err, attempt)) throw err
const delay = Math.min(baseDelayMs * 2 ** (attempt - 1), maxDelayMs)
await new Promise(r => setTimeout(r, delay))
}
}
throw lastErr
}
+89 -14
View File
@@ -1,16 +1,74 @@
const DEFAULT_URL = 'http://127.0.0.1:4567' const DEFAULT_URL = 'http://127.0.0.1:4567'
interface AuthConfig { interface AuthConfig {
baseUrl: string baseUrl: string
mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN'
user?: string user?: string
pass?: string pass?: string
} }
export interface UiAuthDebugStatus {
mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN'
hasSession: boolean
hasRefreshToken: boolean
accessExpiresAt: number | null
refreshExpiresAt: number | null
accessExpiresInMs: number | null
refreshExpiresInMs: number | null
shouldRefreshSoon: boolean
refreshInFlight: boolean
skewMs: number
}
const SKEW_MS = 60_000 * 2
let config: AuthConfig = { baseUrl: DEFAULT_URL, mode: 'NONE' } let config: AuthConfig = { baseUrl: DEFAULT_URL, mode: 'NONE' }
let accessToken: string | null = null let accessToken: string | null = null
let refreshToken: string | null = null let refreshToken: string | null = null
let accessExpiresAt: number | null = null
let refreshExpiresAt: number | null = null
let refreshInFlight = false
function parseExpiry(token: string): number | null {
try {
const payload = JSON.parse(atob(token.split('.')[1]))
return typeof payload.exp === 'number' ? payload.exp * 1000 : null
} catch {
return null
}
}
export const authSession = {
clearTokens() {
accessToken = null
refreshToken = null
accessExpiresAt = null
refreshExpiresAt = null
},
}
export function getUIAccessToken(): string | null {
return accessToken
}
export function getUiAuthDebugStatus(): UiAuthDebugStatus {
const now = Date.now()
const accessExpiresInMs = accessExpiresAt !== null ? accessExpiresAt - now : null
const refreshExpiresInMs = refreshExpiresAt !== null ? refreshExpiresAt - now : null
return {
mode: config.mode,
hasSession: accessToken !== null,
hasRefreshToken: refreshToken !== null,
accessExpiresAt,
refreshExpiresAt,
accessExpiresInMs,
refreshExpiresInMs,
shouldRefreshSoon: accessExpiresInMs !== null && accessExpiresInMs < SKEW_MS,
refreshInFlight,
skewMs: SKEW_MS,
}
}
export function configureAuth( export function configureAuth(
baseUrl: string, baseUrl: string,
@@ -19,8 +77,7 @@ export function configureAuth(
pass?: string, pass?: string,
): void { ): void {
config = { baseUrl: baseUrl.replace(/\/$/, ''), mode, user, pass } config = { baseUrl: baseUrl.replace(/\/$/, ''), mode, user, pass }
accessToken = null authSession.clearTokens()
refreshToken = null
} }
export function authHeaders(): Record<string, string> { export function authHeaders(): Record<string, string> {
@@ -92,10 +149,12 @@ export async function loginUI(user: string, pass: string): Promise<void> {
const data = await gqlRaw(LOGIN_MUTATION, { username: user, password: pass }) as { const data = await gqlRaw(LOGIN_MUTATION, { username: user, password: pass }) as {
login: { accessToken: string; refreshToken: string } login: { accessToken: string; refreshToken: string }
} }
accessToken = data.login.accessToken accessToken = data.login.accessToken
refreshToken = data.login.refreshToken refreshToken = data.login.refreshToken
config.mode = 'UI_LOGIN' accessExpiresAt = parseExpiry(accessToken)
config.user = user refreshExpiresAt = parseExpiry(refreshToken)
config.mode = 'UI_LOGIN'
config.user = user
} }
export async function refreshAccessToken(): Promise<boolean> { export async function refreshAccessToken(): Promise<boolean> {
@@ -104,9 +163,25 @@ export async function refreshAccessToken(): Promise<boolean> {
const data = await gqlRaw(REFRESH_MUTATION, { refreshToken }) as { const data = await gqlRaw(REFRESH_MUTATION, { refreshToken }) as {
refreshToken: { accessToken: string } refreshToken: { accessToken: string }
} }
accessToken = data.refreshToken.accessToken accessToken = data.refreshToken.accessToken
accessExpiresAt = parseExpiry(accessToken)
return true return true
} catch { } catch {
return false return false
} }
} }
export async function refreshUiAccessToken(force = false): Promise<string | null> {
if (config.mode !== 'UI_LOGIN') return null
if (!refreshToken) return null
const now = Date.now()
if (!force && accessExpiresAt !== null && accessExpiresAt - now > SKEW_MS) return accessToken
if (refreshInFlight) return accessToken
refreshInFlight = true
try {
const ok = await refreshAccessToken()
return ok ? accessToken : null
} finally {
refreshInFlight = false
}
}
+256
View File
@@ -0,0 +1,256 @@
import { invoke } from "@tauri-apps/api/core";
import {
persistSettings,
persistLibrary,
persistUpdates,
} from "$lib/core/persistence/persist";
const STORE_FILES = ["settings.json", "library.json", "updates.json"] as const;
export async function exportAppData(): Promise<void> {
const entries: [string, string][] = await invoke("read_store_files", {
names: [...STORE_FILES],
});
const zip = buildZip(
entries.map(([name, content]) => ({
name,
bytes: new TextEncoder().encode(content),
}))
);
await invoke("export_app_data", { bytes: Array.from(zip) });
}
export async function importAppData(): Promise<void> {
const raw: number[] = await invoke("import_app_data");
const files = parseZip(new Uint8Array(raw));
const decode = (name: string) => {
const bytes = files.get(name);
if (!bytes) throw new Error(`Backup is missing ${name}`);
return JSON.parse(new TextDecoder().decode(bytes));
};
const s = decode("settings.json");
const l = decode("library.json");
const u = decode("updates.json");
await Promise.all([
persistSettings({
settings: s.settings ?? null,
storeVersion: s.storeVersion ?? 1,
}),
persistLibrary({
history: l.history ?? [],
bookmarks: l.bookmarks ?? [],
markers: l.markers ?? [],
readLog: l.readLog ?? [],
readingStats: l.readingStats ?? null,
dailyReadCounts: l.dailyReadCounts ?? {},
}),
persistUpdates({
libraryUpdates: u.libraryUpdates ?? [],
lastLibraryRefresh: u.lastLibraryRefresh ?? 0,
acknowledgedUpdateIds: u.acknowledgedUpdateIds ?? [],
}),
]);
await showExitModal();
invoke("exit_app");
}
function showExitModal(): 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),0 8px 24px rgba(0,0,0,0.4)",
"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",
].join(";");
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 = "Import 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 = "Your settings have been restored. Moku will close so you can relaunch with the imported data.";
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(); });
});
}
export async function autoBackupAppData(): Promise<void> {
try {
const entries: [string, string][] = await invoke("read_store_files", {
names: [...STORE_FILES],
});
const zip = buildZip(
entries.map(([name, content]) => ({
name,
bytes: new TextEncoder().encode(content),
}))
);
await invoke("auto_backup_app_data", { bytes: Array.from(zip) });
} catch (e) {
console.warn("[moku] auto-backup failed:", e);
}
}
function crc32(data: Uint8Array): number {
let crc = 0xffffffff;
for (const byte of data) {
crc ^= byte;
for (let j = 0; j < 8; j++) {
crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1;
}
}
return (crc ^ 0xffffffff) >>> 0;
}
function localHeader(name: Uint8Array, data: Uint8Array): Uint8Array {
const buf = new ArrayBuffer(30 + name.byteLength);
const v = new DataView(buf);
v.setUint32(0, 0x04034b50, true);
v.setUint16(4, 20, true);
v.setUint16(6, 0, true);
v.setUint16(8, 0, true);
v.setUint16(10, 0, true);
v.setUint16(12, 0, true);
v.setUint32(14, crc32(data), true);
v.setUint32(18, data.byteLength, true);
v.setUint32(22, data.byteLength, true);
v.setUint16(26, name.byteLength, true);
v.setUint16(28, 0, true);
new Uint8Array(buf).set(name, 30);
return new Uint8Array(buf);
}
function centralHeader(name: Uint8Array, data: Uint8Array, offset: number): Uint8Array {
const buf = new ArrayBuffer(46 + name.byteLength);
const v = new DataView(buf);
v.setUint32(0, 0x02014b50, true);
v.setUint16(4, 20, true);
v.setUint16(6, 20, true);
v.setUint16(8, 0, true);
v.setUint16(10, 0, true);
v.setUint16(12, 0, true);
v.setUint16(14, 0, true);
v.setUint32(16, crc32(data), true);
v.setUint32(20, data.byteLength, true);
v.setUint32(24, data.byteLength, true);
v.setUint16(28, name.byteLength, true);
v.setUint16(30, 0, true);
v.setUint16(32, 0, true);
v.setUint16(34, 0, true);
v.setUint16(36, 0, true);
v.setUint32(38, 0, true);
v.setUint32(42, offset, true);
new Uint8Array(buf).set(name, 46);
return new Uint8Array(buf);
}
function eocd(count: number, cdSize: number, cdOffset: number): Uint8Array {
const buf = new ArrayBuffer(22);
const v = new DataView(buf);
v.setUint32(0, 0x06054b50, true);
v.setUint16(4, 0, true);
v.setUint16(6, 0, true);
v.setUint16(8, count, true);
v.setUint16(10, count, true);
v.setUint32(12, cdSize, true);
v.setUint32(16, cdOffset, true);
v.setUint16(20, 0, true);
return new Uint8Array(buf);
}
function buildZip(files: { name: string; bytes: Uint8Array }[]): Uint8Array {
const enc = new TextEncoder();
const parts: Uint8Array[] = [];
const offsets: number[] = [];
let pos = 0;
for (const { name, bytes } of files) {
const nameBytes = enc.encode(name);
const lh = localHeader(nameBytes, bytes);
offsets.push(pos);
parts.push(lh, bytes);
pos += lh.byteLength + bytes.byteLength;
}
const cdParts = files.map(({ name, bytes }, i) =>
centralHeader(enc.encode(name), bytes, offsets[i])
);
const cd = concat(cdParts);
return concat([...parts, cd, eocd(files.length, cd.byteLength, pos)]);
}
function parseZip(data: Uint8Array): Map<string, Uint8Array> {
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
const files = new Map<string, Uint8Array>();
let pos = 0;
while (pos + 30 <= data.byteLength && view.getUint32(pos, true) === 0x04034b50) {
const fnLen = view.getUint16(pos + 26, true);
const exLen = view.getUint16(pos + 28, true);
const cSize = view.getUint32(pos + 18, true);
const name = new TextDecoder().decode(data.subarray(pos + 30, pos + 30 + fnLen));
const start = pos + 30 + fnLen + exLen;
files.set(name, data.subarray(start, start + cSize));
pos = start + cSize;
}
return files;
}
function concat(arrays: Uint8Array[]): Uint8Array {
const total = arrays.reduce((n, a) => n + a.byteLength, 0);
const out = new Uint8Array(total);
let pos = 0;
for (const a of arrays) { out.set(a, pos); pos += a.byteLength; }
return out;
}
+134
View File
@@ -0,0 +1,134 @@
import { fetch as tauriFetch } from "@tauri-apps/plugin-http";
import { settingsState } from "$lib/state/settings.svelte";
import { getUIAccessToken } from "$lib/core/auth";
const cache = new Map<string, string>();
const inflight = new Map<string, Promise<string>>();
const MAX_CONCURRENT = 6;
let active = 0;
let drainScheduled = false;
let clearing = false;
interface QueueEntry {
url: string;
priority: number;
resolve: (v: string) => void;
reject: (e: unknown) => void;
}
const queue: QueueEntry[] = [];
async function getAuthHeaders(): Promise<Record<string, string>> {
const mode = settingsState.serverAuthMode ?? "NONE";
if (mode === "UI_LOGIN") {
const token = await getUIAccessToken();
return token ? { Authorization: `Bearer ${token}` } : {};
}
if (mode === "BASIC_AUTH") {
const user = settingsState.serverAuthUser?.trim() ?? "";
const pass = settingsState.serverAuthPass?.trim() ?? "";
return user && pass ? { Authorization: `Basic ${btoa(`${user}:${pass}`)}` } : {};
}
return {};
}
async function doFetch(url: string): Promise<string> {
const headers = await getAuthHeaders();
const res = await tauriFetch(url, { method: "GET", headers });
if (!res.ok) throw new Error(`${res.status}`);
const blob = await res.blob();
if (clearing) throw new DOMException("Cancelled", "AbortError");
const blobUrl = URL.createObjectURL(blob);
cache.set(url, blobUrl);
return blobUrl;
}
function insertSorted(entry: QueueEntry) {
let lo = 0, hi = queue.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
if (queue[mid].priority > entry.priority) lo = mid + 1;
else hi = mid;
}
queue.splice(lo, 0, entry);
}
function drain() {
drainScheduled = false;
while (active < MAX_CONCURRENT && queue.length > 0) {
const entry = queue.shift()!;
active++;
doFetch(entry.url)
.then(entry.resolve, entry.reject)
.finally(() => { active--; drain(); });
}
}
function scheduleDrain() {
if (drainScheduled) return;
drainScheduled = true;
requestAnimationFrame(drain);
}
function enqueue(url: string, priority: number): Promise<string> {
const promise = new Promise<string>((resolve, reject) => {
insertSorted({ url, priority, resolve, reject });
}).catch(err => {
inflight.delete(url);
return Promise.reject(err);
});
inflight.set(url, promise);
scheduleDrain();
return promise;
}
export function getBlobUrl(url: string, priority = 0): Promise<string> {
if (!url) return Promise.resolve("");
const cached = cache.get(url);
if (cached) return Promise.resolve(cached);
const existing = inflight.get(url);
if (existing) {
const idx = queue.findIndex(e => e.url === url);
if (idx !== -1 && priority > queue[idx].priority) {
const [entry] = queue.splice(idx, 1);
entry.priority = priority;
insertSorted(entry);
}
return existing;
}
return enqueue(url, priority);
}
export function preloadBlobUrls(urls: string[], basePriority = 0): void {
urls.forEach((url, i) => {
if (!url || cache.has(url) || inflight.has(url)) return;
enqueue(url, basePriority - i);
});
}
export function revokeBlobUrl(url: string): void {
const blob = cache.get(url);
if (blob) { URL.revokeObjectURL(blob); cache.delete(url); }
}
export function deprioritizeQueue(): void {
for (const entry of queue) entry.priority = 0;
queue.sort((a, b) => b.priority - a.priority);
}
export function cancelQueuedFetches(): void {
const dropped = queue.splice(0);
for (const entry of dropped) {
inflight.delete(entry.url);
entry.reject(new DOMException("Cancelled", "AbortError"));
}
}
export function clearBlobCache(): void {
clearing = true;
cancelQueuedFetches();
cache.forEach(blob => URL.revokeObjectURL(blob));
cache.clear();
inflight.clear();
clearing = false;
}
+7 -7
View File
@@ -146,13 +146,13 @@ export const CACHE_GROUPS = {
} as const; } as const;
export const CACHE_KEYS = { export const CACHE_KEYS = {
LIBRARY: "library", LIBRARY: "library",
RECENT_UPDATES: "recent_updates", RECENT_UPDATES: "recent_updates",
ALL_MANGA: "all_manga_unfiltered", ALL_MANGA: "all_manga_unfiltered",
CATEGORIES: "categories", CATEGORIES: "categories",
SEARCH: "search_all_manga", SEARCH: "search_all_manga",
SOURCES: "sources", SOURCES: "sources",
POPULAR: "popular", POPULAR: "popular",
GENRE: (genre: string) => `genre:${genre}`, GENRE: (genre: string) => `genre:${genre}`,
MANGA: (id: number) => `manga:${id}`, MANGA: (id: number) => `manga:${id}`,
CHAPTERS: (id: number) => `chapters:${id}`, CHAPTERS: (id: number) => `chapters:${id}`,
@@ -234,7 +234,7 @@ export async function refreshMangaCache(mangaId: number, thumbnailUrl?: string):
cache.clear(CACHE_KEYS.ALL_MANGA); cache.clear(CACHE_KEYS.ALL_MANGA);
if (thumbnailUrl) { if (thumbnailUrl) {
const { revokeBlobUrl, getBlobUrl } = await import("@core/cache/imageCache"); const { revokeBlobUrl, getBlobUrl } = await import("$lib/core/cache/imageCache");
revokeBlobUrl(thumbnailUrl); revokeBlobUrl(thumbnailUrl);
getBlobUrl(thumbnailUrl, 999).catch(() => {}); getBlobUrl(thumbnailUrl, 999).catch(() => {});
} }
+24
View File
@@ -0,0 +1,24 @@
import { appState } from '$lib/state/app.svelte'
import type { Manga } from '$lib/types'
export function autoLinkLibrary(focal: Manga, allManga: Manga[]): Promise<number> {
return new Promise(resolve => {
const worker = new Worker(new URL('./autoLinkWorker.ts', import.meta.url), { type: 'module' })
worker.onmessage = (e: MessageEvent<number[]>) => {
const matches = e.data
for (const id of matches) appState.linkManga(focal.id, id)
worker.terminate()
resolve(matches.length)
}
worker.onerror = () => { worker.terminate(); resolve(0) }
worker.postMessage({
focalTitle: focal.title,
focalId: focal.id,
allManga: allManga.map(m => ({ id: m.id, title: m.title })),
linkedIds: appState.settings.mangaLinks?.[focal.id] ?? [],
})
})
}
+17 -20
View File
@@ -1,29 +1,26 @@
interface WorkerMsg { interface WorkerMsg {
focalTitle: string; focalTitle: string
focalId: number; focalId: number
allManga: { id: number; title: string }[]; allManga: { id: number; title: string }[]
linkedIds: number[]; linkedIds: number[]
} }
function titleSimilarity(a: string, b: string): number { function titleSimilarity(a: string, b: string): number {
const norm = (s: string) => const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9\s]/g, '').split(/\s+/).filter(Boolean)
s.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter(Boolean); const wa = new Set(norm(a))
const wa = new Set(norm(a)); const wb = new Set(norm(b))
const wb = new Set(norm(b)); if (!wa.size || !wb.size) return 0
if (!wa.size || !wb.size) return 0; const intersection = [...wa].filter(w => wb.has(w)).length
const intersection = [...wa].filter(w => wb.has(w)).length; return intersection / new Set([...wa, ...wb]).size
return intersection / new Set([...wa, ...wb]).size;
} }
self.onmessage = (e: MessageEvent<WorkerMsg>) => { self.onmessage = (e: MessageEvent<WorkerMsg>) => {
const { focalTitle, focalId, allManga, linkedIds } = e.data; const { focalTitle, focalId, allManga, linkedIds } = e.data
const matches: number[] = []; const matches: number[] = []
for (const m of allManga) { for (const m of allManga) {
if (m.id === focalId) continue; if (m.id === focalId) continue
if (linkedIds.includes(m.id)) continue; if (linkedIds.includes(m.id)) continue
if (titleSimilarity(focalTitle, m.title) >= 0.65) matches.push(m.id); if (titleSimilarity(focalTitle, m.title) >= 0.65) matches.push(m.id)
} }
self.postMessage(matches)
self.postMessage(matches); }
};
+28 -29
View File
@@ -1,54 +1,53 @@
const THUMB_SIZE = 16; const THUMB_SIZE = 16
const DUPE_THRESH = 0.12; const DUPE_THRESH = 0.12
const hashCache = new Map<string, Uint8ClampedArray>()
const hashCache = new Map<string, Uint8ClampedArray>();
function toGray(data: Uint8ClampedArray, pixels: number): Uint8ClampedArray { function toGray(data: Uint8ClampedArray, pixels: number): Uint8ClampedArray {
const gray = new Uint8ClampedArray(pixels); const gray = new Uint8ClampedArray(pixels)
for (let i = 0; i < pixels; i++) { for (let i = 0; i < pixels; i++) {
const o = i * 4; const o = i * 4
gray[i] = (data[o] * 299 + data[o + 1] * 587 + data[o + 2] * 114) / 1000; gray[i] = (data[o] * 299 + data[o + 1] * 587 + data[o + 2] * 114) / 1000
} }
return gray; return gray
} }
function loadThumb(url: string): Promise<Uint8ClampedArray> { function loadThumb(url: string): Promise<Uint8ClampedArray> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const img = new Image(); const img = new Image()
img.crossOrigin = "anonymous"; img.crossOrigin = 'anonymous'
img.onload = () => { img.onload = () => {
const canvas = document.createElement("canvas"); const canvas = document.createElement('canvas')
canvas.width = canvas.height = THUMB_SIZE; canvas.width = canvas.height = THUMB_SIZE
const ctx = canvas.getContext("2d")!; const ctx = canvas.getContext('2d')!
ctx.drawImage(img, 0, 0, THUMB_SIZE, THUMB_SIZE); ctx.drawImage(img, 0, 0, THUMB_SIZE, THUMB_SIZE)
resolve(toGray(ctx.getImageData(0, 0, THUMB_SIZE, THUMB_SIZE).data, THUMB_SIZE * THUMB_SIZE)); resolve(toGray(ctx.getImageData(0, 0, THUMB_SIZE, THUMB_SIZE).data, THUMB_SIZE * THUMB_SIZE))
}; }
img.onerror = reject; img.onerror = reject
img.src = url; img.src = url
}); })
} }
function similarity(a: Uint8ClampedArray, b: Uint8ClampedArray): number { function similarity(a: Uint8ClampedArray, b: Uint8ClampedArray): number {
let diff = 0; let diff = 0
for (let i = 0; i < a.length; i++) diff += Math.abs(a[i] - b[i]); for (let i = 0; i < a.length; i++) diff += Math.abs(a[i] - b[i])
return diff / (a.length * 255); return diff / (a.length * 255)
} }
export async function getHash(url: string): Promise<Uint8ClampedArray | null> { export async function getHash(url: string): Promise<Uint8ClampedArray | null> {
if (hashCache.has(url)) return hashCache.get(url)!; if (hashCache.has(url)) return hashCache.get(url)!
try { try {
const thumb = await loadThumb(url); const thumb = await loadThumb(url)
hashCache.set(url, thumb); hashCache.set(url, thumb)
return thumb; return thumb
} catch { } catch {
return null; return null
} }
} }
export function areDuplicates(a: Uint8ClampedArray, b: Uint8ClampedArray): boolean { export function areDuplicates(a: Uint8ClampedArray, b: Uint8ClampedArray): boolean {
return similarity(a, b) <= DUPE_THRESH; return similarity(a, b) <= DUPE_THRESH
} }
export function clearHashCache(): void { export function clearHashCache(): void {
hashCache.clear(); hashCache.clear()
} }
+92
View File
@@ -0,0 +1,92 @@
import { appState } from '$lib/state/app.svelte'
import { searchWithScore } from '$lib/core/algorithms/search'
import { getHash, areDuplicates } from '$lib/core/cover/coverHash'
type CoverManga = { id: number; thumbnailUrl: string; source?: { displayName: string } | null }
export type CoverCandidate = {
mangaId: number
url: string
label: string
isActive: boolean
}
const FUZZY_SCORE_THRESHOLD = 0.65
function normalizeUrl(url: string): string {
try {
const u = new URL(url)
u.search = ''
return u.href.toLowerCase()
} catch {
return url.toLowerCase()
}
}
export function resolvedCover(mangaId: number, ownUrl: string): string {
return appState.settings.mangaPrefs?.[mangaId]?.coverUrl ?? ownUrl
}
function fuzzyMatchIds(
mangaId: number,
title: string,
mangaById: Map<number, CoverManga & { title: string }>,
): number[] {
return searchWithScore(
[...mangaById.values()].filter(m => m.id !== mangaId),
title,
m => m.title,
)
.filter(r => r.score >= FUZZY_SCORE_THRESHOLD)
.map(r => r.item.id)
}
export function coverCandidatesSync(
mangaId: number,
title: string,
ownUrl: string,
mangaById: Map<number, CoverManga & { title: string }>,
): CoverCandidate[] {
const linkedIds = appState.getLinkedMangaIds(mangaId)
const fuzzyIds = fuzzyMatchIds(mangaId, title, mangaById)
const current = appState.settings.mangaPrefs?.[mangaId]?.coverUrl ?? ownUrl
const allIds = Array.from(new Set([...linkedIds, ...fuzzyIds]))
const raw: { mangaId: number; url: string; label: string }[] = [
{ mangaId, url: ownUrl, label: 'This source' },
...allIds.flatMap(id => {
const m = mangaById.get(id)
return m ? [{ mangaId: m.id, url: m.thumbnailUrl, label: m.source?.displayName ?? `ID ${m.id}` }] : []
}),
]
const seen = new Set<string>()
return raw
.filter(c => {
const key = normalizeUrl(c.url)
if (seen.has(key)) return false
seen.add(key)
return true
})
.map(c => ({ ...c, isActive: normalizeUrl(c.url) === normalizeUrl(current) }))
}
export async function dedupeByImage(candidates: CoverCandidate[]): Promise<CoverCandidate[]> {
const hashes = await Promise.all(candidates.map(c => getHash(c.url)))
const groups: number[][] = []
for (let i = 0; i < candidates.length; i++) {
const hi = hashes[i]
const existing = hi
? groups.find(g => { const hj = hashes[g[0]]; return hj ? areDuplicates(hi, hj) : false })
: undefined
if (existing) existing.push(i)
else groups.push([i])
}
return groups.map(group => {
const active = group.find(i => candidates[i].isActive) ?? group[0]
const labels = [...new Set(group.map(i => candidates[i].label))]
return { ...candidates[active], label: labels.join(' · ') }
})
}
+3
View File
@@ -0,0 +1,3 @@
export { eventToKeybind, matchesKeybind, toggleFullscreen } from './keybindEngine'
export { DEFAULT_KEYBINDS, KEYBIND_LABELS } from './defaultBinds'
export type { Keybinds } from './defaultBinds'
+5
View File
@@ -0,0 +1,5 @@
export { loadAllStores, persistSettings, persistLibrary, persistUpdates } from "./persist";
export type { PersistedData } from "./persist";
export { vaultExists, lockVault, unlockVault, clearVault, rekeyVault } from "./credentialVault";
export type { VaultPayload } from "./credentialVault";
+166
View File
@@ -0,0 +1,166 @@
import { LazyStore } from "@tauri-apps/plugin-store";
const settingsStore = new LazyStore("settings.json", { autoSave: false });
const libraryStore = new LazyStore("library.json", { autoSave: false });
const updatesStore = new LazyStore("updates.json", { autoSave: false });
const backupsStore = new LazyStore("backups.json", { autoSave: false });
export interface PersistedData {
settings: any;
storeVersion: number | null;
history: any[];
bookmarks: any[];
markers: any[];
readLog: any[];
readingStats: any | null;
dailyReadCounts: Record<string, number>;
libraryUpdates: any[];
lastLibraryRefresh: number;
acknowledgedUpdateIds: number[];
}
export async function loadAllStores(): Promise<PersistedData> {
const migrated = await migrateFromLocalStorage();
if (migrated) return migrated;
const [sv, s, hist, bk, mk, rl, rs, dc, lu, llr, au] = await Promise.all([
settingsStore.get<number>("storeVersion"),
settingsStore.get<any>("settings"),
libraryStore.get<any[]>("history"),
libraryStore.get<any[]>("bookmarks"),
libraryStore.get<any[]>("markers"),
libraryStore.get<any[]>("readLog"),
libraryStore.get<any>("readingStats"),
libraryStore.get<Record<string, number>>("dailyReadCounts"),
updatesStore.get<any[]>("libraryUpdates"),
updatesStore.get<number>("lastLibraryRefresh"),
updatesStore.get<number[]>("acknowledgedUpdateIds"),
]);
return {
storeVersion: sv ?? null,
settings: s ?? null,
history: hist ?? [],
bookmarks: bk ?? [],
markers: mk ?? [],
readLog: rl ?? [],
readingStats: rs ?? null,
dailyReadCounts: dc ?? {},
libraryUpdates: lu ?? [],
lastLibraryRefresh: llr ?? 0,
acknowledgedUpdateIds: au ?? [],
};
}
async function migrateFromLocalStorage(): Promise<PersistedData | null> {
try {
const raw = localStorage.getItem("moku-store");
if (!raw) return null;
const data = JSON.parse(raw);
await Promise.all([
persistSettings({ settings: data.settings ?? null, storeVersion: data.storeVersion ?? 1 }),
persistLibrary({
history: data.history ?? [],
bookmarks: data.bookmarks ?? [],
markers: data.markers ?? [],
readLog: data.readLog ?? [],
readingStats: data.readingStats ?? null,
dailyReadCounts: data.dailyReadCounts ?? {},
}),
persistUpdates({
libraryUpdates: data.libraryUpdates ?? [],
lastLibraryRefresh: data.lastLibraryRefresh ?? 0,
acknowledgedUpdateIds: data.acknowledgedUpdateIds ?? [],
}),
]);
localStorage.removeItem("moku-store");
return {
storeVersion: data.storeVersion ?? null,
settings: data.settings ?? null,
history: data.history ?? [],
bookmarks: data.bookmarks ?? [],
markers: data.markers ?? [],
readLog: data.readLog ?? [],
readingStats: data.readingStats ?? null,
dailyReadCounts: data.dailyReadCounts ?? {},
libraryUpdates: data.libraryUpdates ?? [],
lastLibraryRefresh: data.lastLibraryRefresh ?? 0,
acknowledgedUpdateIds: data.acknowledgedUpdateIds ?? [],
};
} catch {
return null;
}
}
export async function persistSettings(data: { settings: any; storeVersion: number }) {
await Promise.all([
settingsStore.set("settings", data.settings),
settingsStore.set("storeVersion", data.storeVersion),
]);
await settingsStore.save();
}
export async function persistLibrary(data: {
history: any[];
bookmarks: any[];
markers: any[];
readLog: any[];
readingStats: any;
dailyReadCounts: Record<string, number>;
}) {
await Promise.all([
libraryStore.set("history", data.history),
libraryStore.set("bookmarks", data.bookmarks),
libraryStore.set("markers", data.markers),
libraryStore.set("readLog", data.readLog),
libraryStore.set("readingStats", data.readingStats),
libraryStore.set("dailyReadCounts", data.dailyReadCounts),
]);
await libraryStore.save();
}
export async function persistUpdates(data: {
libraryUpdates: any[];
lastLibraryRefresh: number;
acknowledgedUpdateIds: number[];
}) {
await Promise.all([
updatesStore.set("libraryUpdates", data.libraryUpdates),
updatesStore.set("lastLibraryRefresh", data.lastLibraryRefresh),
updatesStore.set("acknowledgedUpdateIds", data.acknowledgedUpdateIds),
]);
await updatesStore.save();
}
export interface BackupEntry { url: string; name: string; }
export async function loadBackups(): Promise<BackupEntry[]> {
const fromStore = await backupsStore.get<BackupEntry[]>("backupList");
if (fromStore) return fromStore;
try {
const raw = localStorage.getItem("moku_backups");
if (!raw) return [];
const migrated: BackupEntry[] = JSON.parse(raw);
await persistBackups(migrated);
localStorage.removeItem("moku_backups");
return migrated;
} catch { return []; }
}
export async function persistBackups(list: BackupEntry[]): Promise<void> {
await backupsStore.set("backupList", list);
await backupsStore.save();
}
export async function resetAuthSettings(): Promise<void> {
const current = await settingsStore.get<any>("settings") ?? {};
current.serverAuthMode = "NONE";
current.serverAuthUser = "";
current.serverAuthPass = "";
await settingsStore.set("settings", current);
await settingsStore.save();
localStorage.removeItem("moku-credential-vault");
}
+67
View File
@@ -0,0 +1,67 @@
import { settingsState, updateSettings } from "$lib/state/settings.svelte";
let themeStyleEl: HTMLStyleElement | null = null;
let mediaQuery: MediaQueryList | null = null;
let mediaHandler: (() => void) | null = null;
export function applyTheme() {
const themeId = settingsState.theme ?? "dark";
const isCustom = themeId.startsWith("custom:");
if (!isCustom) {
themeStyleEl?.remove();
themeStyleEl = null;
document.documentElement.setAttribute("data-theme", themeId);
return;
}
const custom = settingsState.customThemes?.find(t => t.id === themeId);
if (!custom) {
themeStyleEl?.remove();
themeStyleEl = null;
document.documentElement.setAttribute("data-theme", "dark");
return;
}
const vars = Object.entries(custom.tokens)
.map(([k, v]) => ` --${k}: ${v};`)
.join("\n");
const css = `[data-theme="custom"] {\n${vars}\n}`;
if (!themeStyleEl) {
themeStyleEl = document.createElement("style");
themeStyleEl.id = "moku-custom-theme";
document.head.appendChild(themeStyleEl);
}
themeStyleEl.textContent = css;
document.documentElement.setAttribute("data-theme", "custom");
}
function applySystemTheme(dark: boolean) {
const themeId = dark
? (settingsState.systemThemeDark ?? "dark")
: (settingsState.systemThemeLight ?? "light");
updateSettings({ theme: themeId });
}
export function mountSystemThemeSync() {
if (mediaQuery && mediaHandler) {
mediaQuery.removeEventListener("change", mediaHandler);
mediaHandler = null;
}
if (!settingsState.systemThemeSync) return;
mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
mediaHandler = () => applySystemTheme(mediaQuery!.matches);
mediaQuery.addEventListener("change", mediaHandler);
applySystemTheme(mediaQuery.matches);
}
export function unmountSystemThemeSync() {
if (mediaQuery && mediaHandler) {
mediaQuery.removeEventListener("change", mediaHandler);
mediaHandler = null;
mediaQuery = null;
}
}
+4 -10
View File
@@ -16,9 +16,7 @@ export class CapacitorAdapter implements PlatformAdapter {
async launchServer(_config: ServerLaunchConfig) {} async launchServer(_config: ServerLaunchConfig) {}
async stopServer() {} async stopServer() {}
async getServerStatus(): Promise<'running' | 'stopped' | 'error'> { async getServerStatus(): Promise<'running' | 'stopped' | 'error'> { return 'stopped' }
return 'stopped'
}
async readFile(path: string): Promise<Uint8Array> { async readFile(path: string): Promise<Uint8Array> {
const { Filesystem, Directory } = await import('@capacitor/filesystem') const { Filesystem, Directory } = await import('@capacitor/filesystem')
@@ -37,9 +35,7 @@ export class CapacitorAdapter implements PlatformAdapter {
await Filesystem.writeFile({ path, data: base64, directory: Directory.Data }) await Filesystem.writeFile({ path, data: base64, directory: Directory.Data })
} }
async pickFolder(): Promise<string | null> { async pickFolder(): Promise<string | null> { return null }
return null
}
async authenticateBiometric(): Promise<boolean> { async authenticateBiometric(): Promise<boolean> {
try { try {
@@ -70,6 +66,7 @@ export class CapacitorAdapter implements PlatformAdapter {
async minimize() {} async minimize() {}
async maximize() {} async maximize() {}
async close() {} async close() {}
async toggleFullscreen() {}
async setDiscordPresence(_presence: DiscordPresence) {} async setDiscordPresence(_presence: DiscordPresence) {}
async clearDiscordPresence() {} async clearDiscordPresence() {}
@@ -85,9 +82,6 @@ export class CapacitorAdapter implements PlatformAdapter {
await Browser.open({ url }) await Browser.open({ url })
} }
async checkForAppUpdate(): Promise<AppUpdateInfo | null> { async checkForAppUpdate(): Promise<AppUpdateInfo | null> { return null }
return null
}
async installAppUpdate(): Promise<void> {} async installAppUpdate(): Promise<void> {}
} }
+8 -2
View File
@@ -1,4 +1,5 @@
import { invoke } from '@tauri-apps/api/core' import { invoke } from '@tauri-apps/api/core'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { open } from '@tauri-apps/plugin-dialog' import { open } from '@tauri-apps/plugin-dialog'
import { readFile, writeFile } from '@tauri-apps/plugin-fs' import { readFile, writeFile } from '@tauri-apps/plugin-fs'
import { open as openUrl } from '@tauri-apps/plugin-shell' import { open as openUrl } from '@tauri-apps/plugin-shell'
@@ -83,6 +84,11 @@ export class TauriAdapter implements PlatformAdapter {
await invoke('close_window') await invoke('close_window')
} }
async toggleFullscreen() {
const win = getCurrentWindow()
await win.setFullscreen(!await win.isFullscreen())
}
async setDiscordPresence(presence: DiscordPresence) { async setDiscordPresence(presence: DiscordPresence) {
await invoke('set_discord_presence', { presence }) await invoke('set_discord_presence', { presence })
} }
@@ -104,8 +110,8 @@ export class TauriAdapter implements PlatformAdapter {
if (!update?.available) return null if (!update?.available) return null
return { return {
version: update.version, version: update.version,
url: update.body ?? '', url: update.body ?? '',
notes: update.body, notes: update.body,
} }
} }
@@ -0,0 +1,40 @@
import { invoke } from "@tauri-apps/api/core";
import { getVersion } from "@tauri-apps/api/app";
import { toast } from "$lib/state/app.svelte";
function parse(tag: string): number[] {
return tag.replace(/^v/, "").split(".").map(Number);
}
function compare(a: number[], b: number[]): number {
for (let i = 0; i < 3; i++) {
if ((a[i] ?? 0) !== (b[i] ?? 0)) return (b[i] ?? 0) - (a[i] ?? 0);
}
return 0;
}
export async function checkForUpdateSilently(): Promise<void> {
try {
const [currentVersion, releases] = await Promise.all([
getVersion(),
invoke<Array<{ tag_name: string; html_url: string }>>("list_releases"),
]);
const valid = releases.filter(r => typeof r.tag_name === "string" && r.tag_name.trim());
if (!valid.length) return;
const latestTag = valid
.map(r => r.tag_name)
.sort((a, b) => compare(parse(a), parse(b)))[0]
.replace(/^v/, "");
if (compare(parse(latestTag), parse(currentVersion)) < 0) {
toast({
kind: "info",
title: `Update available — v${latestTag}`,
body: "Open Settings → About to install.",
duration: 8000,
});
}
} catch {}
}
+1
View File
@@ -44,6 +44,7 @@ export interface PlatformAdapter {
minimize(): Promise<void> minimize(): Promise<void>
maximize(): Promise<void> maximize(): Promise<void>
close(): Promise<void> close(): Promise<void>
toggleFullscreen(): Promise<void>
setDiscordPresence(presence: DiscordPresence): Promise<void> setDiscordPresence(presence: DiscordPresence): Promise<void>
clearDiscordPresence(): Promise<void> clearDiscordPresence(): Promise<void>
+16 -30
View File
@@ -15,52 +15,38 @@ export class WebAdapter implements PlatformAdapter {
async launchServer(_config: ServerLaunchConfig) {} async launchServer(_config: ServerLaunchConfig) {}
async stopServer() {} async stopServer() {}
async getServerStatus(): Promise<'running' | 'stopped' | 'error'> { async getServerStatus(): Promise<'running' | 'stopped' | 'error'> { return 'stopped' }
return 'stopped'
}
async readFile(_path: string): Promise<Uint8Array> {
return new Uint8Array()
}
async readFile(_path: string): Promise<Uint8Array> { return new Uint8Array() }
async writeFile(_path: string, _data: Uint8Array) {} async writeFile(_path: string, _data: Uint8Array) {}
async pickFolder(): Promise<string | null> { return null }
async pickFolder(): Promise<string | null> { async authenticateBiometric(): Promise<boolean> { return false }
return null
}
async authenticateBiometric(): Promise<boolean> {
return false
}
async storeCredential(_key: string, _value: string) {} async storeCredential(_key: string, _value: string) {}
async getCredential(_key: string): Promise<string | null> { return null }
async getCredential(_key: string): Promise<string | null> { async setTitle(title: string) { document.title = title }
return null
}
async setTitle(title: string) {
document.title = title
}
async minimize() {} async minimize() {}
async maximize() {} async maximize() {}
async close() {} async close() {}
async toggleFullscreen() {
if (!document.fullscreenElement) {
await document.documentElement.requestFullscreen().catch(() => {})
} else {
await document.exitFullscreen().catch(() => {})
}
}
async setDiscordPresence(_presence: DiscordPresence) {} async setDiscordPresence(_presence: DiscordPresence) {}
async clearDiscordPresence() {} async clearDiscordPresence() {}
async getVersion(): Promise<string> { async getVersion(): Promise<string> { return __APP_VERSION__ }
return __APP_VERSION__
}
async openExternal(url: string) { async openExternal(url: string) {
window.open(url, '_blank', 'noopener,noreferrer') window.open(url, '_blank', 'noopener,noreferrer')
} }
async checkForAppUpdate(): Promise<AppUpdateInfo | null> { async checkForAppUpdate(): Promise<AppUpdateInfo | null> { return null }
return null
}
async installAppUpdate() {} async installAppUpdate() {}
} }
+33 -1
View File
@@ -1,4 +1,5 @@
import type { PlatformAdapter } from '$lib/platform-adapters/types' import type { PlatformAdapter } from '$lib/platform-adapters/types'
import type { ServerLaunchConfig, DiscordPresence, AppUpdateInfo, PlatformFeature } from '$lib/platform-adapters/types'
let adapter: PlatformAdapter let adapter: PlatformAdapter
@@ -6,7 +7,38 @@ export function initPlatformService(a: PlatformAdapter) {
adapter = a adapter = a
} }
export function getPlatformService(): PlatformAdapter { function get(): PlatformAdapter {
if (!adapter) throw new Error('PlatformService not initialized') if (!adapter) throw new Error('PlatformService not initialized')
return adapter return adapter
}
export const platformService = {
isSupported: (f: PlatformFeature) => get().isSupported(f),
init: () => get().init(),
launchServer: (c: ServerLaunchConfig) => get().launchServer(c),
stopServer: () => get().stopServer(),
getServerStatus: () => get().getServerStatus(),
readFile: (path: string) => get().readFile(path),
writeFile: (path: string, data: Uint8Array) => get().writeFile(path, data),
pickFolder: () => get().pickFolder(),
authenticateBiometric: () => get().authenticateBiometric(),
storeCredential: (k: string, v: string) => get().storeCredential(k, v),
getCredential: (k: string) => get().getCredential(k),
setTitle: (title: string) => get().setTitle(title),
minimize: () => get().minimize(),
maximize: () => get().maximize(),
close: () => get().close(),
toggleFullscreen: () => get().toggleFullscreen(),
setDiscordPresence: (p: DiscordPresence) => get().setDiscordPresence(p),
clearDiscordPresence: () => get().clearDiscordPresence(),
getVersion: () => get().getVersion(),
openExternal: (url: string) => get().openExternal(url),
checkForAppUpdate: () => get().checkForAppUpdate(),
installAppUpdate: () => get().installAppUpdate(),
} }
+17
View File
@@ -1,5 +1,6 @@
import { getAdapter } from '$lib/request-manager' import { getAdapter } from '$lib/request-manager'
import { extensionsState } from '$lib/state/extensions.svelte' import { extensionsState } from '$lib/state/extensions.svelte'
import type { SetServerAuthInput, SetSocksProxyInput, SetFlareSolverrInput } from '$lib/server-adapters/types'
export async function loadExtensions() { export async function loadExtensions() {
extensionsState.loading = true extensionsState.loading = true
@@ -60,4 +61,20 @@ export async function browseSource(sourceId: string, page: number) {
} finally { } finally {
extensionsState.browseLoading = false extensionsState.browseLoading = false
} }
}
export async function getServerSecurity() {
return getAdapter().getServerSecurity()
}
export async function setServerAuth(input: SetServerAuthInput) {
await getAdapter().setServerAuth(input)
}
export async function setSocksProxy(input: SetSocksProxyInput) {
await getAdapter().setSocksProxy(input)
}
export async function setFlareSolverr(input: SetFlareSolverrInput) {
await getAdapter().setFlareSolverr(input)
} }
+17
View File
@@ -1,4 +1,9 @@
import type { ServerAdapter } from '$lib/server-adapters/types' import type { ServerAdapter } from '$lib/server-adapters/types'
import * as extensions from './extensions'
import * as chapters from './chapters'
import * as downloads from './downloads'
import * as manga from './manga'
import * as tracking from './tracking'
let adapter: ServerAdapter let adapter: ServerAdapter
@@ -9,4 +14,16 @@ export function initRequestManager(a: ServerAdapter) {
export function getAdapter(): ServerAdapter { export function getAdapter(): ServerAdapter {
if (!adapter) throw new Error('RequestManager not initialized') if (!adapter) throw new Error('RequestManager not initialized')
return adapter return adapter
}
export function clearPageCache(chapterId?: number): void {
getAdapter().clearPageCache(chapterId)
}
export const requestManager = {
extensions,
chapters,
downloads,
manga,
tracking,
} }
+2 -2
View File
@@ -78,9 +78,9 @@ export async function refreshLibrary() {
try { try {
await getAdapter().checkForUpdates() await getAdapter().checkForUpdates()
await loadLibrary() await loadLibrary()
toast('success', 'Library updated') toast({ kind: 'success', message: 'Library updated' })
} catch (e) { } catch (e) {
toast('error', 'Update failed', String(e)) toast({ kind: 'error', message: 'Update failed', detail: String(e) })
} finally { } finally {
libraryState.refreshing = false libraryState.refreshing = false
} }
+34 -1
View File
@@ -8,8 +8,9 @@ import type {
Page, Page,
DownloadItem, DownloadItem,
UpdateResult, UpdateResult,
LibraryUpdateProgress,
} from '$lib/server-adapters/types' } from '$lib/server-adapters/types'
import type { Manga, Chapter, Extension, Source, Tracker } from '$lib/types' import type { Manga, Chapter, Extension, Source, Tracker, Category } from '$lib/types'
function notImplemented(): never { function notImplemented(): never {
throw new Error('MokuAdapter: not implemented') throw new Error('MokuAdapter: not implemented')
@@ -17,37 +18,69 @@ function notImplemented(): never {
export class MokuAdapter implements ServerAdapter { export class MokuAdapter implements ServerAdapter {
async connect(_config: ServerConfig): Promise<void> { notImplemented() } async connect(_config: ServerConfig): Promise<void> { notImplemented() }
getServerUrl(): string { return notImplemented() }
async getStatus(): Promise<ServerStatus> { return notImplemented() } async getStatus(): Promise<ServerStatus> { return notImplemented() }
async getManga(_id: string): Promise<Manga> { return notImplemented() } async getManga(_id: string): Promise<Manga> { return notImplemented() }
async getMangaList(_filters: MangaFilters): Promise<PaginatedResult<Manga>> { return notImplemented() } async getMangaList(_filters: MangaFilters): Promise<PaginatedResult<Manga>> { return notImplemented() }
async searchManga(_query: string, _sourceId?: string): Promise<Manga[]> { return notImplemented() } async searchManga(_query: string, _sourceId?: string): Promise<Manga[]> { return notImplemented() }
async fetchManga(_id: string): Promise<Manga> { return notImplemented() }
async addToLibrary(_mangaId: string): Promise<void> { notImplemented() } async addToLibrary(_mangaId: string): Promise<void> { notImplemented() }
async removeFromLibrary(_mangaId: string): Promise<void> { notImplemented() } async removeFromLibrary(_mangaId: string): Promise<void> { notImplemented() }
async updateMangas(_ids: string[], _patch: { inLibrary?: boolean }): Promise<void> { notImplemented() }
async updateMangaMeta(_id: string, _meta: Partial<MangaMeta>): Promise<void> { notImplemented() } async updateMangaMeta(_id: string, _meta: Partial<MangaMeta>): Promise<void> { notImplemented() }
async deleteMangaMeta(_id: string, _key: string): Promise<void> { notImplemented() }
async getChapters(_mangaId: string): Promise<Chapter[]> { return notImplemented() } async getChapters(_mangaId: string): Promise<Chapter[]> { return notImplemented() }
async getChapter(_id: string): Promise<Chapter> { return notImplemented() } async getChapter(_id: string): Promise<Chapter> { return notImplemented() }
async getChapterPages(_id: string): Promise<Page[]> { return notImplemented() } async getChapterPages(_id: string): Promise<Page[]> { return notImplemented() }
async fetchChapters(_mangaId: string): Promise<Chapter[]> { return notImplemented() }
async getRecentlyUpdated(): Promise<Chapter[]> { return notImplemented() }
async markChapterRead(_id: string, _read: boolean): Promise<void> { notImplemented() } async markChapterRead(_id: string, _read: boolean): Promise<void> { notImplemented() }
async markChaptersRead(_ids: string[], _read: boolean): Promise<void> { notImplemented() } async markChaptersRead(_ids: string[], _read: boolean): Promise<void> { notImplemented() }
async updateChaptersProgress(_ids: string[], _patch: { isRead?: boolean; isBookmarked?: boolean; lastPageRead?: number }): Promise<void> { notImplemented() }
async deleteDownloadedChapters(_ids: string[]): Promise<void> { notImplemented() }
async setChapterMeta(_chapterId: string, _key: string, _value: string): Promise<void> { notImplemented() }
async deleteChapterMeta(_chapterId: string, _key: string): Promise<void> { notImplemented() }
async getDownloads(): Promise<DownloadItem[]> { return notImplemented() } async getDownloads(): Promise<DownloadItem[]> { return notImplemented() }
async enqueueDownload(_chapterId: string): Promise<void> { notImplemented() } async enqueueDownload(_chapterId: string): Promise<void> { notImplemented() }
async enqueueDownloads(_chapterIds: string[]): Promise<void> { notImplemented() }
async dequeueDownload(_chapterId: string): Promise<void> { notImplemented() } async dequeueDownload(_chapterId: string): Promise<void> { notImplemented() }
async dequeueDownloads(_chapterIds: string[]): Promise<void> { notImplemented() }
async clearDownloads(): Promise<void> { notImplemented() } async clearDownloads(): Promise<void> { notImplemented() }
async startDownloader(): Promise<void> { notImplemented() }
async stopDownloader(): Promise<void> { notImplemented() }
async getExtensions(): Promise<Extension[]> { return notImplemented() } async getExtensions(): Promise<Extension[]> { return notImplemented() }
async installExtension(_id: string): Promise<void> { notImplemented() } async installExtension(_id: string): Promise<void> { notImplemented() }
async uninstallExtension(_id: string): Promise<void> { notImplemented() } async uninstallExtension(_id: string): Promise<void> { notImplemented() }
async updateExtension(_id: string): Promise<void> { notImplemented() } async updateExtension(_id: string): Promise<void> { notImplemented() }
async updateExtensions(_ids: string[]): Promise<void> { notImplemented() }
async installExternalExtension(_url: string): Promise<void> { notImplemented() }
async getSources(): Promise<Source[]> { return notImplemented() } async getSources(): Promise<Source[]> { return notImplemented() }
async browseSource(_sourceId: string, _page: number): Promise<PaginatedResult<Manga>> { return notImplemented() } async browseSource(_sourceId: string, _page: number): Promise<PaginatedResult<Manga>> { return notImplemented() }
async getCategories(): Promise<Category[]> { return notImplemented() }
async createCategory(_name: string): Promise<Category> { return notImplemented() }
async deleteCategory(_id: number): Promise<void> { notImplemented() }
async updateCategoryOrder(_id: number, _position: number): Promise<Category[]> { return notImplemented() }
async updateMangaCategories(_mangaId: string, _addTo: number[], _removeFrom: number[]): Promise<void> { notImplemented() }
async updateMangasCategories(_mangaIds: string[], _addTo: number[], _removeFrom: number[]): Promise<void> { notImplemented() }
async updateCategoryManga(_categoryId: number): Promise<void> { notImplemented() }
async getTrackers(): Promise<Tracker[]> { return notImplemented() } async getTrackers(): Promise<Tracker[]> { return notImplemented() }
async getMangaTrackRecords(_mangaId: string): Promise<unknown[]> { return notImplemented() }
async searchTracker(_trackerId: string, _query: string): Promise<unknown[]> { return notImplemented() }
async linkTracker(_mangaId: string, _trackerId: string, _remoteId: string): Promise<void> { notImplemented() } async linkTracker(_mangaId: string, _trackerId: string, _remoteId: string): Promise<void> { notImplemented() }
async unlinkTracker(_recordId: string): Promise<void> { notImplemented() }
async fetchTrackRecord(_recordId: string): Promise<void> { notImplemented() }
async syncTracking(_mangaId: string): Promise<void> { notImplemented() } async syncTracking(_mangaId: string): Promise<void> { notImplemented() }
async checkForUpdates(_mangaIds?: string[]): Promise<UpdateResult[]> { return notImplemented() } async checkForUpdates(_mangaIds?: string[]): Promise<UpdateResult[]> { return notImplemented() }
async stopLibraryUpdate(): Promise<void> { notImplemented() }
async getLibraryUpdateStatus(): Promise<LibraryUpdateProgress> { return notImplemented() }
clearPageCache(_chapterId?: number): void {}
} }
+84 -22
View File
@@ -9,6 +9,10 @@ import type {
DownloadItem, DownloadItem,
UpdateResult, UpdateResult,
LibraryUpdateProgress, LibraryUpdateProgress,
ServerSecurity,
SetServerAuthInput,
SetSocksProxyInput,
SetFlareSolverrInput,
} from '$lib/server-adapters/types' } from '$lib/server-adapters/types'
import type { Manga, Chapter, Extension, Source, Tracker, Category } from '$lib/types' import type { Manga, Chapter, Extension, Source, Tracker, Category } from '$lib/types'
import { import {
@@ -58,10 +62,12 @@ import {
import { import {
GET_EXTENSIONS, GET_EXTENSIONS,
GET_SOURCES, GET_SOURCES,
GET_SERVER_SECURITY,
FETCH_EXTENSIONS, FETCH_EXTENSIONS,
UPDATE_EXTENSION, UPDATE_EXTENSION,
UPDATE_EXTENSIONS, UPDATE_EXTENSIONS,
INSTALL_EXTERNAL_EXTENSION, INSTALL_EXTERNAL_EXTENSION,
SET_SERVER_AUTH,
} from './extensions' } from './extensions'
import { import {
GET_TRACKERS, GET_TRACKERS,
@@ -80,6 +86,51 @@ import {
mapDownloadItem, mapDownloadItem,
mapCategory, mapCategory,
} from './types' } from './types'
import { clearPageCache as _clearPageCache } from './pageCache'
const SET_SOCKS_PROXY = `
mutation SetSocksProxy(
$socksProxyEnabled: Boolean!
$socksProxyHost: String!
$socksProxyPort: String!
$socksProxyVersion: Int!
$socksProxyUsername: String!
$socksProxyPassword: String!
) {
setSettings(input: { settings: {
socksProxyEnabled: $socksProxyEnabled
socksProxyHost: $socksProxyHost
socksProxyPort: $socksProxyPort
socksProxyVersion: $socksProxyVersion
socksProxyUsername: $socksProxyUsername
socksProxyPassword: $socksProxyPassword
}}) {
settings { socksProxyEnabled socksProxyHost socksProxyPort }
}
}
`
const SET_FLARE_SOLVERR = `
mutation SetFlareSolverr(
$flareSolverrEnabled: Boolean!
$flareSolverrUrl: String!
$flareSolverrTimeout: Int!
$flareSolverrSessionName: String!
$flareSolverrSessionTtl: Int!
$flareSolverrAsResponseFallback: Boolean!
) {
setSettings(input: { settings: {
flareSolverrEnabled: $flareSolverrEnabled
flareSolverrUrl: $flareSolverrUrl
flareSolverrTimeout: $flareSolverrTimeout
flareSolverrSessionName: $flareSolverrSessionName
flareSolverrSessionTtl: $flareSolverrSessionTtl
flareSolverrAsResponseFallback: $flareSolverrAsResponseFallback
}}) {
settings { flareSolverrEnabled flareSolverrUrl }
}
}
`
export class SuwayomiAdapter implements ServerAdapter { export class SuwayomiAdapter implements ServerAdapter {
private baseUrl = 'http://127.0.0.1:4567' private baseUrl = 'http://127.0.0.1:4567'
@@ -133,8 +184,6 @@ export class SuwayomiAdapter implements ServerAdapter {
return json.data return json.data
} }
// ─── Manga ───────────────────────────────────────────────────────────────
async getManga(id: string): Promise<Manga> { async getManga(id: string): Promise<Manga> {
const data = await this.gql<{ manga: Record<string, unknown> }>(GET_MANGA, { id: Number(id) }) const data = await this.gql<{ manga: Record<string, unknown> }>(GET_MANGA, { id: Number(id) })
return mapManga(data.manga) return mapManga(data.manga)
@@ -150,10 +199,10 @@ export class SuwayomiAdapter implements ServerAdapter {
async getMangaList(filters: MangaFilters): Promise<PaginatedResult<Manga>> { async getMangaList(filters: MangaFilters): Promise<PaginatedResult<Manga>> {
const data = await this.gql<{ mangas: { nodes: Record<string, unknown>[] } }>(GET_LIBRARY) const data = await this.gql<{ mangas: { nodes: Record<string, unknown>[] } }>(GET_LIBRARY)
let items = data.mangas.nodes.map(mapManga) let items = data.mangas.nodes.map(mapManga)
if (filters.status) items = items.filter(m => m.status === filters.status) if (filters.status) items = items.filter(m => m.status === filters.status)
if (filters.tags?.length) items = items.filter(m => filters.tags!.every(t => m.tags?.includes(t))) if (filters.tags?.length) items = items.filter(m => filters.tags!.every(t => m.tags?.includes(t)))
if (filters.unread) items = items.filter(m => (m.unreadCount ?? 0) > 0) if (filters.unread) items = items.filter(m => (m.unreadCount ?? 0) > 0)
if (filters.sourceId) items = items.filter(m => String(m.source?.id) === filters.sourceId) if (filters.sourceId) items = items.filter(m => String(m.source?.id) === filters.sourceId)
return { items, hasNextPage: false } return { items, hasNextPage: false }
} }
@@ -188,8 +237,6 @@ export class SuwayomiAdapter implements ServerAdapter {
await this.gql(DELETE_MANGA_META, { mangaId: Number(id), key }) await this.gql(DELETE_MANGA_META, { mangaId: Number(id), key })
} }
// ─── Chapters ────────────────────────────────────────────────────────────
async getChapters(mangaId: string): Promise<Chapter[]> { async getChapters(mangaId: string): Promise<Chapter[]> {
const data = await this.gql<{ chapters: { nodes: Record<string, unknown>[] } }>( const data = await this.gql<{ chapters: { nodes: Record<string, unknown>[] } }>(
GET_CHAPTERS, { mangaId: Number(mangaId) } GET_CHAPTERS, { mangaId: Number(mangaId) }
@@ -252,8 +299,6 @@ export class SuwayomiAdapter implements ServerAdapter {
await this.gql(DELETE_CHAPTER_META, { chapterId: Number(chapterId), key }) await this.gql(DELETE_CHAPTER_META, { chapterId: Number(chapterId), key })
} }
// ─── Downloads ───────────────────────────────────────────────────────────
async getDownloads(): Promise<DownloadItem[]> { async getDownloads(): Promise<DownloadItem[]> {
const data = await this.gql<{ downloadStatus: { queue: Record<string, unknown>[] } }>( const data = await this.gql<{ downloadStatus: { queue: Record<string, unknown>[] } }>(
GET_DOWNLOAD_STATUS GET_DOWNLOAD_STATUS
@@ -289,8 +334,6 @@ export class SuwayomiAdapter implements ServerAdapter {
await this.gql(STOP_DOWNLOADER) await this.gql(STOP_DOWNLOADER)
} }
// ─── Extensions ──────────────────────────────────────────────────────────
async getExtensions(): Promise<Extension[]> { async getExtensions(): Promise<Extension[]> {
await this.gql(FETCH_EXTENSIONS) await this.gql(FETCH_EXTENSIONS)
const data = await this.gql<{ extensions: { nodes: Record<string, unknown>[] } }>(GET_EXTENSIONS) const data = await this.gql<{ extensions: { nodes: Record<string, unknown>[] } }>(GET_EXTENSIONS)
@@ -332,8 +375,6 @@ export class SuwayomiAdapter implements ServerAdapter {
} }
} }
// ─── Categories ──────────────────────────────────────────────────────────
async getCategories(): Promise<Category[]> { async getCategories(): Promise<Category[]> {
const data = await this.gql<{ categories: { nodes: Record<string, unknown>[] } }>(GET_CATEGORIES) const data = await this.gql<{ categories: { nodes: Record<string, unknown>[] } }>(GET_CATEGORIES)
return data.categories.nodes.map(mapCategory) return data.categories.nodes.map(mapCategory)
@@ -369,24 +410,22 @@ export class SuwayomiAdapter implements ServerAdapter {
await this.gql(UPDATE_CATEGORY_MANGA, { categoryId }) await this.gql(UPDATE_CATEGORY_MANGA, { categoryId })
} }
// ─── Tracking ────────────────────────────────────────────────────────────
async getTrackers(): Promise<Tracker[]> { async getTrackers(): Promise<Tracker[]> {
const data = await this.gql<{ trackers: { nodes: Tracker[] } }>(GET_TRACKERS) const data = await this.gql<{ trackers: { nodes: Tracker[] } }>(GET_TRACKERS)
return data.trackers.nodes return data.trackers.nodes
} }
async getMangaTrackRecords(mangaId: string): Promise<unknown[]> { async getMangaTrackRecords(mangaId: string): Promise<unknown[]> {
const data = await this.gql<{ const data = await this.gql<{ manga: { trackRecords: { nodes: unknown[] } } }>(
manga: { trackRecords: { nodes: unknown[] } } GET_MANGA_TRACK_RECORDS, { mangaId: Number(mangaId) }
}>(GET_MANGA_TRACK_RECORDS, { mangaId: Number(mangaId) }) )
return data.manga.trackRecords.nodes return data.manga.trackRecords.nodes
} }
async searchTracker(trackerId: string, query: string): Promise<unknown[]> { async searchTracker(trackerId: string, query: string): Promise<unknown[]> {
const data = await this.gql<{ const data = await this.gql<{ searchTracker: { trackSearches: unknown[] } }>(
searchTracker: { trackSearches: unknown[] } SEARCH_TRACKER, { trackerId: Number(trackerId), query }
}>(SEARCH_TRACKER, { trackerId: Number(trackerId), query }) )
return data.searchTracker.trackSearches return data.searchTracker.trackSearches
} }
@@ -410,7 +449,26 @@ export class SuwayomiAdapter implements ServerAdapter {
await this.gql(TRACK_PROGRESS, { mangaId: Number(mangaId) }) await this.gql(TRACK_PROGRESS, { mangaId: Number(mangaId) })
} }
// ─── Library updates ───────────────────────────────────────────────────── async getServerSecurity(): Promise<ServerSecurity> {
const data = await this.gql<{ settings: ServerSecurity }>(GET_SERVER_SECURITY)
return data.settings
}
async setServerAuth(input: SetServerAuthInput): Promise<void> {
await this.gql(SET_SERVER_AUTH, {
authMode: input.authMode,
authUsername: input.authUsername,
authPassword: input.authPassword,
})
}
async setSocksProxy(input: SetSocksProxyInput): Promise<void> {
await this.gql(SET_SOCKS_PROXY, input)
}
async setFlareSolverr(input: SetFlareSolverrInput): Promise<void> {
await this.gql(SET_FLARE_SOLVERR, input)
}
async checkForUpdates(mangaIds?: string[]): Promise<UpdateResult[]> { async checkForUpdates(mangaIds?: string[]): Promise<UpdateResult[]> {
if (mangaIds?.length) { if (mangaIds?.length) {
@@ -440,4 +498,8 @@ export class SuwayomiAdapter implements ServerAdapter {
const { isRunning, finishedJobs, totalJobs } = data.libraryUpdateStatus.jobsInfo const { isRunning, finishedJobs, totalJobs } = data.libraryUpdateStatus.jobsInfo
return { isRunning, finishedJobs, totalJobs } return { isRunning, finishedJobs, totalJobs }
} }
clearPageCache(chapterId?: number): void {
_clearPageCache(chapterId)
}
} }
@@ -0,0 +1,84 @@
import { gql, getServerUrl } from "$lib/server-adapters/suwayomi";
import { getBlobUrl, preloadBlobUrls } from "$lib/core/cache/imageCache";
import { dedupeRequest } from "$lib/core/async/batchRequests";
import { FETCH_CHAPTER_PAGES } from "$lib/server-adapters/suwayomi/chapters";
const pageCache = new Map<number, string[]>();
const inflight = new Map<number, Promise<string[]>>();
const resolvedUrlCache = new Map<string, Promise<string>>();
const aspectCache = new Map<string, number>();
export function resolveUrl(url: string, useBlob: boolean, priority = 0): Promise<string> {
if (!useBlob) return Promise.resolve(url);
const cached = resolvedUrlCache.get(url);
if (cached) return cached;
const p = getBlobUrl(url, priority).catch(err => {
resolvedUrlCache.delete(url);
return Promise.reject(err);
});
resolvedUrlCache.set(url, p);
return p;
}
export function fetchPages(
chapterId: number,
useBlob: boolean,
signal?: AbortSignal,
priorityPage = 0,
): Promise<string[]> {
const cached = pageCache.get(chapterId);
if (cached) return Promise.resolve(cached);
if (signal?.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
if (!inflight.has(chapterId)) {
const p = dedupeRequest(`chapter-pages:${chapterId}`, () =>
gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId })
.then(d => {
const urls = d.fetchChapterPages.pages.map(p => p.startsWith("http") ? p : `${getServerUrl()}${p}`);
if (useBlob && urls[priorityPage]) getBlobUrl(urls[priorityPage], 999);
pageCache.set(chapterId, urls);
return urls;
})
).finally(() => inflight.delete(chapterId));
inflight.set(chapterId, p);
}
const base = inflight.get(chapterId)!;
if (!signal) return base;
return new Promise((resolve, reject) => {
signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true });
base.then(resolve, reject);
});
}
export function measureAspect(url: string, useBlob: boolean): Promise<number> {
if (aspectCache.has(url)) return Promise.resolve(aspectCache.get(url)!);
return resolveUrl(url, useBlob).then(src => new Promise(res => {
const img = new Image();
img.onload = () => { const r = img.naturalHeight > 0 ? img.naturalWidth / img.naturalHeight : 0.67; aspectCache.set(url, r); res(r); };
img.onerror = () => res(0.67);
img.src = src;
}));
}
export function preloadImage(url: string, useBlob: boolean): void {
if (useBlob) { preloadBlobUrls([url], 0); return; }
resolveUrl(url, useBlob).then(src => { new Image().src = src; }).catch(() => {});
}
export function clearResolvedUrlCache(): void {
resolvedUrlCache.clear();
aspectCache.clear();
}
export function clearPageCache(chapterId?: number): void {
if (chapterId !== undefined) {
pageCache.delete(chapterId);
inflight.delete(chapterId);
} else {
pageCache.clear();
inflight.clear();
resolvedUrlCache.clear();
aspectCache.clear();
}
}
+47 -1
View File
@@ -63,6 +63,46 @@ export interface LibraryUpdateProgress {
totalJobs: number totalJobs: number
} }
export interface ServerSecurity {
authMode: string
authUsername: string
socksProxyEnabled: boolean
socksProxyHost: string
socksProxyPort: string
socksProxyVersion: number
socksProxyUsername: string
flareSolverrEnabled: boolean
flareSolverrUrl: string
flareSolverrTimeout: number
flareSolverrSessionName: string
flareSolverrSessionTtl: number
flareSolverrAsResponseFallback: boolean
}
export interface SetServerAuthInput {
authMode: string
authUsername: string
authPassword: string
}
export interface SetSocksProxyInput {
socksProxyEnabled: boolean
socksProxyHost: string
socksProxyPort: string
socksProxyVersion: number
socksProxyUsername: string
socksProxyPassword: string
}
export interface SetFlareSolverrInput {
flareSolverrEnabled: boolean
flareSolverrUrl: string
flareSolverrTimeout: number
flareSolverrSessionName: string
flareSolverrSessionTtl: number
flareSolverrAsResponseFallback: boolean
}
export interface ServerAdapter { export interface ServerAdapter {
connect(config: ServerConfig): Promise<void> connect(config: ServerConfig): Promise<void>
getStatus(): Promise<ServerStatus> getStatus(): Promise<ServerStatus>
@@ -80,7 +120,7 @@ export interface ServerAdapter {
getChapters(mangaId: string): Promise<Chapter[]> getChapters(mangaId: string): Promise<Chapter[]>
getChapter(id: string): Promise<Chapter> getChapter(id: string): Promise<Chapter>
getChapterPages(id: string): Promise<Page[]> getChapterPages(id: string, signal?: AbortSignal): Promise<Page[]>
fetchChapters(mangaId: string): Promise<Chapter[]> fetchChapters(mangaId: string): Promise<Chapter[]>
getRecentlyUpdated(): Promise<Chapter[]> getRecentlyUpdated(): Promise<Chapter[]>
markChapterRead(id: string, read: boolean): Promise<void> markChapterRead(id: string, read: boolean): Promise<void>
@@ -125,7 +165,13 @@ export interface ServerAdapter {
fetchTrackRecord(recordId: string): Promise<void> fetchTrackRecord(recordId: string): Promise<void>
syncTracking(mangaId: string): Promise<void> syncTracking(mangaId: string): Promise<void>
getServerSecurity(): Promise<ServerSecurity>
setServerAuth(input: SetServerAuthInput): Promise<void>
setSocksProxy(input: SetSocksProxyInput): Promise<void>
setFlareSolverr(input: SetFlareSolverrInput): Promise<void>
checkForUpdates(mangaIds?: string[]): Promise<UpdateResult[]> checkForUpdates(mangaIds?: string[]): Promise<UpdateResult[]>
stopLibraryUpdate(): Promise<void> stopLibraryUpdate(): Promise<void>
getLibraryUpdateStatus(): Promise<LibraryUpdateProgress> getLibraryUpdateStatus(): Promise<LibraryUpdateProgress>
clearPageCache(chapterId?: number): void
} }
+43 -8
View File
@@ -1,11 +1,46 @@
export type NavPage =
| 'home' | 'library' | 'sources' | 'explore'
| 'downloads' | 'extensions' | 'history' | 'search' | 'tracking'
export type AppStatus = 'booting' | 'not-configured' | 'auth' | 'ready' | 'error' export type AppStatus = 'booting' | 'not-configured' | 'auth' | 'ready' | 'error'
class AppStore {
navPage: NavPage = $state('home')
settingsOpen: boolean = $state(false)
searchPrefill: string = $state('')
searchQuery: string = $state('')
genreFilter: string = $state('')
scrollPositions: Map<string, number> = $state(new Map())
setNavPage(next: NavPage) { this.navPage = next }
setSettingsOpen(next: boolean) { this.settingsOpen = next }
setSearchPrefill(next: string) { this.searchPrefill = next }
setSearchQuery(next: string) { this.searchQuery = next }
setGenreFilter(next: string) { this.genreFilter = next }
saveScroll(key: string, top: number) {
const m = new Map(this.scrollPositions)
m.set(key, top)
this.scrollPositions = m
}
getScroll(key: string): number { return this.scrollPositions.get(key) ?? 0 }
}
export const app = new AppStore()
export const appState = $state({ export const appState = $state({
status: 'booting' as AppStatus, status: 'booting' as AppStatus,
error: null as string | null, error: null as string | null,
serverUrl: '', serverUrl: '',
authenticated: false, authenticated: false,
authMode: 'NONE' as 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN', authMode: 'NONE' as 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN',
platform: 'web' as 'web' | 'tauri' | 'capacitor', platform: 'web' as 'web' | 'tauri' | 'capacitor',
version: '', version: '',
}) })
export function setNavPage(next: NavPage) { app.setNavPage(next) }
export function setSettingsOpen(next: boolean) { app.setSettingsOpen(next) }
export function setSearchPrefill(next: string) { app.setSearchPrefill(next) }
export function setSearchQuery(next: string) { app.setSearchQuery(next) }
export function setGenreFilter(next: string) { app.setGenreFilter(next) }
export function saveScroll(key: string, top: number) { app.saveScroll(key, top) }
export function getScroll(key: string): number { return app.getScroll(key) }
+156
View File
@@ -0,0 +1,156 @@
import { probeServer, loginBasic, loginUI } from '$lib/core/auth'
import { appState } from '$lib/state/app.svelte'
const MAX_ATTEMPTS = 15
const BG_MAX_ATTEMPTS = 60
export const boot = $state({
failed: false,
notConfigured: false,
loginRequired: false,
loginError: null as string | null,
loginBusy: false,
loginUser: '',
loginPass: '',
sessionExpired: false,
skipped: false,
})
let probeGeneration = 0
function handleProbeSuccess(gen: number) {
if (gen !== probeGeneration) return
boot.failed = false
boot.skipped = false
appState.authenticated = true
appState.status = 'ready'
}
function handleAuthRequired(gen: number, authMode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN', user: string, pass: string) {
if (gen !== probeGeneration) return
boot.failed = false
if (authMode === 'BASIC_AUTH' && user && pass) {
loginBasic(user, pass)
.then(() => { if (gen === probeGeneration) handleProbeSuccess(gen) })
.catch(() => {
if (gen !== probeGeneration) return
boot.loginUser = user
boot.loginRequired = true
appState.status = 'auth'
})
return
}
boot.loginUser = user
boot.loginRequired = true
appState.status = 'auth'
}
export function startProbe(
authMode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' = 'NONE',
user = '',
pass = '',
) {
const gen = ++probeGeneration
boot.failed = false
boot.loginRequired = false
boot.skipped = false
appState.status = 'booting'
let tries = 0
async function probe() {
if (gen !== probeGeneration) return
tries++
const result = await probeServer()
if (gen !== probeGeneration) return
if (result === 'ok') { handleProbeSuccess(gen); return }
if (result === 'auth_required') { handleAuthRequired(gen, authMode, user, pass); return }
if (tries >= MAX_ATTEMPTS) { boot.failed = true; appState.status = 'error'; startBackgroundProbe(gen, authMode, user, pass); return }
setTimeout(probe, Math.min(300 + tries * 150, 1500))
}
setTimeout(probe, 100)
}
function startBackgroundProbe(
gen: number,
authMode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN',
user: string,
pass: string,
) {
let bgTries = 0
async function bgProbe() {
if (gen !== probeGeneration) return
bgTries++
const result = await probeServer()
if (gen !== probeGeneration) return
if (result === 'ok') { handleProbeSuccess(gen); return }
if (result === 'auth_required') { handleAuthRequired(gen, authMode, user, pass); return }
if (bgTries >= BG_MAX_ATTEMPTS) return
setTimeout(bgProbe, 2000)
}
setTimeout(bgProbe, 2000)
}
export function stopProbe() {
probeGeneration++
}
export async function submitLogin(): Promise<void> {
if (!boot.loginUser.trim() || !boot.loginPass.trim()) {
boot.loginError = 'Username and password are required'
return
}
boot.loginBusy = true
boot.loginError = null
try {
if (appState.authMode === 'UI_LOGIN') {
await loginUI(boot.loginUser.trim(), boot.loginPass.trim())
} else {
await loginBasic(boot.loginUser.trim(), boot.loginPass.trim())
}
boot.loginRequired = false
boot.sessionExpired = false
boot.skipped = false
boot.loginPass = ''
boot.loginError = null
appState.authenticated = true
appState.status = 'ready'
} catch (e: unknown) {
boot.loginError = e instanceof Error ? e.message : 'Login failed'
} finally {
boot.loginBusy = false
}
}
export function retryBoot(
authMode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' = 'NONE',
user = '',
pass = '',
) {
boot.failed = false
boot.notConfigured = false
boot.loginRequired = false
boot.skipped = false
startProbe(authMode, user, pass)
}
export function bypassBoot(
authMode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' = 'NONE',
user = '',
pass = '',
) {
const gen = probeGeneration
boot.loginRequired = false
boot.sessionExpired = false
boot.skipped = true
appState.status = 'ready'
startBackgroundProbe(gen, authMode, user, pass)
}
+9 -7
View File
@@ -5,12 +5,14 @@ export const downloadsState = $state({
error: null as string | null, error: null as string | null,
}) })
export const activeDownloads = $derived( export function activeDownloads() {
downloadsState.items.filter(d => d.state === 'downloading') return downloadsState.items.filter(d => d.state === 'downloading')
) }
export const queuedDownloads = $derived( export function queuedDownloads() {
downloadsState.items.filter(d => d.state === 'queued') return downloadsState.items.filter(d => d.state === 'queued')
) }
export const downloadCount = $derived(downloadsState.items.length) export function downloadCount() {
return downloadsState.items.length
}
+2 -2
View File
@@ -18,7 +18,7 @@ export const extensionsState = $state({
browseHasMore: false, browseHasMore: false,
}) })
export const filteredExtensions = $derived.by(() => { export function filteredExtensions() {
let result = extensionsState.items let result = extensionsState.items
if (extensionsState.filter.installed) { if (extensionsState.filter.installed) {
@@ -33,4 +33,4 @@ export const filteredExtensions = $derived.by(() => {
} }
return result return result
}) }
+28 -15
View File
@@ -1,25 +1,38 @@
export type ToastKind = 'info' | 'success' | 'error' | 'download' export type ToastKind = 'info' | 'success' | 'error' | 'download'
export interface Toast { export interface Toast {
id: string id: string
kind: ToastKind kind: ToastKind
message: string message: string
detail?: string detail?: string
duration?: number duration?: number
} }
export const notificationsState = $state({ export interface ActiveDownload {
toasts: [] as Toast[], chapterId: number
}) mangaId: number
progress: number
}
export function toast(kind: ToastKind, message: string, detail?: string, duration = 4000) { class NotificationStore {
const id = crypto.randomUUID() toasts: Toast[] = $state([])
notificationsState.toasts.push({ id, kind, message, detail, duration }) activeDownloads: ActiveDownload[] = $state([])
if (duration > 0) {
setTimeout(() => dismissToast(id), duration) toast(toast: Omit<Toast, 'id'>) {
this.toasts = [...this.toasts, { ...toast, id: Math.random().toString(36).slice(2) }].slice(-5)
}
dismissToast(id: string) {
this.toasts = this.toasts.filter(x => x.id !== id)
}
setActiveDownloads(next: ActiveDownload[]) {
this.activeDownloads = next
} }
} }
export function dismissToast(id: string) { export const notifications = new NotificationStore()
notificationsState.toasts = notificationsState.toasts.filter(t => t.id !== id)
} export function toast(toast: Omit<Toast, 'id'>) { notifications.toast(toast) }
export function dismissToast(id: string) { notifications.dismissToast(id) }
export function setActiveDownloads(next: ActiveDownload[]) { notifications.setActiveDownloads(next) }
+13 -10
View File
@@ -25,17 +25,20 @@ export const readerState = $state({
fullscreen: false, fullscreen: false,
}) })
export const currentPageData = $derived( export function currentPageData() {
readerState.pages[readerState.currentPage] ?? null return readerState.pages[readerState.currentPage] ?? null
) }
export const progress = $derived( export function progress() {
readerState.pages.length > 0 return readerState.pages.length > 0
? (readerState.currentPage + 1) / readerState.pages.length ? (readerState.currentPage + 1) / readerState.pages.length
: 0 : 0
) }
export const hasPrev = $derived(readerState.currentPage > 0) export function hasPrev() {
export const hasNext = $derived( return readerState.currentPage > 0
readerState.currentPage < readerState.pages.length - 1 }
)
export function hasNext() {
return readerState.currentPage < readerState.pages.length - 1
}
+28
View File
@@ -0,0 +1,28 @@
import type { Settings } from '$lib/types/settings'
import { DEFAULT_SETTINGS } from '$lib/types/settings'
const KEY = 'moku_settings'
function load(): Settings {
try {
const raw = localStorage.getItem(KEY)
if (raw) return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) }
} catch {}
return { ...DEFAULT_SETTINGS }
}
function save(s: Settings) {
try { localStorage.setItem(KEY, JSON.stringify(s)) } catch {}
}
export const settingsState = $state({ settings: load() })
export function updateSettings(patch: Partial<Settings>) {
Object.assign(settingsState.settings, patch)
save(settingsState.settings)
}
export function resetSettings() {
settingsState.settings = { ...DEFAULT_SETTINGS }
save(settingsState.settings)
}
+42 -2
View File
@@ -1,4 +1,6 @@
import type { Tracker } from '$lib/types' import type { Tracker, TrackRecord } from '$lib/types'
import type { Chapter } from '$lib/types/chapter'
import type { MangaPrefs } from '$lib/types/settings'
export const trackingState = $state({ export const trackingState = $state({
trackers: [] as Tracker[], trackers: [] as Tracker[],
@@ -13,4 +15,42 @@ export const trackingState = $state({
searchResults: [] as unknown[], searchResults: [] as unknown[],
searchLoading: false, searchLoading: false,
searchError: null as string | null, searchError: null as string | null,
}) })
export async function syncBackFromTracker(
records: TrackRecord[],
chapters: Chapter[],
opts: {
threshold: number | null
respectScanlatorFilter: boolean
chapterPrefs: Partial<MangaPrefs>
},
markChaptersRead: (ids: string[], read: boolean) => Promise<void>,
): Promise<Chapter[]> {
const marked: Chapter[] = []
const activeScanlators: string[] | null =
opts.respectScanlatorFilter && opts.chapterPrefs.scanlatorFilter?.length
? opts.chapterPrefs.scanlatorFilter
: null
for (const record of records) {
const lastRead = record.lastChapterRead ?? 0
if (lastRead <= 0) continue
const toMark = chapters.filter(ch => {
if (ch.read) return false
if (activeScanlators && ch.scanlator && !activeScanlators.includes(ch.scanlator)) return false
return opts.threshold !== null
? ch.chapterNumber <= lastRead && ch.chapterNumber >= lastRead - opts.threshold
: ch.chapterNumber <= lastRead
})
if (toMark.length === 0) continue
await markChaptersRead(toMark.map(ch => String(ch.id)), true)
marked.push(...toMark)
}
return marked
}
+135 -283
View File
@@ -1,312 +1,164 @@
import type { Keybinds } from "$lib/core/keybinds/defaultBinds"; import { DEFAULT_KEYBINDS, type Keybinds } from '$lib/core/keybinds/defaultBinds'
export type PageStyle = "single" | "double" | "longstrip"; export type PageStyle = 'single' | 'double' | 'longstrip'
export type FitMode = "width" | "height" | "screen" | "original"; export type FitMode = 'width' | 'height' | 'screen' | 'original'
export type LibraryFilter = "all" | "library" | "downloaded" | string; export type LibraryFilter = 'all' | 'library' | 'downloaded' | string
export type ReadingDirection = "ltr" | "rtl"; export type ReadingDirection = 'ltr' | 'rtl'
export type ChapterSortDir = "desc" | "asc"; export type ChapterSortDir = 'desc' | 'asc'
export type ChapterSortMode = "source" | "chapterNumber" | "uploadDate"; export type ChapterSortMode = 'source' | 'chapterNumber' | 'uploadDate'
export type ContentLevel = "strict" | "moderate" | "unrestricted"; export type ContentLevel = 'strict' | 'moderate' | 'unrestricted'
export type LibrarySortMode = export type LibrarySortMode =
| "az" | "unreadCount" | "totalChapters" | 'az' | 'unreadCount' | 'totalChapters'
| "recentlyAdded" | "recentlyRead" | "latestFetched" | "latestUploaded"; | 'recentlyAdded' | 'recentlyRead' | 'latestFetched' | 'latestUploaded'
export type LibrarySortDir = "asc" | "desc"; export type LibrarySortDir = 'asc' | 'desc'
export type LibraryStatusFilter = 'ALL' | 'ONGOING' | 'COMPLETED' | 'CANCELLED' | 'HIATUS' | 'UNKNOWN'
export type LibraryStatusFilter = "ALL" | "ONGOING" | "COMPLETED" | "CANCELLED" | "HIATUS" | "UNKNOWN"; export type LibraryContentFilter = 'unread' | 'started' | 'downloaded' | 'bookmarked' | 'marked'
export type LibraryContentFilter = "unread" | "started" | "downloaded" | "bookmarked" | "marked"; export type BuiltinTheme = 'original' | 'dark' | 'light' | 'light-contrast' | 'midnight' | 'warm'
export type Theme = BuiltinTheme | string
export type BuiltinTheme = "original" | "dark" | "light" | "light-contrast" | "midnight" | "warm";
export type Theme = BuiltinTheme | string;
export interface ThemeTokens { export interface ThemeTokens {
"bg-void": string; 'bg-void': string; 'bg-base': string; 'bg-surface': string
"bg-base": string; 'bg-raised': string; 'bg-overlay': string; 'bg-subtle': string
"bg-surface": string; 'border-dim': string; 'border-base': string; 'border-strong': string; 'border-focus': string
"bg-raised": string; 'text-primary': string; 'text-secondary': string; 'text-muted': string
"bg-overlay": string; 'text-faint': string; 'text-disabled': string
"bg-subtle": string; 'accent': string; 'accent-dim': string; 'accent-muted': string
"border-dim": string; 'accent-fg': string; 'accent-bright': string
"border-base": string; 'color-error': string; 'color-error-bg': string
"border-strong": string; 'color-success': string; 'color-info': string; 'color-info-bg': string
"border-focus": string;
"text-primary": string;
"text-secondary": string;
"text-muted": string;
"text-faint": string;
"text-disabled": string;
"accent": string;
"accent-dim": string;
"accent-muted": string;
"accent-fg": string;
"accent-bright": string;
"color-error": string;
"color-error-bg": string;
"color-success": string;
"color-info": string;
"color-info-bg": string;
} }
export interface CustomTheme { export interface CustomTheme { id: string; name: string; tokens: ThemeTokens }
id: string;
name: string;
tokens: ThemeTokens;
}
export const DEFAULT_THEME_TOKENS: ThemeTokens = { export const DEFAULT_THEME_TOKENS: ThemeTokens = {
"bg-void": "#080808", 'bg-void': '#080808', 'bg-base': '#0c0c0c', 'bg-surface': '#101010',
"bg-base": "#0c0c0c", 'bg-raised': '#151515', 'bg-overlay': '#1a1a1a', 'bg-subtle': '#202020',
"bg-surface": "#101010", 'border-dim': '#1c1c1c', 'border-base': '#242424', 'border-strong': '#2e2e2e', 'border-focus': '#4a5c4a',
"bg-raised": "#151515", 'text-primary': '#f0efec', 'text-secondary': '#c8c6c0', 'text-muted': '#8a8880',
"bg-overlay": "#1a1a1a", 'text-faint': '#4e4d4a', 'text-disabled': '#2a2a28',
"bg-subtle": "#202020", 'accent': '#6b8f6b', 'accent-dim': '#2a3d2a', 'accent-muted': '#1a251a',
"border-dim": "#1c1c1c", 'accent-fg': '#a8c4a8', 'accent-bright': '#8fb88f',
"border-base": "#242424", 'color-error': '#c47a7a', 'color-error-bg': '#1f1212',
"border-strong": "#2e2e2e", 'color-success': '#7aab7a', 'color-info': '#7a9ec4', 'color-info-bg': '#121a1f',
"border-focus": "#4a5c4a", }
"text-primary": "#f0efec",
"text-secondary": "#c8c6c0",
"text-muted": "#8a8880",
"text-faint": "#4e4d4a",
"text-disabled": "#2a2a28",
"accent": "#6b8f6b",
"accent-dim": "#2a3d2a",
"accent-muted": "#1a251a",
"accent-fg": "#a8c4a8",
"accent-bright": "#8fb88f",
"color-error": "#c47a7a",
"color-error-bg": "#1f1212",
"color-success": "#7aab7a",
"color-info": "#7a9ec4",
"color-info-bg": "#121a1f",
};
export interface MangaPrefs { export interface MangaPrefs {
autoDownload: boolean; autoDownload: boolean; downloadAhead: number; deleteOnRead: boolean
downloadAhead: number; deleteDelayHours: number; maxKeepChapters: number; pauseUpdates: boolean
deleteOnRead: boolean; refreshInterval: 'global' | 'daily' | 'weekly' | 'manual'
deleteDelayHours: number; preferredScanlator: string; scanlatorFilter: string[]
maxKeepChapters: number; scanlatorBlacklist: string[]; scanlatorForce: boolean
pauseUpdates: boolean; autoDownloadScanlators: string[]
refreshInterval: "global" | "daily" | "weekly" | "manual"; coverUrl?: string
preferredScanlator: string;
scanlatorFilter: string[];
scanlatorBlacklist: string[];
scanlatorForce: boolean;
autoDownloadScanlators: string[];
coverUrl?: string;
} }
export const DEFAULT_MANGA_PREFS: MangaPrefs = { export const DEFAULT_MANGA_PREFS: MangaPrefs = {
autoDownload: false, autoDownload: false, downloadAhead: 0, deleteOnRead: false,
downloadAhead: 0, deleteDelayHours: 0, maxKeepChapters: 0, pauseUpdates: false,
deleteOnRead: false, refreshInterval: 'global', preferredScanlator: '', scanlatorFilter: [],
deleteDelayHours: 0, scanlatorBlacklist: [], scanlatorForce: false,
maxKeepChapters: 0,
pauseUpdates: false,
refreshInterval: "global",
preferredScanlator: "",
scanlatorFilter: [],
scanlatorBlacklist: [],
scanlatorForce: false,
autoDownloadScanlators: [], autoDownloadScanlators: [],
}; }
export interface ReaderSettings { export interface ReaderSettings {
pageStyle: PageStyle; pageStyle: PageStyle
fitMode: FitMode; fitMode: FitMode
readingDirection: ReadingDirection; readingDirection: ReadingDirection
readerZoom: number; readerZoom: number
pageGap: boolean; pageGap: boolean
optimizeContrast: boolean; optimizeContrast: boolean
offsetDoubleSpreads: boolean; offsetDoubleSpreads: boolean
barPosition?: "top" | "left" | "right"; barPosition?: 'top' | 'left' | 'right'
} }
export interface ReaderPreset { export interface ReaderPreset {
id: string; id: string
name: string; name: string
settings: ReaderSettings; settings: ReaderSettings
} }
export interface Settings { export interface Settings {
pageStyle: PageStyle; pageStyle: PageStyle; readingDirection: ReadingDirection; fitMode: FitMode
readingDirection: ReadingDirection; readerZoom: number; pageGap: boolean; optimizeContrast: boolean
fitMode: FitMode; offsetDoubleSpreads: boolean; preloadPages: number
readerZoom: number; autoMarkRead: boolean; autoNextChapter: boolean
pageGap: boolean; libraryCropCovers: boolean; libraryPageSize: number
optimizeContrast: boolean; contentLevel: ContentLevel; sourceOverridesEnabled: boolean
offsetDoubleSpreads: boolean; nsfwAllowedSourceIds: string[]; nsfwBlockedSourceIds: string[]
preloadPages: number; discordRpc: boolean
autoMarkRead: boolean; chapterSortDir: ChapterSortDir; chapterSortMode: ChapterSortMode; chapterPageSize: number
autoNextChapter: boolean; uiZoom: number; compactSidebar: boolean; gpuAcceleration: boolean
libraryCropCovers: boolean; serverUrl: string; serverBinary: string; serverBinaryArgs: string; autoStartServer: boolean; suwayomiWebUI: boolean
libraryPageSize: number; preferredExtensionLang: string; keybinds: Keybinds
contentLevel: ContentLevel; idleTimeoutMin?: number; splashCards?: boolean
sourceOverridesEnabled: boolean; storageLimitGb: number | null; markReadOnNext: boolean; readerDebounceMs: number
nsfwAllowedSourceIds: string[]; autoBookmark: boolean; theme: Theme; libraryBranches: boolean; renderLimit: number
nsfwBlockedSourceIds: string[]; heroSlots: (number | null)[]; mangaLinks: Record<number, number[]>
discordRpc: boolean; mangaPrefs: Record<number, Partial<MangaPrefs>>
chapterSortDir: ChapterSortDir; serverAuthUser: string; serverAuthPass: string
chapterSortMode: ChapterSortMode; serverAuthMode: 'NONE' | 'BASIC_AUTH' | 'SIMPLE_LOGIN' | 'UI_LOGIN'
chapterPageSize: number; socksProxyEnabled: boolean; socksProxyHost: string; socksProxyPort: string
uiZoom: number; socksProxyVersion: number; socksProxyUsername: string; socksProxyPassword: string
compactSidebar: boolean; flareSolverrEnabled: boolean; flareSolverrUrl: string; flareSolverrTimeout: number
gpuAcceleration: boolean; flareSolverrSessionName: string; flareSolverrSessionTtl: number; flareSolverrAsResponseFallback: boolean
serverUrl: string; appLockEnabled: boolean; appLockPin: string
serverBinary: string; customThemes: CustomTheme[]; hiddenCategoryIds: number[]
serverBinaryArgs: string; defaultLibraryCategoryId: number | null; savedIsDefaultCategory: boolean
autoStartServer: boolean; libraryTabSort: Record<string, { mode: LibrarySortMode; dir: LibrarySortDir }>
suwayomiWebUI: boolean; libraryTabStatus: Record<string, LibraryStatusFilter>
preferredExtensionLang: string; libraryTabFilters: Record<string, Partial<Record<LibraryContentFilter, boolean>>>
keybinds: Keybinds; maxPageWidth?: number; uiScale?: number
idleTimeoutMin?: number; extraScanDirs: string[]; serverDownloadsPath: string; serverLocalSourcePath: string
splashCards?: boolean; qolAnimations: boolean; libraryStatsAlways: boolean; pinnedSourceIds: string[]
storageLimitGb: number | null; readerPresets: ReaderPreset[]; mangaReaderSettings: Record<number, ReaderSettings>
markReadOnNext: boolean; barPosition?: 'top' | 'left' | 'right'
readerDebounceMs: number; trackerSyncBack: boolean; trackerSyncBackThreshold: number | null; trackerRespectScanlatorFilter: boolean
autoBookmark: boolean; pinchZoom?: boolean; autoLinkOnOpen: boolean
theme: Theme; downloadToastsEnabled: boolean; downloadAutoRetry: boolean
libraryBranches: boolean; hiddenLibraryTabs: string[]; libraryPinnedTabOrder: string[]
renderLimit: number; autoScroll?: boolean; autoScrollSpeed?: number; disableAutoComplete: boolean
heroSlots: (number | null)[]; systemThemeSync?: boolean; systemThemeDark?: string; systemThemeLight?: string
mangaLinks: Record<number, number[]>; closeAction?: 'ask' | 'tray' | 'quit'
mangaPrefs: Record<number, Partial<MangaPrefs>>; overlayBars?: boolean; tapToToggleBar?: boolean
serverAuthUser: string; automationEnabled?: boolean; automationEnforceGlobal?: boolean
serverAuthPass: string; automationDefaults?: Partial<MangaPrefs>
serverAuthMode: "NONE" | "BASIC_AUTH" | "SIMPLE_LOGIN" | "UI_LOGIN"; libraryShowAllInSaved?: boolean; libraryHideCompletedInSaved?: boolean
socksProxyEnabled: boolean;
socksProxyHost: string;
socksProxyPort: string;
socksProxyVersion: number;
socksProxyUsername: string;
socksProxyPassword: string;
flareSolverrEnabled: boolean;
flareSolverrUrl: string;
flareSolverrTimeout: number;
flareSolverrSessionName: string;
flareSolverrSessionTtl: number;
flareSolverrAsResponseFallback: boolean;
appLockEnabled: boolean;
appLockPin: string;
customThemes: CustomTheme[];
hiddenCategoryIds: number[];
defaultLibraryCategoryId: number | null;
savedIsDefaultCategory: boolean;
libraryTabSort: Record<string, { mode: LibrarySortMode; dir: LibrarySortDir }>;
libraryTabStatus: Record<string, LibraryStatusFilter>;
libraryTabFilters: Record<string, Partial<Record<LibraryContentFilter, boolean>>>;
maxPageWidth?: number;
uiScale?: number;
extraScanDirs: string[];
serverDownloadsPath: string;
serverLocalSourcePath: string;
qolAnimations: boolean;
libraryStatsAlways: boolean;
pinnedSourceIds: string[];
readerPresets: ReaderPreset[];
mangaReaderSettings: Record<number, ReaderSettings>;
barPosition?: "top" | "left" | "right";
trackerSyncBack: boolean;
trackerSyncBackThreshold: number | null;
trackerRespectScanlatorFilter: boolean;
pinchZoom?: boolean;
autoLinkOnOpen: boolean;
downloadToastsEnabled: boolean;
downloadAutoRetry: boolean;
hiddenLibraryTabs: string[];
libraryPinnedTabOrder: string[];
autoScroll?: boolean;
autoScrollSpeed?: number;
disableAutoComplete: boolean;
} }
export const DEFAULT_SETTINGS: Settings = { export const DEFAULT_SETTINGS: Settings = {
pageStyle: "longstrip", pageStyle: 'longstrip', readingDirection: 'ltr', fitMode: 'width',
readingDirection: "ltr", readerZoom: 1.0, pageGap: true, optimizeContrast: false, offsetDoubleSpreads: false,
fitMode: "width", preloadPages: 3, autoMarkRead: true, autoNextChapter: true,
readerZoom: 1.0, libraryCropCovers: true, libraryPageSize: 48,
pageGap: true, contentLevel: 'strict', sourceOverridesEnabled: false,
optimizeContrast: false, nsfwAllowedSourceIds: [], nsfwBlockedSourceIds: [],
offsetDoubleSpreads: false,
preloadPages: 3,
autoMarkRead: true,
autoNextChapter: true,
libraryCropCovers: true,
libraryPageSize: 48,
contentLevel: "strict",
sourceOverridesEnabled: false,
nsfwAllowedSourceIds: [],
nsfwBlockedSourceIds: [],
discordRpc: false, discordRpc: false,
chapterSortDir: "desc", chapterSortDir: 'desc', chapterSortMode: 'source', chapterPageSize: 25,
chapterSortMode: "source", uiZoom: 1.0, compactSidebar: false, gpuAcceleration: true,
chapterPageSize: 25, serverUrl: 'http://localhost:4567', serverBinary: '', serverBinaryArgs: '', autoStartServer: true, suwayomiWebUI: false,
uiZoom: 1.0, preferredExtensionLang: 'en', keybinds: DEFAULT_KEYBINDS,
compactSidebar: false, idleTimeoutMin: 5, splashCards: true, storageLimitGb: null,
gpuAcceleration: true, markReadOnNext: true, readerDebounceMs: 120, autoBookmark: true,
serverUrl: "http://localhost:4567", theme: 'dark', libraryBranches: true, renderLimit: 48,
serverBinary: "", heroSlots: [null, null, null, null], mangaLinks: {}, mangaPrefs: {},
serverBinaryArgs: "", serverAuthUser: '', serverAuthPass: '', serverAuthMode: 'NONE',
autoStartServer: true, socksProxyEnabled: false, socksProxyHost: '', socksProxyPort: '1080',
suwayomiWebUI: false, socksProxyVersion: 5, socksProxyUsername: '', socksProxyPassword: '',
preferredExtensionLang: "en", flareSolverrEnabled: false, flareSolverrUrl: 'http://localhost:8191',
keybinds: {} as Keybinds, flareSolverrTimeout: 60, flareSolverrSessionName: 'moku',
idleTimeoutMin: 5, flareSolverrSessionTtl: 15, flareSolverrAsResponseFallback: false,
splashCards: true, appLockEnabled: false, appLockPin: '',
storageLimitGb: null, customThemes: [], hiddenCategoryIds: [], defaultLibraryCategoryId: null,
markReadOnNext: true,
readerDebounceMs: 120,
autoBookmark: true,
theme: "dark",
libraryBranches: true,
renderLimit: 48,
heroSlots: [null, null, null, null],
mangaLinks: {},
mangaPrefs: {},
serverAuthUser: "",
serverAuthPass: "",
serverAuthMode: "NONE",
socksProxyEnabled: false,
socksProxyHost: "",
socksProxyPort: "1080",
socksProxyVersion: 5,
socksProxyUsername: "",
socksProxyPassword: "",
flareSolverrEnabled: false,
flareSolverrUrl: "http://localhost:8191",
flareSolverrTimeout: 60,
flareSolverrSessionName: "moku",
flareSolverrSessionTtl: 15,
flareSolverrAsResponseFallback: false,
appLockEnabled: false,
appLockPin: "",
customThemes: [],
hiddenCategoryIds: [],
defaultLibraryCategoryId: null,
savedIsDefaultCategory: false, savedIsDefaultCategory: false,
libraryTabSort: {}, libraryTabSort: {}, libraryTabStatus: {}, libraryTabFilters: {},
libraryTabStatus: {}, extraScanDirs: [], serverDownloadsPath: '', serverLocalSourcePath: '',
libraryTabFilters: {}, qolAnimations: true, libraryStatsAlways: false, pinnedSourceIds: [],
extraScanDirs: [], readerPresets: [], mangaReaderSettings: {},
serverDownloadsPath: "", trackerSyncBack: false, trackerSyncBackThreshold: 20, trackerRespectScanlatorFilter: true,
serverLocalSourcePath: "", pinchZoom: false, autoLinkOnOpen: false,
qolAnimations: true, downloadToastsEnabled: true, downloadAutoRetry: false,
libraryStatsAlways: false, hiddenLibraryTabs: [], libraryPinnedTabOrder: [],
pinnedSourceIds: [], autoScroll: false, autoScrollSpeed: 5, disableAutoComplete: false,
readerPresets: [], }
mangaReaderSettings: {},
trackerSyncBack: false,
trackerSyncBackThreshold: 20,
trackerRespectScanlatorFilter: true,
pinchZoom: false,
autoLinkOnOpen: false,
downloadToastsEnabled: true,
downloadAutoRetry: false,
hiddenLibraryTabs: [],
libraryPinnedTabOrder: [],
autoScroll: false,
autoScrollSpeed: 5,
disableAutoComplete: false,
};
+30 -16
View File
@@ -1,11 +1,13 @@
<script lang="ts"> <script lang="ts">
import { appState } from '$lib/state/app.svelte' import { appState, app } from '$lib/state/app.svelte'
import { notificationsState } from '$lib/state/notifications.svelte' import { notifications } from '$lib/state/notifications.svelte'
import SplashScreen from '$lib/ui/chrome/SplashScreen.svelte' import SplashScreen from '$lib/components/chrome/SplashScreen.svelte'
import AuthGate from '$lib/ui/chrome/AuthGate.svelte' import AuthGate from '$lib/components/chrome/AuthGate.svelte'
import Sidebar from '$lib/ui/chrome/Sidebar.svelte' import Sidebar from '$lib/components/chrome/Sidebar.svelte'
import TitleBar from '$lib/ui/chrome/TitleBar.svelte' import TitleBar from '$lib/components/chrome/TitleBar.svelte'
import Toaster from '$lib/ui/chrome/Toaster.svelte' import Toaster from '$lib/components/chrome/Toaster.svelte'
import Settings from '$lib/components/settings/Settings.svelte'
import ThemeEditor from '$lib/components/settings/ThemeEditor.svelte'
import '../app.css' import '../app.css'
let { children } = $props() let { children } = $props()
@@ -13,8 +15,10 @@
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
const ringFull = $derived(appState.status !== 'booting') const ringFull = $derived(appState.status !== 'booting')
let splashVisible = $state(true) let splashVisible = $state(true)
let bypassed = $state(false) let bypassed = $state(false)
let themeEditorOpen = $state(false)
let themeEditorId = $state<string | null>(null)
const showApp = $derived( const showApp = $derived(
appState.status === 'ready' || appState.status === 'ready' ||
@@ -22,13 +26,12 @@
bypassed bypassed
) )
function onSplashReady() { function onSplashReady() { splashVisible = false }
splashVisible = false function onSplashBypass() { bypassed = true; splashVisible = false }
}
function onSplashBypass() { function openThemeEditor(id?: string | null) {
bypassed = true themeEditorId = id ?? null
splashVisible = false themeEditorOpen = true
} }
</script> </script>
@@ -59,8 +62,19 @@
</div> </div>
{/if} {/if}
{#if app.settingsOpen}
<Settings
onclose={() => app.setSettingsOpen(false)}
onOpenThemeEditor={openThemeEditor}
/>
{/if}
{#if themeEditorOpen}
<ThemeEditor onclose={() => themeEditorOpen = false} editId={themeEditorId} />
{/if}
<AuthGate /> <AuthGate />
<Toaster toasts={notificationsState.toasts} /> <Toaster toasts={notifications.toasts} />
<style> <style>
.frame { .frame {
+6 -6
View File
@@ -6,12 +6,12 @@
import { libraryState } from '$lib/state/library.svelte' import { libraryState } from '$lib/state/library.svelte'
import { homeState, setHeroSlot } from '$lib/state/home.svelte' import { homeState, setHeroSlot } from '$lib/state/home.svelte'
import type { HistoryEntry } from '$lib/state/home.svelte' import type { HistoryEntry } from '$lib/state/home.svelte'
import HeroStage from '$lib/ui/home/HeroStage.svelte' import HeroStage from '$lib/components/home/HeroStage.svelte'
import HeroSlotPicker from '$lib/ui/home/HeroSlotPicker.svelte' import HeroSlotPicker from '$lib/components/home/HeroSlotPicker.svelte'
import ActivityFeed from '$lib/ui/home/ActivityFeed.svelte' import ActivityFeed from '$lib/components/home/ActivityFeed.svelte'
import ActivityHeatmap from '$lib/ui/home/ActivityHeatmap.svelte' import ActivityHeatmap from '$lib/components/home/ActivityHeatmap.svelte'
import RecsRow from '$lib/ui/home/RecsRow.svelte' import RecsRow from '$lib/components/home/RecsRow.svelte'
import StatsGrid from '$lib/ui/home/StatsGrid.svelte' import StatsGrid from '$lib/components/home/StatsGrid.svelte'
import type { Manga, Chapter } from '$lib/types' import type { Manga, Chapter } from '$lib/types'
const TOTAL_SLOTS = 4 const TOTAL_SLOTS = 4
+2 -2
View File
@@ -2,8 +2,8 @@
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import { libraryState } from '$lib/state/library.svelte' import { libraryState } from '$lib/state/library.svelte'
import { loadLibrary, refreshLibrary, removeFromLibrary, bulkRemoveFromLibrary } from '$lib/request-manager/manga' import { loadLibrary, refreshLibrary, removeFromLibrary, bulkRemoveFromLibrary } from '$lib/request-manager/manga'
import LibraryToolbar from '$lib/ui/library/LibraryToolbar.svelte' import LibraryToolbar from '$lib/components/library/LibraryToolbar.svelte'
import LibraryGrid from '$lib/ui/library/LibraryGrid.svelte' import LibraryGrid from '$lib/components/library/LibraryGrid.svelte'
import type { Manga } from '$lib/types' import type { Manga } from '$lib/types'
const saved = $derived(libraryState.items.filter(m => m.inLibrary).length) const saved = $derived(libraryState.items.filter(m => m.inLibrary).length)
+8 -3
View File
@@ -1,5 +1,10 @@
<script lang="ts"> <script lang="ts">
import { appState } from '$lib/state/app.svelte' import { goto } from '$app/navigation'
</script> import { app } from '$lib/state/app.svelte'
import { onMount } from 'svelte'
<p>Settings — stub</p> onMount(() => {
app.setSettingsOpen(true)
goto('/', { replaceState: true })
})
</script>