mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Chore: Port over Settings (Barely Works)
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
@import '$lib/components/settings/Settings.css';
|
||||
|
||||
:root {
|
||||
--bg-void: #080808;
|
||||
--bg-base: #0c0c0c;
|
||||
|
||||
@@ -1,35 +1,10 @@
|
||||
<script lang="ts">
|
||||
import logoUrl from '$lib/assets/moku-icon-splash.svg'
|
||||
import { appState } from '$lib/state/app.svelte'
|
||||
import { loginUI, loginBasic, configureAuth } from '$lib/core/auth'
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
import { boot, submitLogin, bypassBoot } from '$lib/state/boot.svelte'
|
||||
|
||||
function handleBypass() {
|
||||
appState.authenticated = false
|
||||
appState.status = 'ready'
|
||||
bypassBoot(appState.authMode, boot.loginUser)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -43,8 +18,8 @@
|
||||
</span>
|
||||
<p class="host">{appState.serverUrl || 'localhost:4567'}</p>
|
||||
|
||||
{#if loginError}
|
||||
<p class="error">{loginError}</p>
|
||||
{#if boot.loginError}
|
||||
<p class="error">{boot.loginError}</p>
|
||||
{/if}
|
||||
|
||||
<div class="fields">
|
||||
@@ -52,28 +27,28 @@
|
||||
class="input"
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
bind:value={loginUser}
|
||||
disabled={loginBusy}
|
||||
bind:value={boot.loginUser}
|
||||
disabled={boot.loginBusy}
|
||||
autocomplete="username"
|
||||
onkeydown={(e) => e.key === 'Enter' && handleLogin()}
|
||||
onkeydown={(e) => e.key === 'Enter' && submitLogin()}
|
||||
/>
|
||||
<input
|
||||
class="input"
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
bind:value={loginPass}
|
||||
disabled={loginBusy}
|
||||
bind:value={boot.loginPass}
|
||||
disabled={boot.loginBusy}
|
||||
autocomplete="current-password"
|
||||
onkeydown={(e) => e.key === 'Enter' && handleLogin()}
|
||||
onkeydown={(e) => e.key === 'Enter' && submitLogin()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="btn"
|
||||
onclick={handleLogin}
|
||||
disabled={loginBusy || !loginUser.trim() || !loginPass.trim()}
|
||||
onclick={submitLogin}
|
||||
disabled={boot.loginBusy || !boot.loginUser.trim() || !boot.loginPass.trim()}
|
||||
>
|
||||
{loginBusy ? 'Signing in…' : 'Sign in'}
|
||||
{boot.loginBusy ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
<button class="btn btn--ghost" onclick={handleBypass}>Skip</button>
|
||||
</div>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores'
|
||||
import { goto } from '$app/navigation'
|
||||
import { app } from '$lib/state/app.svelte'
|
||||
import {
|
||||
House, Books, MagnifyingGlass, ClockCounterClockwise,
|
||||
DownloadSimple, PuzzlePiece, GearSix, ChartLineUp,
|
||||
@@ -51,7 +52,7 @@
|
||||
</nav>
|
||||
|
||||
<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" />
|
||||
</button>
|
||||
</div>
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
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 {
|
||||
mode?: 'loading' | 'idle'
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
import { detectOs } from '$lib/ui/chrome/titlebarOs'
|
||||
import type { OsKind } from '$lib/ui/chrome/titlebarOs'
|
||||
import { detectOs } from '$lib/components/chrome/titlebarOs'
|
||||
import type { OsKind } from '$lib/components/chrome/titlebarOs'
|
||||
|
||||
let { onClose }: { onClose: () => void } = $props()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
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 { HistoryEntry } from '$lib/state/home.svelte'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Play, ArrowRight, ArrowLeft, BookOpen, Clock, ListBullets, PushPin, X as XIcon } from 'phosphor-svelte'
|
||||
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 { Chapter } from '$lib/types'
|
||||
import type { HistoryEntry } from '$lib/state/home.svelte'
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
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'
|
||||
|
||||
let {
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 & Zoom</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Default fit mode</span><span class="s-desc">How pages are scaled to fill the reader on open</span></div>
|
||||
<div class="s-select">
|
||||
<button bind:this={triggerFitMode} class="s-select-btn" onclick={() => toggleSelect('fit-mode')}>
|
||||
<span>{{ 'width':'Fit width','height':'Fit height','screen':'Fit screen','original':'Original (1:1)' }[settingsState.settings.fitMode ?? 'width']}</span>
|
||||
<svg class="s-select-caret" class:open={selectOpen === 'fit-mode'} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||
</button>
|
||||
{#if selectOpen === 'fit-mode'}
|
||||
<div class="s-select-menu" class:anims>
|
||||
{#each [['width','Fit width'],['height','Fit height'],['screen','Fit screen'],['original','Original (1:1)']] as [v, l]}
|
||||
<button class="s-select-option" class:active={(settingsState.settings.fitMode ?? 'width') === v} onclick={() => { updateSettings({ fitMode: v as FitMode }); toggleSelect('fit-mode') }}>{l}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Optimize contrast</span><span class="s-desc">Sharpens dark lines on light pages; best for black-and-white manga</span></div>
|
||||
<button role="switch" aria-checked={settingsState.settings.optimizeContrast} aria-label="Optimize contrast" class="s-toggle" class:on={settingsState.settings.optimizeContrast} onclick={() => updateSettings({ optimizeContrast: !settingsState.settings.optimizeContrast })}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Behaviour</p>
|
||||
<div class="s-section-body">
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Auto-mark read</span><span class="s-desc">Marks a chapter as read when you reach the last page</span></div>
|
||||
<button role="switch" aria-checked={settingsState.settings.autoMarkRead} aria-label="Auto-mark chapters read" class="s-toggle" class:on={settingsState.settings.autoMarkRead} onclick={() => updateSettings({ autoMarkRead: !settingsState.settings.autoMarkRead })}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Auto-advance chapters</span><span class="s-desc">Automatically loads the next chapter when you pass the last page</span></div>
|
||||
<button role="switch" aria-checked={settingsState.settings.autoNextChapter ?? false} aria-label="Auto-advance chapters" class="s-toggle" class:on={settingsState.settings.autoNextChapter} onclick={() => updateSettings({ autoNextChapter: !(settingsState.settings.autoNextChapter ?? false) })}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
{#if !(settingsState.settings.autoNextChapter ?? false)}
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Mark read when skipping</span><span class="s-desc">Marks the current chapter read when you manually jump to the next</span></div>
|
||||
<button role="switch" aria-checked={settingsState.settings.markReadOnNext ?? true} aria-label="Mark read when skipping" class="s-toggle" class:on={settingsState.settings.markReadOnNext ?? true} onclick={() => updateSettings({ markReadOnNext: !(settingsState.settings.markReadOnNext ?? true) })}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
{/if}
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Auto-bookmark</span><span class="s-desc">Automatically saves your page position as you read</span></div>
|
||||
<button role="switch" aria-checked={settingsState.settings.autoBookmark ?? true} aria-label="Enable auto-bookmark" class="s-toggle" class:on={settingsState.settings.autoBookmark ?? true} onclick={() => updateSettings({ autoBookmark: !(settingsState.settings.autoBookmark ?? true) })}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Pages to preload</span><span class="s-desc">How many pages ahead to fetch in the background while reading</span></div>
|
||||
<div class="s-stepper">
|
||||
<button class="s-step-btn" onclick={() => updateSettings({ preloadPages: Math.max(0, settingsState.settings.preloadPages - 1) })} disabled={settingsState.settings.preloadPages <= 0}>−</button>
|
||||
<span class="s-step-val">{settingsState.settings.preloadPages}</span>
|
||||
<button class="s-step-btn" onclick={() => updateSettings({ preloadPages: Math.min(10, settingsState.settings.preloadPages + 1) })} disabled={settingsState.settings.preloadPages >= 10}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,340 @@
|
||||
<script lang="ts">
|
||||
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
|
||||
import { requestManager } from '$lib/request-manager'
|
||||
import { authSession, loginUI } from '$lib/core/auth'
|
||||
|
||||
interface Props { selectOpen: string | null; toggleSelect: (id: string) => void }
|
||||
let { selectOpen, toggleSelect }: Props = $props()
|
||||
|
||||
let showAuthPass = $state(false)
|
||||
let showSocksPass = $state(false)
|
||||
let secLoading = $state(false)
|
||||
let secError = $state<string | null>(null)
|
||||
let secSaved = $state<string | null>(null)
|
||||
let secLoaded = $state(false)
|
||||
|
||||
let authMode = $state(settingsState.settings.serverAuthMode ?? 'NONE')
|
||||
let authUsername = $state(settingsState.settings.serverAuthUser ?? '')
|
||||
let authPassword = $state('')
|
||||
|
||||
let socksEnabled = $state(settingsState.settings.socksProxyEnabled ?? false)
|
||||
let socksHost = $state(settingsState.settings.socksProxyHost ?? '')
|
||||
let socksPort = $state(settingsState.settings.socksProxyPort ?? '1080')
|
||||
let socksVersion = $state(settingsState.settings.socksProxyVersion ?? 5)
|
||||
let socksUsername = $state(settingsState.settings.socksProxyUsername ?? '')
|
||||
let socksPassword = $state(settingsState.settings.socksProxyPassword ?? '')
|
||||
|
||||
let flareEnabled = $state(settingsState.settings.flareSolverrEnabled ?? false)
|
||||
let flareUrl = $state(settingsState.settings.flareSolverrUrl ?? 'http://localhost:8191')
|
||||
let flareTimeout = $state(settingsState.settings.flareSolverrTimeout ?? 60)
|
||||
let flareSession = $state(settingsState.settings.flareSolverrSessionName ?? 'moku')
|
||||
let flareTtl = $state(settingsState.settings.flareSolverrSessionTtl ?? 15)
|
||||
let flareFallback = $state(settingsState.settings.flareSolverrAsResponseFallback ?? false)
|
||||
|
||||
function normalizeAuthMode(mode: string): 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN' {
|
||||
if (mode === 'BASIC_AUTH' || mode === 'UI_LOGIN' || mode === 'NONE') return mode
|
||||
return 'NONE'
|
||||
}
|
||||
|
||||
function showSaved(key: string) {
|
||||
secSaved = key; secError = null
|
||||
setTimeout(() => { if (secSaved === key) secSaved = null }, 2000)
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!secLoaded) { secLoaded = true; authSession.clearTokens(); loadServerSecurity() }
|
||||
})
|
||||
|
||||
async function loadServerSecurity() {
|
||||
try {
|
||||
const s = await requestManager.extensions.getServerSecurity()
|
||||
const serverMode = normalizeAuthMode(s.authMode)
|
||||
if (serverMode !== 'UI_LOGIN') authSession.clearTokens()
|
||||
authMode = serverMode
|
||||
authUsername = s.authUsername || ''
|
||||
updateSettings({ serverAuthMode: serverMode, serverAuthUser: authUsername })
|
||||
socksEnabled = s.socksProxyEnabled; socksHost = s.socksProxyHost
|
||||
socksPort = s.socksProxyPort; socksVersion = s.socksProxyVersion
|
||||
socksUsername = s.socksProxyUsername
|
||||
flareEnabled = s.flareSolverrEnabled; flareUrl = s.flareSolverrUrl
|
||||
flareTimeout = s.flareSolverrTimeout; flareSession = s.flareSolverrSessionName
|
||||
flareTtl = s.flareSolverrSessionTtl; flareFallback = s.flareSolverrAsResponseFallback
|
||||
updateSettings({
|
||||
socksProxyEnabled: socksEnabled, socksProxyHost: socksHost, socksProxyPort: socksPort,
|
||||
socksProxyVersion: socksVersion, socksProxyUsername: socksUsername,
|
||||
flareSolverrEnabled: flareEnabled, flareSolverrUrl: flareUrl,
|
||||
flareSolverrTimeout: flareTimeout, flareSolverrSessionName: flareSession,
|
||||
flareSolverrSessionTtl: flareTtl, flareSolverrAsResponseFallback: flareFallback,
|
||||
})
|
||||
} catch {}
|
||||
}
|
||||
|
||||
async function saveAuth() {
|
||||
if (authMode === 'NONE') { await clearAuth(); return }
|
||||
if (!authUsername.trim() || !authPassword.trim()) { secError = 'Username and password are required'; return }
|
||||
secLoading = true; secError = null
|
||||
const prev = { mode: settingsState.settings.serverAuthMode, user: settingsState.settings.serverAuthUser, pass: settingsState.settings.serverAuthPass }
|
||||
try {
|
||||
const newUser = authUsername.trim()
|
||||
const newPass = authPassword.trim()
|
||||
authSession.clearTokens()
|
||||
if (authMode === 'UI_LOGIN') {
|
||||
await loginUI(newUser, newPass)
|
||||
updateSettings({ serverAuthMode: 'UI_LOGIN', serverAuthUser: newUser, serverAuthPass: '' })
|
||||
} else {
|
||||
updateSettings({ serverAuthMode: 'BASIC_AUTH', serverAuthUser: newUser, serverAuthPass: newPass })
|
||||
}
|
||||
await requestManager.extensions.setServerAuth({ authMode, authUsername: newUser, authPassword: newPass })
|
||||
authPassword = ''
|
||||
showSaved('auth')
|
||||
} catch (e: any) {
|
||||
const msg = e?.message ?? 'Failed to save authentication settings'
|
||||
const authMismatch = /unauthorized|unauthenticated|authentication|401/i.test(msg)
|
||||
if (!authMismatch) {
|
||||
authSession.clearTokens()
|
||||
updateSettings({ serverAuthMode: prev.mode, serverAuthUser: prev.user, serverAuthPass: prev.pass })
|
||||
}
|
||||
secError = authMismatch
|
||||
? 'Saved local auth settings, but the server rejected the update. Verify your new credentials with the current server configuration.'
|
||||
: msg
|
||||
} finally { secLoading = false }
|
||||
}
|
||||
|
||||
async function clearAuth() {
|
||||
secLoading = true; secError = null
|
||||
const prev = { mode: settingsState.settings.serverAuthMode, user: settingsState.settings.serverAuthUser, pass: settingsState.settings.serverAuthPass }
|
||||
try {
|
||||
await requestManager.extensions.setServerAuth({ authMode: 'NONE', authUsername: '', authPassword: '' })
|
||||
updateSettings({ serverAuthMode: 'NONE', serverAuthUser: '', serverAuthPass: '' })
|
||||
authMode = 'NONE'; authUsername = ''; authPassword = ''
|
||||
authSession.clearTokens(); showSaved('auth')
|
||||
} catch (e: any) {
|
||||
updateSettings({ serverAuthMode: prev.mode, serverAuthUser: prev.user, serverAuthPass: prev.pass })
|
||||
secError = e?.message ?? 'Failed to disable authentication'
|
||||
} finally { secLoading = false }
|
||||
}
|
||||
|
||||
async function saveSocksProxy() {
|
||||
secLoading = true; secError = null
|
||||
try {
|
||||
await requestManager.extensions.setSocksProxy({
|
||||
socksProxyEnabled: socksEnabled, socksProxyHost: socksHost.trim(),
|
||||
socksProxyPort: socksPort.trim(), socksProxyVersion: socksVersion,
|
||||
socksProxyUsername: socksUsername.trim(), socksProxyPassword: socksPassword.trim(),
|
||||
})
|
||||
updateSettings({
|
||||
socksProxyEnabled: socksEnabled, socksProxyHost: socksHost,
|
||||
socksProxyPort: socksPort, socksProxyVersion: socksVersion,
|
||||
socksProxyUsername: socksUsername, socksProxyPassword: socksPassword,
|
||||
})
|
||||
showSaved('socks')
|
||||
} catch (e: any) {
|
||||
secError = e?.message ?? 'Failed to save SOCKS proxy'
|
||||
} finally { secLoading = false }
|
||||
}
|
||||
|
||||
async function saveFlareSolverr() {
|
||||
secLoading = true; secError = null
|
||||
try {
|
||||
await requestManager.extensions.setFlareSolverr({
|
||||
flareSolverrEnabled: flareEnabled, flareSolverrUrl: flareUrl.trim(),
|
||||
flareSolverrTimeout: flareTimeout, flareSolverrSessionName: flareSession.trim(),
|
||||
flareSolverrSessionTtl: flareTtl, flareSolverrAsResponseFallback: flareFallback,
|
||||
})
|
||||
updateSettings({
|
||||
flareSolverrEnabled: flareEnabled, flareSolverrUrl: flareUrl,
|
||||
flareSolverrTimeout: flareTimeout, flareSolverrSessionName: flareSession,
|
||||
flareSolverrSessionTtl: flareTtl, flareSolverrAsResponseFallback: flareFallback,
|
||||
})
|
||||
showSaved('flare')
|
||||
} catch (e: any) {
|
||||
secError = e?.message ?? 'Failed to save FlareSolverr'
|
||||
} finally { secLoading = false }
|
||||
}
|
||||
|
||||
function forceResetAuth() {
|
||||
authSession.clearTokens()
|
||||
authMode = 'NONE'
|
||||
authUsername = ''
|
||||
authPassword = ''
|
||||
updateSettings({ serverAuthMode: 'NONE', serverAuthUser: '', serverAuthPass: '' })
|
||||
showSaved('auth')
|
||||
}
|
||||
|
||||
const EyeOpen = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>`
|
||||
const EyeClose = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/></svg>`
|
||||
</script>
|
||||
|
||||
<div class="s-panel">
|
||||
|
||||
{#if secError}
|
||||
<div class="s-banner s-banner-error">{secError}</div>
|
||||
{/if}
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">
|
||||
Server Authentication
|
||||
<span class="s-pill" class:on={settingsState.settings.serverAuthMode === 'BASIC_AUTH' || settingsState.settings.serverAuthMode === 'UI_LOGIN'}>
|
||||
{settingsState.settings.serverAuthMode === 'BASIC_AUTH' ? 'Basic Auth' :
|
||||
settingsState.settings.serverAuthMode === 'UI_LOGIN' ? 'UI Login' : 'Disabled'}
|
||||
</span>
|
||||
</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Mode</span><span class="s-desc">How Moku authenticates with the server</span></div>
|
||||
<div class="s-segment">
|
||||
{#each [{ value: 'NONE', label: 'None' }, { value: 'BASIC_AUTH', label: 'Basic' }, { value: 'UI_LOGIN', label: 'UI Login' }] as opt}
|
||||
<button class="s-segment-btn" class:active={authMode === opt.value}
|
||||
onclick={() => authMode = opt.value as any} disabled={secLoading}>{opt.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{#if authMode !== 'NONE'}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Username</span></div>
|
||||
<input class="s-input" bind:value={authUsername} placeholder="Username" autocomplete="off" spellcheck="false" disabled={secLoading} />
|
||||
</div>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Password</span></div>
|
||||
<div class="s-field-wrap">
|
||||
<input class="s-input" type={showAuthPass ? 'text' : 'password'} bind:value={authPassword} placeholder="Password" autocomplete="off" spellcheck="false" disabled={secLoading} />
|
||||
<button class="s-eye-btn" onclick={() => showAuthPass = !showAuthPass} tabindex="-1" aria-label={showAuthPass ? 'Hide password' : 'Show password'}>{@html showAuthPass ? EyeClose : EyeOpen}</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if settingsState.settings.serverAuthMode === 'BASIC_AUTH'}
|
||||
<div class="s-row">
|
||||
<span class="s-desc">Images are proxied through Tauri when Basic Auth is active, which reduces loading speed.</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<button class="s-ghost-btn" onclick={forceResetAuth} disabled={secLoading} title="Force reset local auth state">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
<div class="s-btn-row">
|
||||
{#if settingsState.settings.serverAuthMode !== 'NONE'}
|
||||
<button class="s-btn s-btn-danger" onclick={clearAuth} disabled={secLoading}>
|
||||
{secLoading ? 'Saving…' : 'Disable'}
|
||||
</button>
|
||||
{/if}
|
||||
<button class="s-btn s-btn-accent" onclick={saveAuth}
|
||||
disabled={secLoading || ((authMode === 'BASIC_AUTH' || authMode === 'UI_LOGIN') && (!authUsername.trim() || !authPassword.trim()))}>
|
||||
{secLoading ? 'Saving…' : secSaved === 'auth' ? 'Saved ✓' : settingsState.settings.serverAuthMode === 'BASIC_AUTH' ? 'Update' : authMode === 'NONE' ? 'Save' : 'Enable'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">SOCKS Proxy</p>
|
||||
<div class="s-section-body">
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Enable SOCKS proxy</span><span class="s-desc">Route Suwayomi traffic through a SOCKS4/5 proxy</span></div>
|
||||
<button role="switch" aria-checked={socksEnabled} aria-label="Enable SOCKS proxy" class="s-toggle" class:on={socksEnabled}
|
||||
onclick={() => { socksEnabled = !socksEnabled; saveSocksProxy() }}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
{#if socksEnabled}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Version</span></div>
|
||||
<div class="s-select" id="socks-ver">
|
||||
<button class="s-select-btn" onclick={() => toggleSelect('socks-ver')}>
|
||||
<span>SOCKS{socksVersion}</span>
|
||||
<svg class="s-select-caret" class:open={selectOpen === 'socks-ver'} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||
</button>
|
||||
{#if selectOpen === 'socks-ver'}
|
||||
<div class="s-select-menu">
|
||||
{#each [[4, 'SOCKS4'], [5, 'SOCKS5']] as [v, l]}
|
||||
<button class="s-select-option" class:active={socksVersion === v} onclick={() => { socksVersion = v as number; toggleSelect('socks-ver') }}>{l}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Host</span></div>
|
||||
<input class="s-input" bind:value={socksHost} placeholder="127.0.0.1" autocomplete="off" spellcheck="false" />
|
||||
</div>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Port</span></div>
|
||||
<input class="s-input" style="width:80px" bind:value={socksPort} placeholder="1080" autocomplete="off" spellcheck="false" />
|
||||
</div>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Username</span><span class="s-desc">Optional</span></div>
|
||||
<input class="s-input" bind:value={socksUsername} placeholder="Username" autocomplete="off" spellcheck="false" />
|
||||
</div>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Password</span><span class="s-desc">Optional</span></div>
|
||||
<div class="s-field-wrap">
|
||||
<input class="s-input" type={showSocksPass ? 'text' : 'password'} bind:value={socksPassword} placeholder="Password" autocomplete="off" spellcheck="false" />
|
||||
<button class="s-eye-btn" onclick={() => showSocksPass = !showSocksPass} tabindex="-1" aria-label={showSocksPass ? 'Hide password' : 'Show password'}>{@html showSocksPass ? EyeClose : EyeOpen}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"></div>
|
||||
<button class="s-btn s-btn-accent" onclick={saveSocksProxy} disabled={secLoading}>
|
||||
{secLoading ? 'Saving…' : secSaved === 'socks' ? 'Saved ✓' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">FlareSolverr</p>
|
||||
<div class="s-section-body">
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Enable FlareSolverr</span><span class="s-desc">Bypass Cloudflare challenges for sources that require it</span></div>
|
||||
<button role="switch" aria-checked={flareEnabled} aria-label="Enable FlareSolverr" class="s-toggle" class:on={flareEnabled}
|
||||
onclick={() => { flareEnabled = !flareEnabled; saveFlareSolverr() }}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
{#if flareEnabled}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">URL</span><span class="s-desc">FlareSolverr instance address</span></div>
|
||||
<input class="s-input" bind:value={flareUrl} placeholder="http://localhost:8191" autocomplete="off" spellcheck="false" />
|
||||
</div>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Timeout</span><span class="s-desc">Max wait per request, in seconds</span></div>
|
||||
<div class="s-stepper">
|
||||
<button class="s-step-btn" onclick={() => flareTimeout = Math.max(10, flareTimeout - 10)}>−</button>
|
||||
<span class="s-step-val">{flareTimeout}s</span>
|
||||
<button class="s-step-btn" onclick={() => flareTimeout = Math.min(300, flareTimeout + 10)}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Session name</span><span class="s-desc">Reuse browser session across requests</span></div>
|
||||
<input class="s-input" bind:value={flareSession} placeholder="moku" autocomplete="off" spellcheck="false" />
|
||||
</div>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Session TTL</span><span class="s-desc">Minutes before session is refreshed</span></div>
|
||||
<div class="s-stepper">
|
||||
<button class="s-step-btn" onclick={() => flareTtl = Math.max(1, flareTtl - 1)}>−</button>
|
||||
<span class="s-step-val">{flareTtl}m</span>
|
||||
<button class="s-step-btn" onclick={() => flareTtl = Math.min(60, flareTtl + 1)}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
<label class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Response fallback</span><span class="s-desc">Use FlareSolverr's response when the direct request fails</span></div>
|
||||
<button role="switch" aria-checked={flareFallback} aria-label="Response fallback" class="s-toggle" class:on={flareFallback}
|
||||
onclick={() => flareFallback = !flareFallback}><span class="s-toggle-thumb"></span></button>
|
||||
</label>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"></div>
|
||||
<button class="s-btn s-btn-accent" onclick={saveFlareSolverr} disabled={secLoading}>
|
||||
{secLoading ? 'Saving…' : secSaved === 'flare' ? 'Saved ✓' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.s-ghost-btn { display: inline-flex; align-items: center; gap: 5px; background: none; border: none; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); cursor: pointer; padding: 2px 0; transition: color 0.15s; }
|
||||
.s-ghost-btn:hover:not(:disabled) { color: var(--color-error); }
|
||||
.s-ghost-btn:disabled { opacity: 0.35; cursor: default; }
|
||||
</style>
|
||||
@@ -0,0 +1,869 @@
|
||||
<script lang="ts">
|
||||
import { Trash, ClockCounterClockwise } from 'phosphor-svelte'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { untrack } from 'svelte'
|
||||
import { appState } from '$lib/state/app.svelte'
|
||||
import { toast } from '$lib/state/notifications.svelte'
|
||||
import { settingsState, updateSettings } from '$lib/state/settings.svelte'
|
||||
import { exportAppData, importAppData } from '$lib/core/backup'
|
||||
import { loadBackups, persistBackups, persistSettings, persistLibrary } from '$lib/core/persistence/persist'
|
||||
import type { BackupEntry } from '$lib/core/persistence/persist'
|
||||
import { DEFAULT_SETTINGS } from '$lib/types/settings'
|
||||
import { DEFAULT_READING_STATS } from '$lib/types/history'
|
||||
import { clearBlobCache } from '$lib/core/cache/imageCache'
|
||||
import { clearPageCache } from '$lib/request-manager'
|
||||
import { cache as queryCache } from '$lib/core/cache/queryCache'
|
||||
|
||||
type ResetState = 'idle' | 'busy' | 'done' | 'error'
|
||||
interface ResetItem { key: string; label: string; desc: string; state: ResetState; error: string | null; confirm: boolean }
|
||||
|
||||
let resetItems = $state<ResetItem[]>([
|
||||
{ key: 'all-cache', label: 'Clear all caches', desc: 'Flushes the image blob cache, page cache, query cache, Moku disk cache, Suwayomi disk cache, and server image/thumbnail cache in one pass.', state: 'idle', error: null, confirm: false },
|
||||
{ key: 'reading-history', label: 'Clear reading history', desc: 'Erases chapter history, read log, reading stats, and daily read counts.', state: 'idle', error: null, confirm: true },
|
||||
{ key: 'moku-settings', label: 'Reset Moku settings', desc: 'Restores all app settings to their defaults. Does not affect library data.', state: 'idle', error: null, confirm: true },
|
||||
{ key: 'suwayomi-data', label: 'Reset Suwayomi data', desc: 'Deletes the database, extensions, settings, and logs. Downloads and backups are preserved.', state: 'idle', error: null, confirm: true },
|
||||
])
|
||||
|
||||
let confirming = $state<string | null>(null)
|
||||
|
||||
function patchReset(key: string, update: Partial<ResetItem>) {
|
||||
resetItems = resetItems.map(i => i.key === key ? { ...i, ...update } : i)
|
||||
}
|
||||
|
||||
function showExitCountdown(): Promise<void> {
|
||||
return new Promise(resolve => {
|
||||
const backdrop = document.createElement('div')
|
||||
backdrop.className = 's-backdrop'
|
||||
backdrop.style.cssText = 'z-index:99999'
|
||||
const modal = document.createElement('div')
|
||||
modal.style.cssText = 'background:var(--bg-surface);border:1px solid var(--border-base);border-radius:var(--radius-2xl);box-shadow:0 0 0 1px rgba(255,255,255,0.04) inset,0 24px 80px rgba(0,0,0,0.7);width:min(400px,calc(100vw - 40px));display:flex;flex-direction:column;overflow:hidden;animation:s-scale-in 0.2s cubic-bezier(0.16,1,0.3,1) both'
|
||||
const header = document.createElement('div')
|
||||
header.style.cssText = 'padding:var(--sp-4) var(--sp-5) var(--sp-3);border-bottom:1px solid var(--border-dim)'
|
||||
const title = document.createElement('p')
|
||||
title.style.cssText = 'margin:0;font-size:var(--text-sm);font-weight:var(--weight-medium);color:var(--text-primary);letter-spacing:0.01em'
|
||||
title.textContent = 'Reset complete'
|
||||
header.appendChild(title)
|
||||
const body = document.createElement('div')
|
||||
body.style.cssText = 'padding:var(--sp-4) var(--sp-5);display:flex;flex-direction:column;gap:var(--sp-2)'
|
||||
const sub = document.createElement('p')
|
||||
sub.style.cssText = 'margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-faint);letter-spacing:var(--tracking-wide);line-height:var(--leading-snug)'
|
||||
sub.textContent = 'Moku will close so you can relaunch with the reset applied.'
|
||||
const counter = document.createElement('p')
|
||||
counter.style.cssText = 'margin:0;font-family:var(--font-ui);font-size:var(--text-xs);color:var(--text-faint);letter-spacing:var(--tracking-wide)'
|
||||
counter.textContent = 'Closing in 3…'
|
||||
body.append(sub, counter)
|
||||
const footer = document.createElement('div')
|
||||
footer.style.cssText = 'padding:var(--sp-3) var(--sp-5);border-top:1px solid var(--border-dim);display:flex;justify-content:flex-end'
|
||||
const btn = document.createElement('button')
|
||||
btn.className = 's-btn s-btn-danger'
|
||||
btn.textContent = 'Close now'
|
||||
footer.appendChild(btn)
|
||||
modal.append(header, body, footer)
|
||||
backdrop.appendChild(modal)
|
||||
document.body.appendChild(backdrop)
|
||||
let secs = 3
|
||||
const tick = setInterval(() => {
|
||||
secs--
|
||||
counter.textContent = secs > 0 ? `Closing in ${secs}…` : 'Closing…'
|
||||
if (secs <= 0) { clearInterval(tick); backdrop.remove(); resolve() }
|
||||
}, 1000)
|
||||
btn.addEventListener('click', () => { clearInterval(tick); backdrop.remove(); resolve() })
|
||||
})
|
||||
}
|
||||
|
||||
async function clearAllCaches(): Promise<void> {
|
||||
clearBlobCache()
|
||||
clearPageCache()
|
||||
queryCache.clearAll()
|
||||
await Promise.all([
|
||||
invoke('clear_moku_cache'),
|
||||
invoke('clear_suwayomi_cache'),
|
||||
gql(`mutation { clearCachedImages(input: { cachedPages: true, cachedThumbnails: true, downloadedThumbnails: false }) { cachedPages cachedThumbnails } }`),
|
||||
])
|
||||
}
|
||||
|
||||
async function runReset(key: string) {
|
||||
confirming = null
|
||||
patchReset(key, { state: 'busy', error: null })
|
||||
try {
|
||||
switch (key) {
|
||||
case 'all-cache':
|
||||
await clearAllCaches()
|
||||
break
|
||||
case 'reading-history':
|
||||
await persistLibrary({ history: [], bookmarks: [], markers: [], readLog: [], readingStats: DEFAULT_READING_STATS, dailyReadCounts: {} })
|
||||
break
|
||||
case 'moku-settings':
|
||||
localStorage.clear()
|
||||
await persistSettings({ settings: DEFAULT_SETTINGS, storeVersion: 1 })
|
||||
patchReset(key, { state: 'done' })
|
||||
await showExitCountdown()
|
||||
invoke('exit_app')
|
||||
return
|
||||
case 'suwayomi-data':
|
||||
localStorage.clear()
|
||||
await invoke('reset_suwayomi_data')
|
||||
patchReset(key, { state: 'done' })
|
||||
await showExitCountdown()
|
||||
invoke('exit_app')
|
||||
return
|
||||
}
|
||||
patchReset(key, { state: 'done' })
|
||||
setTimeout(() => patchReset(key, { state: 'idle' }), 3000)
|
||||
} catch (e: any) {
|
||||
patchReset(key, { state: 'error', error: e?.message ?? String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
interface StorageInfo { manga_bytes: number; total_bytes: number; free_bytes: number; path: string }
|
||||
|
||||
const isExternalServer = $derived.by(() => {
|
||||
const url = (settingsState.settings.serverUrl ?? 'http://localhost:4567').toLowerCase().trim()
|
||||
try {
|
||||
const host = new URL(url).hostname
|
||||
return host !== 'localhost' && host !== '127.0.0.1' && host !== '::1'
|
||||
} catch { return false }
|
||||
})
|
||||
|
||||
let storageInfo = $state<StorageInfo | null>(null)
|
||||
let storageLoading = $state(false)
|
||||
let storageError = $state<string | null>(null)
|
||||
|
||||
let downloadsPathInput = $state(settingsState.settings.serverDownloadsPath ?? '')
|
||||
let localSourcePathInput = $state(settingsState.settings.serverLocalSourcePath ?? '')
|
||||
let pathsSaving = $state(false)
|
||||
let pathsError = $state<string | null>(null)
|
||||
let pathsFieldError = $state<{ dl?: string; loc?: string }>({})
|
||||
let pathsSaved = $state(false)
|
||||
|
||||
let defaultDownloadsPath = $state('')
|
||||
$effect(() => {
|
||||
if (!isExternalServer) {
|
||||
invoke<string>('get_default_downloads_path').then(p => { defaultDownloadsPath = p })
|
||||
} else {
|
||||
defaultDownloadsPath = ''
|
||||
}
|
||||
})
|
||||
|
||||
let confirmedDownloadsPath = $state(settingsState.settings.serverDownloadsPath ?? '')
|
||||
let confirmedLocalSourcePath = $state(settingsState.settings.serverLocalSourcePath ?? '')
|
||||
|
||||
let migrateFrom = $state<string | null>(null)
|
||||
let migrateTo = $state<string | null>(null)
|
||||
let migrating = $state(false)
|
||||
let migrateProgress = $state<{ done: number; total: number; current: string } | null>(null)
|
||||
let migrateError = $state<string | null>(null)
|
||||
let migrateUnlisten: (() => void) | null = null
|
||||
|
||||
let extraScanDirs = $state<string[]>([...(settingsState.settings.extraScanDirs ?? [])])
|
||||
let newScanDir = $state('')
|
||||
let multiStorageInfos = $state<(StorageInfo & { label: string })[]>([])
|
||||
let advStorageOpen = $state(false)
|
||||
let backupSectionOpen = $state(false)
|
||||
let resetSectionOpen = $state(false)
|
||||
|
||||
async function fetchStorage() {
|
||||
storageLoading = true; storageError = null
|
||||
try {
|
||||
const pathData = await gql<{ downloadsPath: string | null; localSourcePath: string | null }>(
|
||||
`{ downloadsPath localSourcePath }`
|
||||
)
|
||||
const dl = pathData.downloadsPath ?? ''
|
||||
const loc = pathData.localSourcePath ?? ''
|
||||
downloadsPathInput = dl; localSourcePathInput = loc
|
||||
confirmedDownloadsPath = dl; confirmedLocalSourcePath = loc
|
||||
updateSettings({ serverDownloadsPath: dl, serverLocalSourcePath: loc })
|
||||
if (isExternalServer) { multiStorageInfos = []; storageInfo = null; return }
|
||||
const effectiveDl = dl || defaultDownloadsPath
|
||||
const dirsToScan: { path: string; label: string }[] = []
|
||||
if (effectiveDl) dirsToScan.push({ path: effectiveDl, label: dl ? 'Downloads' : 'Downloads (default)' })
|
||||
if (loc && loc !== effectiveDl) dirsToScan.push({ path: loc, label: 'Local source' })
|
||||
for (const p of extraScanDirs) {
|
||||
if (p && !dirsToScan.find(d => d.path === p)) dirsToScan.push({ path: p, label: p })
|
||||
}
|
||||
if (dirsToScan.length === 0) { multiStorageInfos = []; storageInfo = null; return }
|
||||
const results = await Promise.allSettled(
|
||||
dirsToScan.map(d => invoke<StorageInfo>('get_storage_info', { downloadsPath: d.path }).then(info => ({ ...info, label: d.label })))
|
||||
)
|
||||
multiStorageInfos = results
|
||||
.filter((r): r is PromiseFulfilledResult<StorageInfo & { label: string }> => r.status === 'fulfilled')
|
||||
.map(r => r.value)
|
||||
storageInfo = multiStorageInfos[0] ?? null
|
||||
} catch (e: any) {
|
||||
storageError = e instanceof Error ? e.message : String(e)
|
||||
} finally { storageLoading = false }
|
||||
}
|
||||
|
||||
async function validatePath(path: string): Promise<string | null> {
|
||||
if (!path.trim()) return null
|
||||
if (isExternalServer) return null
|
||||
try {
|
||||
const exists = await invoke<boolean>('check_path_exists', { path: path.trim() })
|
||||
return exists ? null : 'Directory does not exist'
|
||||
} catch { return 'Could not check path' }
|
||||
}
|
||||
|
||||
async function createDirectory(path: string): Promise<void> {
|
||||
if (isExternalServer) throw new Error('Cannot create directories on an external server')
|
||||
await invoke('create_directory', { path })
|
||||
}
|
||||
|
||||
async function savePaths() {
|
||||
const dl = downloadsPathInput.trim()
|
||||
const loc = localSourcePathInput.trim()
|
||||
pathsError = null; pathsFieldError = {}
|
||||
const [dlErr, locErr] = await Promise.all([validatePath(dl), validatePath(loc)])
|
||||
if (dlErr || locErr) { pathsFieldError = { ...(dlErr ? { dl: dlErr } : {}), ...(locErr ? { loc: locErr } : {}) }; return }
|
||||
pathsSaving = true
|
||||
try {
|
||||
await gql(`mutation($path: String!) { setDownloadsPath(input: { location: $path }) { location } }`, { path: dl })
|
||||
if (loc) await gql(`mutation($path: String!) { setLocalSourcePath(input: { location: $path }) { location } }`, { path: loc })
|
||||
updateSettings({ serverDownloadsPath: dl, serverLocalSourcePath: loc })
|
||||
if (!isExternalServer) {
|
||||
const oldDl = confirmedDownloadsPath || defaultDownloadsPath
|
||||
const newDl = dl || defaultDownloadsPath
|
||||
if (newDl && oldDl && newDl !== oldDl) {
|
||||
const hadContent = await invoke<boolean>('check_path_exists', { path: oldDl })
|
||||
if (hadContent) { migrateFrom = oldDl; migrateTo = newDl }
|
||||
}
|
||||
}
|
||||
confirmedDownloadsPath = dl; confirmedLocalSourcePath = loc
|
||||
pathsSaved = true; setTimeout(() => pathsSaved = false, 2000)
|
||||
await fetchStorage()
|
||||
} catch (e: any) {
|
||||
pathsError = e?.message ?? 'Failed to save paths'
|
||||
} finally { pathsSaving = false }
|
||||
}
|
||||
|
||||
async function startMigration() {
|
||||
if (!migrateFrom || !migrateTo) return
|
||||
migrating = true; migrateError = null; migrateProgress = { done: 0, total: 0, current: '' }
|
||||
const { listen: tauriListen } = await import('@tauri-apps/api/event')
|
||||
migrateUnlisten = await tauriListen<{ done: number; total: number; current: string }>(
|
||||
'migrate_progress', e => { migrateProgress = e.payload }
|
||||
)
|
||||
try {
|
||||
await invoke('migrate_downloads', { src: migrateFrom, dst: migrateTo })
|
||||
migrateFrom = null; migrateTo = null; migrateProgress = null
|
||||
await fetchStorage()
|
||||
} catch (e: any) {
|
||||
migrateError = e?.message ?? 'Migration failed'
|
||||
} finally { migrating = false; migrateUnlisten?.(); migrateUnlisten = null }
|
||||
}
|
||||
|
||||
function dismissMigration() { migrateFrom = null; migrateTo = null; migrateError = null; migrateProgress = null }
|
||||
|
||||
async function browseDownloadsFolder() {
|
||||
const picked = await invoke<string | null>('pick_downloads_folder')
|
||||
if (picked) { downloadsPathInput = picked; pathsFieldError = { ...pathsFieldError, dl: undefined }; await savePaths() }
|
||||
}
|
||||
|
||||
async function browseLocalSourceFolder() {
|
||||
const picked = await invoke<string | null>('pick_downloads_folder')
|
||||
if (picked) { localSourcePathInput = picked; pathsFieldError = { ...pathsFieldError, loc: undefined }; await savePaths() }
|
||||
}
|
||||
|
||||
async function browseExtraScanDir() {
|
||||
const picked = await invoke<string | null>('pick_downloads_folder')
|
||||
if (picked) { newScanDir = picked; addExtraScanDir() }
|
||||
}
|
||||
|
||||
function addExtraScanDir() {
|
||||
const dir = newScanDir.trim()
|
||||
if (!dir || extraScanDirs.includes(dir)) return
|
||||
extraScanDirs = [...extraScanDirs, dir]
|
||||
updateSettings({ extraScanDirs }); newScanDir = ''; fetchStorage()
|
||||
}
|
||||
|
||||
function removeExtraScanDir(path: string) {
|
||||
extraScanDirs = extraScanDirs.filter(d => d !== path)
|
||||
updateSettings({ extraScanDirs }); fetchStorage()
|
||||
}
|
||||
|
||||
function fmtBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(1024))
|
||||
return `${(bytes / Math.pow(1024, i)).toFixed(i >= 2 ? 1 : 0)} ${units[i]}`
|
||||
}
|
||||
|
||||
let backupLoading = $state(false)
|
||||
let backupError = $state<string | null>(null)
|
||||
let backupList = $state<(BackupEntry & { deleting?: boolean })[]>([])
|
||||
|
||||
async function loadBackupList() {
|
||||
backupList = (await loadBackups()).map(b => ({ ...b }))
|
||||
}
|
||||
|
||||
async function saveBackupList() {
|
||||
await persistBackups(backupList.map(({ url, name }) => ({ url, name })))
|
||||
}
|
||||
|
||||
async function createBackup() {
|
||||
backupLoading = true; backupError = null
|
||||
try {
|
||||
const data = await gql<{ createBackup: { url: string } }>(`mutation { createBackup { url } }`)
|
||||
const { url } = data.createBackup
|
||||
const name = url.split('/').pop() ?? url
|
||||
backupList = [{ url, name }, ...backupList]
|
||||
await saveBackupList()
|
||||
} catch (e: any) { backupError = e?.message ?? 'Failed to create backup' }
|
||||
finally { backupLoading = false }
|
||||
}
|
||||
|
||||
async function deleteBackup(url: string) {
|
||||
backupList = backupList.map(b => b.url === url ? { ...b, deleting: true } : b)
|
||||
try {
|
||||
await fetch(`${serverUrl()}${url}`, { method: 'DELETE', headers: buildAuthHeaders() })
|
||||
backupList = backupList.filter(b => b.url !== url)
|
||||
await saveBackupList()
|
||||
} catch (e: any) {
|
||||
backupList = backupList.map(b => b.url === url ? { ...b, deleting: false } : b)
|
||||
backupError = e?.message ?? 'Failed to delete backup'
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadBackup(backup: BackupEntry) {
|
||||
try {
|
||||
const resp = await fetch(`${serverUrl()}${backup.url}`, { headers: buildAuthHeaders() })
|
||||
if (!resp.ok) throw new Error(`Server returned ${resp.status}`)
|
||||
const blob = await resp.blob()
|
||||
if ('showSaveFilePicker' in window) {
|
||||
try {
|
||||
const handle = await (window as any).showSaveFilePicker({
|
||||
suggestedName: backup.name,
|
||||
types: [{ description: 'Backup file', accept: { 'application/octet-stream': ['.tachibk', '.proto.gz'] } }],
|
||||
})
|
||||
const writable = await handle.createWritable()
|
||||
await writable.write(blob); await writable.close()
|
||||
toast({ kind: 'success', message: 'Backup saved', detail: backup.name }); return
|
||||
} catch (pickerErr: any) { if (pickerErr?.name === 'AbortError') return }
|
||||
}
|
||||
const objectUrl = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = objectUrl; a.download = backup.name
|
||||
document.body.appendChild(a); a.click(); document.body.removeChild(a)
|
||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 5000)
|
||||
toast({ kind: 'download', message: 'Backup downloaded', detail: backup.name })
|
||||
} catch (e: any) { backupError = e?.message ?? 'Failed to download backup' }
|
||||
}
|
||||
|
||||
let restoreLoading = $state(false)
|
||||
let restoreError = $state<string | null>(null)
|
||||
let restoreJobId = $state<string | null>(null)
|
||||
let restoreStatus = $state<{ mangaProgress: number; state: string; totalManga: number } | null>(null)
|
||||
let restorePollInterval = $state<ReturnType<typeof setInterval> | null>(null)
|
||||
let validateLoading = $state(false)
|
||||
let validateError = $state<string | null>(null)
|
||||
let validateResult = $state<{ missingSources: { id: string; name: string }[]; missingTrackers: { name: string }[] } | null>(null)
|
||||
let restoreFile = $state<File | null>(null)
|
||||
|
||||
function stopRestorePoll() {
|
||||
if (restorePollInterval) { clearInterval(restorePollInterval); restorePollInterval = null }
|
||||
}
|
||||
|
||||
async function pollRestoreStatus(id: string) {
|
||||
try {
|
||||
const data = await gql<{ restoreStatus: { mangaProgress: number; state: string; totalManga: number } }>(
|
||||
`query($id: String!) { restoreStatus(id: $id) { mangaProgress state totalManga } }`,
|
||||
{ id }
|
||||
)
|
||||
const status = data.restoreStatus
|
||||
restoreStatus = status
|
||||
if (status?.state === 'SUCCESS' || status?.state === 'FAILURE') stopRestorePoll()
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function buildBackupFormData(file: File, query: string, variables: Record<string, unknown>) {
|
||||
const form = new FormData()
|
||||
form.append('operations', JSON.stringify({ query, variables }))
|
||||
form.append('map', JSON.stringify({ '0': ['variables.backup'] }))
|
||||
form.append('0', file, file.name)
|
||||
return form
|
||||
}
|
||||
|
||||
function buildAuthHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = { Accept: 'application/json' }
|
||||
const pass = settingsState.settings.serverAuthPass ?? '', user = settingsState.settings.serverAuthUser ?? ''
|
||||
if (settingsState.settings.serverAuthMode === 'BASIC_AUTH' && user && pass)
|
||||
headers['Authorization'] = 'Basic ' + btoa(`${user}:${pass}`)
|
||||
return headers
|
||||
}
|
||||
|
||||
function serverUrl(): string {
|
||||
return (settingsState.settings.serverUrl ?? 'http://localhost:4567').replace(/\/$/, '')
|
||||
}
|
||||
|
||||
async function gql<T = unknown>(query: string, variables?: Record<string, unknown>): Promise<T> {
|
||||
const res = await fetch(`${serverUrl()}/api/graphql`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...buildAuthHeaders() },
|
||||
body: JSON.stringify({ query, variables }),
|
||||
})
|
||||
const json = await res.json()
|
||||
if (json.errors?.length) throw new Error(json.errors[0].message)
|
||||
return json.data as T
|
||||
}
|
||||
|
||||
async function submitRestore() {
|
||||
if (!restoreFile) return
|
||||
restoreLoading = true; restoreError = null; restoreStatus = null; restoreJobId = null
|
||||
stopRestorePoll()
|
||||
try {
|
||||
const resp = await fetch(`${serverUrl()}/api/graphql`, { method: 'POST', headers: buildAuthHeaders(), body: form })
|
||||
const json = await resp.json()
|
||||
if (json.errors?.length) throw new Error(json.errors[0].message)
|
||||
const result = json.data.restoreBackup
|
||||
restoreJobId = result.id; restoreStatus = result.status
|
||||
if (result.status?.state !== 'SUCCESS' && result.status?.state !== 'FAILURE')
|
||||
restorePollInterval = setInterval(() => pollRestoreStatus(result.id), 1500)
|
||||
} catch (e: any) { restoreError = e?.message ?? 'Failed to start restore' }
|
||||
finally { restoreLoading = false }
|
||||
}
|
||||
|
||||
async function submitValidate() {
|
||||
if (!restoreFile) return
|
||||
validateLoading = true; validateError = null; validateResult = null
|
||||
try {
|
||||
const form = buildBackupFormData(
|
||||
restoreFile,
|
||||
`query ValidateBackup($backup: Upload!) { validateBackup(input: { backup: $backup }) { missingSources { id name } missingTrackers { name } } }`,
|
||||
{ backup: null }
|
||||
)
|
||||
const resp = await fetch(`${serverUrl()}/api/graphql`, { method: 'POST', headers: buildAuthHeaders(), body: form })
|
||||
const json = await resp.json()
|
||||
if (json.errors?.length) throw new Error(json.errors[0].message)
|
||||
validateResult = json.data.validateBackup
|
||||
} catch (e: any) { validateError = e?.message ?? 'Failed to validate backup' }
|
||||
finally { validateLoading = false }
|
||||
}
|
||||
|
||||
let appDataExporting = $state(false)
|
||||
let appDataImporting = $state(false)
|
||||
let appDataError = $state<string | null>(null)
|
||||
let appDataMsg = $state<string | null>(null)
|
||||
let appDataBackupDir = $state<string | null>(null)
|
||||
|
||||
$effect(() => {
|
||||
invoke<string>('get_auto_backup_dir').then(d => { appDataBackupDir = d }).catch(() => {})
|
||||
})
|
||||
|
||||
async function handleExportAppData() {
|
||||
appDataExporting = true; appDataError = null; appDataMsg = null
|
||||
try {
|
||||
await exportAppData()
|
||||
appDataMsg = 'Backup saved.'
|
||||
setTimeout(() => appDataMsg = null, 3000)
|
||||
} catch (e: any) {
|
||||
if (String(e).includes('Cancelled')) return
|
||||
appDataError = e?.message ?? String(e)
|
||||
} finally { appDataExporting = false }
|
||||
}
|
||||
|
||||
async function handleImportAppData() {
|
||||
appDataImporting = true; appDataError = null; appDataMsg = null
|
||||
try {
|
||||
await importAppData()
|
||||
} catch (e: any) {
|
||||
if (String(e).includes('Cancelled')) { appDataImporting = false; return }
|
||||
appDataError = e?.message ?? String(e)
|
||||
appDataImporting = false
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => { untrack(() => { loadBackupList(); fetchStorage() }) })
|
||||
$effect(() => { return () => stopRestorePoll() })
|
||||
</script>
|
||||
|
||||
<div class="s-panel">
|
||||
|
||||
{#if migrateFrom && !isExternalServer}
|
||||
<div class="s-migrate-banner">
|
||||
<div class="s-migrate-body">
|
||||
<span class="s-migrate-title">Manga found at previous path — move to new location?</span>
|
||||
<span class="s-migrate-paths">{migrateFrom} → {migrateTo}</span>
|
||||
{#if migrateProgress && migrateProgress.total > 0}
|
||||
<div class="s-migrate-bar"><div class="s-migrate-fill" style="width:{Math.round((migrateProgress.done/migrateProgress.total)*100)}%"></div></div>
|
||||
<span class="s-migrate-paths">{migrateProgress.current} · {migrateProgress.done} / {migrateProgress.total}</span>
|
||||
{/if}
|
||||
{#if migrateError}<span class="s-desc" style="color:var(--color-error)">{migrateError}</span>{/if}
|
||||
</div>
|
||||
<div class="s-migrate-actions">
|
||||
<button class="s-btn s-btn-accent" onclick={startMigration} disabled={migrating}>
|
||||
{migrating ? (migrateProgress ? `Moving… ${migrateProgress.done}/${migrateProgress.total}` : 'Starting…') : 'Move files'}
|
||||
</button>
|
||||
<button class="s-btn" onclick={dismissMigration} disabled={migrating}>Skip</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">
|
||||
Disk Usage
|
||||
<button class="s-btn" onclick={fetchStorage} disabled={storageLoading}>{storageLoading ? '…' : '↻'}</button>
|
||||
</p>
|
||||
<div class="s-section-body">
|
||||
{#if storageLoading}
|
||||
<p class="s-empty">Reading filesystem…</p>
|
||||
{:else if storageError}
|
||||
<p class="s-empty" style="color:var(--color-error)">{storageError}</p>
|
||||
{:else if isExternalServer}
|
||||
<p class="s-empty">Disk usage is unavailable for external servers — filesystem access requires a local connection.</p>
|
||||
{:else if multiStorageInfos.length > 0}
|
||||
{#each multiStorageInfos as info}
|
||||
{@const limitGb = settingsState.settings.storageLimitGb ?? null}
|
||||
{@const limitBytes = limitGb !== null ? limitGb * 1024 ** 3 : null}
|
||||
{@const available = info.manga_bytes + info.free_bytes}
|
||||
{@const cap = limitBytes !== null ? Math.min(limitBytes, available) : available}
|
||||
{@const pct = cap > 0 ? Math.min(100, (info.manga_bytes / cap) * 100) : 0}
|
||||
<div class="s-storage-wrap">
|
||||
<div class="s-storage-header">
|
||||
<span class="s-storage-label">{info.label}</span>
|
||||
<span class="s-storage-used">{fmtBytes(info.manga_bytes)} of {fmtBytes(cap)}</span>
|
||||
</div>
|
||||
<div class="s-storage-bar">
|
||||
<div class="s-storage-fill" class:critical={pct > 90} class:warn={pct > 75 && pct <= 90} style="width:{pct}%"></div>
|
||||
</div>
|
||||
<div class="s-storage-footer">
|
||||
<span>{info.path}</span>
|
||||
<span>{fmtBytes(info.free_bytes)} free</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<p class="s-empty">No download path configured.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Downloads Path</p>
|
||||
<div class="s-section-body">
|
||||
{#if isExternalServer}
|
||||
<div class="s-row">
|
||||
<span class="s-desc">Connected to an external server. The path below is read from the server — changes here will update the server's config directly.</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="s-row" style="gap:var(--sp-2)">
|
||||
<input class="s-input full" class:error={!!pathsFieldError.dl}
|
||||
bind:value={downloadsPathInput}
|
||||
placeholder={isExternalServer ? 'Server default' : (defaultDownloadsPath || 'Default location')}
|
||||
spellcheck="false"
|
||||
onkeydown={(e) => e.key === 'Enter' && savePaths()}
|
||||
oninput={() => { pathsFieldError = { ...pathsFieldError, dl: undefined } }} />
|
||||
{#if !isExternalServer}
|
||||
<button class="s-btn" onclick={browseDownloadsFolder}>Browse</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
{#if pathsFieldError.dl}
|
||||
<span class="s-desc" style="color:var(--color-error)">{pathsFieldError.dl}</span>
|
||||
{/if}
|
||||
{#if pathsError}
|
||||
<span class="s-desc" style="color:var(--color-error)">{pathsError}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="s-btn-row">
|
||||
{#if pathsFieldError.dl && !isExternalServer}
|
||||
<button class="s-btn" onclick={async () => {
|
||||
try { await createDirectory(downloadsPathInput.trim()); pathsFieldError = { ...pathsFieldError, dl: undefined } }
|
||||
catch (e: any) { pathsFieldError = { ...pathsFieldError, dl: e?.message ?? 'Failed' } }
|
||||
}}>Create</button>
|
||||
{/if}
|
||||
{#if downloadsPathInput.trim() !== confirmedDownloadsPath}
|
||||
<button class="s-btn s-btn-accent" onclick={savePaths} disabled={pathsSaving}>
|
||||
{pathsSaved ? 'Saved ✓' : pathsSaving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Storage Limit</p>
|
||||
<div class="s-section-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Warn when limit is reached</span>
|
||||
<span class="s-desc">{settingsState.settings.storageLimitGb === null ? 'No limit set' : `Warn above ${settingsState.settings.storageLimitGb} GB`}</span>
|
||||
</div>
|
||||
{#if settingsState.settings.storageLimitGb === null}
|
||||
<button class="s-btn" onclick={() => updateSettings({ storageLimitGb: 10 })}>Set limit</button>
|
||||
{:else}
|
||||
<div class="s-stepper">
|
||||
<button class="s-step-btn" onclick={() => updateSettings({ storageLimitGb: Math.max(1, (settingsState.settings.storageLimitGb ?? 10) - 1) })} disabled={(settingsState.settings.storageLimitGb ?? 10) <= 1}>−</button>
|
||||
<input type="number" min="1" step="1" class="s-slider-val" style="width:52px"
|
||||
value={settingsState.settings.storageLimitGb}
|
||||
oninput={(e) => { const n = parseFloat(e.currentTarget.value); if (!isNaN(n) && n > 0) updateSettings({ storageLimitGb: n }) }} />
|
||||
<span class="s-slider-unit">GB</span>
|
||||
<button class="s-step-btn" onclick={() => updateSettings({ storageLimitGb: (settingsState.settings.storageLimitGb ?? 10) + 1 })}>+</button>
|
||||
<button class="s-btn-icon" title="Remove limit" onclick={() => updateSettings({ storageLimitGb: null })}>↺</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<button class="s-collapsible-trigger" onclick={() => advStorageOpen = !advStorageOpen}>
|
||||
<span class="s-label">Advanced</span>
|
||||
<svg class="s-collapsible-caret" class:open={advStorageOpen} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||
</button>
|
||||
{#if advStorageOpen}
|
||||
<div class="s-collapsible-body">
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Local source path</span>
|
||||
<span class="s-desc">Read manga already on disk without an extension. Leave blank if unused.</span>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px">
|
||||
<div class="s-btn-row">
|
||||
<input class="s-input mono" class:error={!!pathsFieldError.loc}
|
||||
bind:value={localSourcePathInput} placeholder="Optional" spellcheck="false"
|
||||
onkeydown={(e) => e.key === 'Enter' && savePaths()}
|
||||
oninput={() => { pathsFieldError = { ...pathsFieldError, loc: undefined } }} />
|
||||
{#if !isExternalServer}
|
||||
<button class="s-btn" onclick={browseLocalSourceFolder}>Browse</button>
|
||||
{/if}
|
||||
{#if pathsFieldError.loc && !isExternalServer}
|
||||
<button class="s-btn" onclick={async () => {
|
||||
try { await createDirectory(localSourcePathInput.trim()); pathsFieldError = { ...pathsFieldError, loc: undefined } }
|
||||
catch (e: any) { pathsFieldError = { ...pathsFieldError, loc: e?.message ?? 'Failed' } }
|
||||
}}>Create</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if pathsFieldError.loc}<span class="s-desc" style="color:var(--color-error)">{pathsFieldError.loc}</span>{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#each extraScanDirs as dir}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label mono" style="font-family:monospace;font-size:var(--text-xs)">{dir}</span>
|
||||
<span class="s-desc">Extra scan directory</span>
|
||||
</div>
|
||||
<button class="s-btn s-btn-danger" onclick={() => removeExtraScanDir(dir)}>Remove</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Additional scan path</span>
|
||||
<span class="s-desc">Include an extra directory in disk usage readings</span>
|
||||
</div>
|
||||
<div class="s-btn-row">
|
||||
<input class="s-input mono" bind:value={newScanDir} placeholder="/path/to/dir" spellcheck="false"
|
||||
onkeydown={(e) => e.key === 'Enter' && addExtraScanDir()} />
|
||||
{#if !isExternalServer}
|
||||
<button class="s-btn" onclick={browseExtraScanDir}>Browse</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<button class="s-collapsible-trigger" onclick={() => backupSectionOpen = !backupSectionOpen}>
|
||||
<span class="s-label">Backup</span>
|
||||
<svg class="s-collapsible-caret" class:open={backupSectionOpen} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||
</button>
|
||||
{#if backupSectionOpen}
|
||||
<div class="s-collapsible-body">
|
||||
|
||||
<p class="s-subsection-title">Library backup</p>
|
||||
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Create backup</span>
|
||||
<span class="s-desc">Snapshot your library, categories, and tracker links</span>
|
||||
</div>
|
||||
<button class="s-btn s-btn-accent" onclick={createBackup} disabled={backupLoading}>
|
||||
{backupLoading ? 'Creating…' : 'Create backup'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if backupError}
|
||||
<div class="s-banner s-banner-error">{backupError}</div>
|
||||
{/if}
|
||||
|
||||
{#if backupList.length === 0}
|
||||
<p class="s-empty">No backups yet — create one above.</p>
|
||||
{:else}
|
||||
{#each backupList as backup}
|
||||
<div class="s-folder-row">
|
||||
<ClockCounterClockwise size={14} weight="light" style="color:var(--text-faint);flex-shrink:0" />
|
||||
<span class="s-folder-name" style="font-family:monospace;font-size:var(--text-xs)">{backup.name}</span>
|
||||
<button class="s-btn-icon" onclick={() => downloadBackup(backup)} title="Download">↓</button>
|
||||
<button class="s-btn-icon danger" onclick={() => deleteBackup(backup.url)} disabled={backup.deleting} title="Delete">
|
||||
<Trash size={12} weight="light" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Restore from file</span>
|
||||
<span class="s-desc">{restoreFile ? restoreFile.name : 'Select a .tachibk file'}</span>
|
||||
</div>
|
||||
<label class="s-btn" style="cursor:pointer">
|
||||
Browse
|
||||
<input type="file" accept=".tachibk,.proto.gz" style="display:none"
|
||||
onchange={(e) => {
|
||||
const f = (e.currentTarget as HTMLInputElement).files?.[0] ?? null
|
||||
restoreFile = f; restoreStatus = null; restoreError = null; validateResult = null; validateError = null
|
||||
}} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#if restoreFile}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"></div>
|
||||
<div class="s-btn-row">
|
||||
<button class="s-btn" onclick={submitValidate} disabled={validateLoading || restoreLoading}>
|
||||
{validateLoading ? 'Checking…' : 'Validate'}
|
||||
</button>
|
||||
<button class="s-btn s-btn-accent" onclick={submitRestore} disabled={restoreLoading || validateLoading}>
|
||||
{restoreLoading ? 'Restoring…' : 'Restore'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if validateError}
|
||||
<div class="s-banner s-banner-error">{validateError}</div>
|
||||
{/if}
|
||||
|
||||
{#if validateResult}
|
||||
{#if validateResult.missingSources.length === 0 && validateResult.missingTrackers.length === 0}
|
||||
<div class="s-row"><span class="s-desc" style="color:var(--color-success,#4caf50)">✓ All sources and trackers present</span></div>
|
||||
{:else}
|
||||
{#if validateResult.missingSources.length > 0}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label" style="color:var(--color-error)">Missing sources</span>
|
||||
<span class="s-desc">{validateResult.missingSources.map(s => s.name).join(', ')}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if validateResult.missingTrackers.length > 0}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label" style="color:var(--color-error)">Missing trackers</span>
|
||||
<span class="s-desc">{validateResult.missingTrackers.map(t => t.name).join(', ')}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if restoreError}
|
||||
<div class="s-banner s-banner-error">{restoreError}</div>
|
||||
{/if}
|
||||
|
||||
{#if restoreStatus}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">
|
||||
{restoreStatus.state === 'SUCCESS' ? '✓ Restore complete' :
|
||||
restoreStatus.state === 'FAILURE' ? '✗ Restore failed' : 'Restoring…'}
|
||||
</span>
|
||||
{#if restoreStatus.totalManga > 0}
|
||||
<span class="s-desc">{restoreStatus.mangaProgress} / {restoreStatus.totalManga} manga</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if restoreStatus.state !== 'SUCCESS' && restoreStatus.state !== 'FAILURE' && restoreStatus.totalManga > 0}
|
||||
<div class="s-storage-bar" style="width:160px;flex-shrink:0">
|
||||
<div class="s-storage-fill" style="width:{Math.round((restoreStatus.mangaProgress / restoreStatus.totalManga) * 100)}%"></div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="s-subsection-title">App data backup</p>
|
||||
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Export settings</span>
|
||||
<span class="s-desc">Save all Moku app settings to a .zip via a native save dialog.</span>
|
||||
</div>
|
||||
<button class="s-btn s-btn-accent" onclick={handleExportAppData} disabled={appDataExporting}>
|
||||
{appDataExporting ? 'Saving…' : 'Export'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Import settings</span>
|
||||
<span class="s-desc">Restore from a previously exported .zip file. Reloads the app immediately.</span>
|
||||
</div>
|
||||
<button class="s-btn" onclick={handleImportAppData} disabled={appDataImporting}>
|
||||
{appDataImporting ? 'Importing…' : 'Import'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if appDataError}
|
||||
<div class="s-banner s-banner-error">{appDataError}</div>
|
||||
{/if}
|
||||
|
||||
{#if appDataMsg}
|
||||
<div class="s-row">
|
||||
<span class="s-desc" style="color:var(--color-success,#4caf50)">{appDataMsg}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if appDataBackupDir}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Auto-backup location</span>
|
||||
<span class="s-desc">Pre-update snapshots are kept here (last 5).</span>
|
||||
</div>
|
||||
<button class="s-btn" onclick={() => invoke('open_path', { path: appDataBackupDir })}>Open folder</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<button class="s-collapsible-trigger" onclick={() => resetSectionOpen = !resetSectionOpen}>
|
||||
<span class="s-label">Reset</span>
|
||||
<svg class="s-collapsible-caret" class:open={resetSectionOpen} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
|
||||
</button>
|
||||
{#if resetSectionOpen}
|
||||
<div class="s-collapsible-body">
|
||||
{#each resetItems as item}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">{item.label}</span>
|
||||
<span class="s-desc">{item.desc}</span>
|
||||
{#if item.error}<span class="s-desc" style="color:var(--color-error)">{item.error}</span>{/if}
|
||||
</div>
|
||||
<div class="s-btn-row">
|
||||
{#if item.state === 'done'}
|
||||
<span class="s-pill on">Done</span>
|
||||
{:else if item.state === 'busy'}
|
||||
<button class="s-btn" disabled>Working…</button>
|
||||
{:else if confirming === item.key}
|
||||
<span class="s-desc" style="color:var(--text-muted)">Sure?</span>
|
||||
<button class="s-btn s-btn-danger" onclick={() => runReset(item.key)}>Confirm</button>
|
||||
<button class="s-btn" onclick={() => confirming = null}>Cancel</button>
|
||||
{:else}
|
||||
<button
|
||||
class="s-btn"
|
||||
class:s-btn-danger={item.confirm}
|
||||
onclick={() => item.confirm ? (confirming = item.key) : runReset(item.key)}
|
||||
>Reset</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,274 @@
|
||||
<script lang="ts">
|
||||
import { settingsState, updateSettings } from "$lib/state/settings.svelte";
|
||||
import { toast } from "$lib/state/notifications.svelte";
|
||||
import { getAdapter } from "$lib/request-manager";
|
||||
import { syncBackFromTracker } from "$lib/state/tracking.svelte";
|
||||
import type { Tracker, TrackRecord } from "$lib/types/index";
|
||||
|
||||
let trackers = $state<Tracker[]>([]);
|
||||
let trackersLoading = $state(false);
|
||||
let trackersError = $state<string | null>(null);
|
||||
let oauthTrackerId = $state<number | null>(null);
|
||||
let oauthCallbackInput = $state("");
|
||||
let oauthSubmitting = $state(false);
|
||||
let oauthError = $state<string | null>(null);
|
||||
let credsTrackerId = $state<number | null>(null);
|
||||
let credsUsername = $state("");
|
||||
let credsPassword = $state("");
|
||||
let credsSubmitting = $state(false);
|
||||
let credsError = $state<string | null>(null);
|
||||
let loggingOut = $state<number | null>(null);
|
||||
let syncing = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (trackers.length === 0 && !trackersLoading) loadTrackers();
|
||||
});
|
||||
|
||||
async function loadTrackers() {
|
||||
trackersLoading = true; trackersError = null;
|
||||
try {
|
||||
trackers = await getAdapter().getTrackers();
|
||||
} catch (e: any) {
|
||||
trackersError = e?.message ?? "Failed to load trackers";
|
||||
} finally { trackersLoading = false; }
|
||||
}
|
||||
|
||||
async function startOAuth(tracker: Tracker) {
|
||||
if (!tracker.authUrl) return;
|
||||
oauthTrackerId = tracker.id; oauthCallbackInput = "";
|
||||
window.open(tracker.authUrl, "_blank");
|
||||
}
|
||||
|
||||
async function submitOAuth() {
|
||||
if (!oauthTrackerId || !oauthCallbackInput.trim()) return;
|
||||
oauthSubmitting = true;
|
||||
try {
|
||||
await getAdapter().loginTrackerOAuth(oauthTrackerId, oauthCallbackInput.trim());
|
||||
await loadTrackers();
|
||||
oauthTrackerId = null; oauthCallbackInput = "";
|
||||
} catch (e: any) {
|
||||
oauthError = e?.message ?? "Login failed";
|
||||
} finally { oauthSubmitting = false; }
|
||||
}
|
||||
|
||||
function cancelOAuth() { oauthTrackerId = null; oauthCallbackInput = ""; oauthError = null; }
|
||||
|
||||
function startCredentials(tracker: Tracker) { credsTrackerId = tracker.id; credsUsername = ""; credsPassword = ""; }
|
||||
|
||||
async function submitCredentials() {
|
||||
if (!credsTrackerId || !credsUsername.trim() || !credsPassword.trim()) return;
|
||||
credsSubmitting = true;
|
||||
try {
|
||||
await getAdapter().loginTrackerCredentials(credsTrackerId, credsUsername.trim(), credsPassword.trim());
|
||||
await loadTrackers();
|
||||
credsTrackerId = null; credsUsername = ""; credsPassword = "";
|
||||
} catch (e: any) {
|
||||
credsError = e?.message ?? "Login failed";
|
||||
} finally { credsSubmitting = false; }
|
||||
}
|
||||
|
||||
function cancelCredentials() { credsTrackerId = null; credsUsername = ""; credsPassword = ""; credsError = null; }
|
||||
|
||||
async function logoutTracker(trackerId: number) {
|
||||
loggingOut = trackerId;
|
||||
try {
|
||||
await getAdapter().logoutTracker(trackerId);
|
||||
await loadTrackers();
|
||||
} catch (e: any) {
|
||||
trackersError = e?.message ?? "Logout failed";
|
||||
} finally { loggingOut = null; }
|
||||
}
|
||||
|
||||
function focusEl(node: HTMLElement) { setTimeout(() => node.focus(), 0); }
|
||||
|
||||
async function runSyncAll() {
|
||||
syncing = true;
|
||||
try {
|
||||
const adapter = getAdapter();
|
||||
const allTrackers = await adapter.getTrackersWithRecords();
|
||||
const loggedIn = allTrackers.filter((t: any) => t.isLoggedIn);
|
||||
const settings = settingsState.settings;
|
||||
let totalMarked = 0;
|
||||
|
||||
for (const tracker of loggedIn) {
|
||||
for (const record of tracker.trackRecords.nodes as TrackRecord[]) {
|
||||
if (!record.manga?.id) continue;
|
||||
const mangaId = record.manga.id;
|
||||
const chapters = await adapter.getChapters(mangaId);
|
||||
const prefs = settings.mangaPrefs?.[mangaId] ?? {};
|
||||
|
||||
const marked = await syncBackFromTracker(
|
||||
[record],
|
||||
chapters,
|
||||
{
|
||||
threshold: settings.trackerSyncBackThreshold ?? null,
|
||||
respectScanlatorFilter: settings.trackerRespectScanlatorFilter ?? true,
|
||||
chapterPrefs: prefs,
|
||||
},
|
||||
adapter.markChaptersRead.bind(adapter),
|
||||
);
|
||||
totalMarked += marked.length;
|
||||
}
|
||||
}
|
||||
|
||||
toast({ kind: "success", message: "Sync complete", detail: `${totalMarked} chapter${totalMarked !== 1 ? "s" : ""} marked read` });
|
||||
} catch (e: any) {
|
||||
toast({ kind: "error", message: "Sync failed", detail: e?.message });
|
||||
} finally { syncing = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="s-panel">
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Connected Trackers</p>
|
||||
<div class="s-section-body">
|
||||
{#if trackersError}
|
||||
<div class="s-banner s-banner-error s-banner-dismissible" onclick={() => trackersError = null} role="button" tabindex="0" onkeydown={(e) => e.key === "Enter" && (trackersError = null)}>{trackersError}</div>
|
||||
{/if}
|
||||
{#if trackersLoading}
|
||||
<p class="s-empty">Loading trackers…</p>
|
||||
{:else}
|
||||
{#each trackers as tracker}
|
||||
<div class="s-tracker-row" class:expanded={oauthTrackerId === tracker.id || credsTrackerId === tracker.id}>
|
||||
<div class="s-tracker-identity">
|
||||
<img src={tracker.icon} alt={tracker.name} class="s-tracker-logo" />
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">{tracker.name}</span>
|
||||
<div class="s-tracker-status-row">
|
||||
<span class="s-pill" class:on={tracker.isLoggedIn && !tracker.isTokenExpired}>
|
||||
{tracker.isLoggedIn ? "Connected" : "Not connected"}
|
||||
</span>
|
||||
{#if tracker.isLoggedIn && tracker.isTokenExpired}
|
||||
<span class="s-pill s-pill-warn">Token expired — reconnect</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="s-tracker-action">
|
||||
{#if tracker.isLoggedIn && tracker.isTokenExpired}
|
||||
<button class="s-btn s-btn-accent" onclick={() => tracker.authUrl ? startOAuth(tracker) : startCredentials(tracker)}>
|
||||
Reconnect
|
||||
</button>
|
||||
<button class="s-btn s-btn-danger" onclick={() => logoutTracker(tracker.id)} disabled={loggingOut === tracker.id}>
|
||||
{loggingOut === tracker.id ? "Disconnecting…" : "Disconnect"}
|
||||
</button>
|
||||
{:else if tracker.isLoggedIn}
|
||||
<button class="s-btn s-btn-danger" onclick={() => logoutTracker(tracker.id)} disabled={loggingOut === tracker.id}>
|
||||
{loggingOut === tracker.id ? "Disconnecting…" : "Disconnect"}
|
||||
</button>
|
||||
{:else if oauthTrackerId !== tracker.id && credsTrackerId !== tracker.id}
|
||||
<button class="s-btn" onclick={() => tracker.authUrl ? startOAuth(tracker) : startCredentials(tracker)}>
|
||||
{tracker.authUrl ? "Connect via browser →" : "Connect"}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{#if oauthTrackerId === tracker.id}
|
||||
<div class="s-tracker-expand">
|
||||
{#if oauthError}
|
||||
<div class="s-banner s-banner-error s-banner-dismissible" onclick={() => oauthError = null} role="button" tabindex="0" onkeydown={(e) => e.key === "Enter" && (oauthError = null)}>{oauthError}</div>
|
||||
{/if}
|
||||
<p class="s-oauth-hint">Browser opened {tracker.name} login — authorise then paste the callback URL below.</p>
|
||||
<input class="s-input full" placeholder="https://suwayomi.org/tracker-oauth#access_token=…"
|
||||
bind:value={oauthCallbackInput}
|
||||
onkeydown={(e) => { if (e.key === "Enter") submitOAuth(); if (e.key === "Escape") cancelOAuth(); }}
|
||||
use:focusEl />
|
||||
<div class="s-oauth-btns">
|
||||
<button class="s-btn s-btn-accent" onclick={submitOAuth} disabled={oauthSubmitting || !oauthCallbackInput.trim()}>
|
||||
{oauthSubmitting ? "Connecting…" : "Connect"}
|
||||
</button>
|
||||
<button class="s-btn" onclick={cancelOAuth}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if credsTrackerId === tracker.id}
|
||||
<div class="s-tracker-expand">
|
||||
{#if credsError}
|
||||
<div class="s-banner s-banner-error s-banner-dismissible" onclick={() => credsError = null} role="button" tabindex="0" onkeydown={(e) => e.key === "Enter" && (credsError = null)}>{credsError}</div>
|
||||
{/if}
|
||||
<input class="s-input full" placeholder="Username / Email" bind:value={credsUsername}
|
||||
onkeydown={(e) => e.key === "Escape" && cancelCredentials()} use:focusEl />
|
||||
<input class="s-input full" type="password" placeholder="Password" bind:value={credsPassword}
|
||||
onkeydown={(e) => { if (e.key === "Enter") submitCredentials(); if (e.key === "Escape") cancelCredentials(); }} />
|
||||
<div class="s-oauth-btns">
|
||||
<button class="s-btn s-btn-accent" onclick={submitCredentials} disabled={credsSubmitting || !credsUsername.trim() || !credsPassword.trim()}>
|
||||
{credsSubmitting ? "Connecting…" : "Connect"}
|
||||
</button>
|
||||
<button class="s-btn" onclick={cancelCredentials}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="s-section">
|
||||
<p class="s-section-title">Sync back from tracker</p>
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Enable sync back</span>
|
||||
<span class="s-desc">Mark chapters read locally based on tracker progress</span>
|
||||
</div>
|
||||
<button class="s-toggle" class:on={settings.trackerSyncBack}
|
||||
onclick={() => updateSettings({ trackerSyncBack: !settings.trackerSyncBack })}
|
||||
role="switch" aria-checked={settings.trackerSyncBack} aria-label="Enable sync back">
|
||||
<span class="s-toggle-thumb"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if settings.trackerSyncBack}
|
||||
<label class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Chapter number tolerance</span>
|
||||
<span class="s-desc">Allow source and tracker chapter numbers to differ by up to the set amount. When off, the tracker number is used as-is with no range check.</span>
|
||||
</div>
|
||||
<button role="switch" aria-checked={settings.trackerSyncBackThreshold !== null} aria-label="Chapter number tolerance" class="s-toggle" class:on={settings.trackerSyncBackThreshold !== null}
|
||||
onclick={() => updateSettings({ trackerSyncBackThreshold: settings.trackerSyncBackThreshold !== null ? null : 20 })}>
|
||||
<span class="s-toggle-thumb"></span>
|
||||
</button>
|
||||
</label>
|
||||
{#if settings.trackerSyncBackThreshold !== null}
|
||||
<div class="s-row">
|
||||
<div class="s-row-info"><span class="s-label">Tolerance</span><span class="s-desc">Max chapter number difference allowed (1–20)</span></div>
|
||||
<div class="s-stepper">
|
||||
<button class="s-step-btn" onclick={() => updateSettings({ trackerSyncBackThreshold: Math.max(1, (settings.trackerSyncBackThreshold ?? 20) - 1) })}>−</button>
|
||||
<span class="s-step-val">{settings.trackerSyncBackThreshold}</span>
|
||||
<button class="s-step-btn" onclick={() => updateSettings({ trackerSyncBackThreshold: Math.min(20, (settings.trackerSyncBackThreshold ?? 20) + 1) })}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Respect scanlator filter</span>
|
||||
<span class="s-desc">Only mark chapters matching the series' active scanlator filter</span>
|
||||
</div>
|
||||
<button class="s-toggle" class:on={settings.trackerRespectScanlatorFilter}
|
||||
onclick={() => updateSettings({ trackerRespectScanlatorFilter: !settings.trackerRespectScanlatorFilter })}
|
||||
role="switch" aria-checked={settings.trackerRespectScanlatorFilter} aria-label="Respect scanlator filter">
|
||||
<span class="s-toggle-thumb"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="s-row">
|
||||
<div class="s-row-info">
|
||||
<span class="s-label">Sync now</span>
|
||||
<span class="s-desc">Apply tracker progress to all linked manga in your library</span>
|
||||
</div>
|
||||
<button class="s-btn" onclick={runSyncAll} disabled={syncing}>
|
||||
{syncing ? "Syncing…" : "Sync all"}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.s-tracker-status-row { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
|
||||
.s-pill-warn { background: color-mix(in srgb, var(--color-warn, #c97c2b) 15%, transparent); color: var(--color-warn, #c97c2b); border-color: color-mix(in srgb, var(--color-warn, #c97c2b) 35%, transparent); }
|
||||
.s-banner-dismissible { cursor: pointer; max-height: 8rem; overflow-y: auto; }
|
||||
.s-banner-dismissible:hover { opacity: 0.85; }
|
||||
</style>
|
||||
@@ -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} />
|
||||
@@ -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 {
|
||||
return (item) => predicates.every((p) => p(item));
|
||||
return item => predicates.every(p => p(item))
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
export interface PaginationState {
|
||||
visible: number;
|
||||
visible: number
|
||||
}
|
||||
|
||||
export interface PaginationResult<T> {
|
||||
items: T[];
|
||||
hasMore: boolean;
|
||||
remaining: number;
|
||||
items: T[]
|
||||
hasMore: boolean
|
||||
remaining: number
|
||||
}
|
||||
|
||||
export function createPaginator<T>(pageSize: number) {
|
||||
@@ -15,15 +15,9 @@ export function createPaginator<T>(pageSize: number) {
|
||||
items: all.slice(0, visible),
|
||||
hasMore: all.length > visible,
|
||||
remaining: Math.max(0, all.length - visible),
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
nextVisible(current: number): number {
|
||||
return current + pageSize;
|
||||
},
|
||||
|
||||
reset(): number {
|
||||
return pageSize;
|
||||
},
|
||||
};
|
||||
}
|
||||
nextVisible(current: number): number { return current + pageSize },
|
||||
reset(): number { return pageSize },
|
||||
}
|
||||
}
|
||||
@@ -1,29 +1,26 @@
|
||||
export interface AsyncQueue<T> {
|
||||
enqueue(item: T): void;
|
||||
drain(): void;
|
||||
clear(): void;
|
||||
size(): number;
|
||||
enqueue(item: T): void
|
||||
drain(): void
|
||||
clear(): void
|
||||
size(): number
|
||||
}
|
||||
|
||||
export function createAsyncQueue<T>(
|
||||
worker: (item: T) => Promise<void>,
|
||||
concurrency = 1,
|
||||
): AsyncQueue<T> {
|
||||
const queue: T[] = [];
|
||||
let active = 0;
|
||||
export function createAsyncQueue<T>(worker: (item: T) => Promise<void>, concurrency = 1): AsyncQueue<T> {
|
||||
const queue: T[] = []
|
||||
let active = 0
|
||||
|
||||
function next() {
|
||||
while (active < concurrency && queue.length > 0) {
|
||||
const item = queue.shift()!;
|
||||
active++;
|
||||
worker(item).finally(() => { active--; next(); });
|
||||
const item = queue.shift()!
|
||||
active++
|
||||
worker(item).finally(() => { active--; next() })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
enqueue(item) { queue.push(item); next(); },
|
||||
drain() { next(); },
|
||||
clear() { queue.length = 0; },
|
||||
size() { return queue.length; },
|
||||
};
|
||||
}
|
||||
enqueue(item) { queue.push(item); next() },
|
||||
drain() { next() },
|
||||
clear() { queue.length = 0 },
|
||||
size() { return queue.length },
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,24 @@
|
||||
export interface SearchResult<T> {
|
||||
item: T;
|
||||
score: number;
|
||||
item: T
|
||||
score: number
|
||||
}
|
||||
|
||||
export function searchItems<T>(
|
||||
items: T[],
|
||||
query: string,
|
||||
getField: (item: T) => string,
|
||||
): T[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return items;
|
||||
return items.filter(item => getField(item).toLowerCase().includes(q));
|
||||
export function searchItems<T>(items: T[], query: string, getField: (item: T) => string): T[] {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return items
|
||||
return items.filter(item => getField(item).toLowerCase().includes(q))
|
||||
}
|
||||
|
||||
export function searchWithScore<T>(
|
||||
items: T[],
|
||||
query: string,
|
||||
getField: (item: T) => string,
|
||||
): SearchResult<T>[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return items.map(item => ({ item, score: 0 }));
|
||||
|
||||
export function searchWithScore<T>(items: T[], query: string, getField: (item: T) => string): SearchResult<T>[] {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return items.map(item => ({ item, score: 0 }))
|
||||
return items
|
||||
.map(item => {
|
||||
const field = getField(item).toLowerCase();
|
||||
if (!field.includes(q)) return null;
|
||||
const score = field === q ? 2 : field.startsWith(q) ? 1 : 0;
|
||||
return { item, score };
|
||||
const field = getField(item).toLowerCase()
|
||||
if (!field.includes(q)) return null
|
||||
const score = field === q ? 2 : field.startsWith(q) ? 1 : 0
|
||||
return { item, score }
|
||||
})
|
||||
.filter((r): r is SearchResult<T> => r !== null)
|
||||
.sort((a, b) => b.score - a.score);
|
||||
}
|
||||
.sort((a, b) => b.score - a.score)
|
||||
}
|
||||
@@ -1,32 +1,31 @@
|
||||
export type SortDir = "asc" | "desc";
|
||||
export type SortDir = 'asc' | 'desc'
|
||||
|
||||
export interface SortField<T> {
|
||||
key: string;
|
||||
comparator: (a: T, b: T, context?: Record<string, unknown>) => number;
|
||||
key: string
|
||||
comparator: (a: T, b: T, context?: Record<string, unknown>) => number
|
||||
}
|
||||
|
||||
export interface SortConfig<T> {
|
||||
fields: SortField<T>[];
|
||||
defaultField: string;
|
||||
defaultDir: SortDir;
|
||||
fields: SortField<T>[]
|
||||
defaultField: string
|
||||
defaultDir: SortDir
|
||||
}
|
||||
|
||||
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> {
|
||||
const fieldMap = new Map(config.fields.map(f => [f.key, f]));
|
||||
|
||||
const fieldMap = new Map(config.fields.map(f => [f.key, f]))
|
||||
return {
|
||||
sort(items, field, dir, context) {
|
||||
const f = fieldMap.get(field) ?? fieldMap.get(config.defaultField);
|
||||
if (!f) return [...items];
|
||||
const d = dir ?? config.defaultDir;
|
||||
const f = fieldMap.get(field) ?? fieldMap.get(config.defaultField)
|
||||
if (!f) return [...items]
|
||||
const d = dir ?? config.defaultDir
|
||||
return [...items].sort((a, b) => {
|
||||
const cmp = f.comparator(a, b, context);
|
||||
return d === "asc" ? cmp : -cmp;
|
||||
});
|
||||
const cmp = f.comparator(a, b, context)
|
||||
return d === 'asc' ? cmp : -cmp
|
||||
})
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -1,16 +1,74 @@
|
||||
const DEFAULT_URL = 'http://127.0.0.1:4567'
|
||||
|
||||
interface AuthConfig {
|
||||
baseUrl: string
|
||||
mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN'
|
||||
user?: string
|
||||
pass?: string
|
||||
baseUrl: string
|
||||
mode: 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN'
|
||||
user?: 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 accessToken: string | null = null
|
||||
let refreshToken: string | null = null
|
||||
let accessToken: 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(
|
||||
baseUrl: string,
|
||||
@@ -19,8 +77,7 @@ export function configureAuth(
|
||||
pass?: string,
|
||||
): void {
|
||||
config = { baseUrl: baseUrl.replace(/\/$/, ''), mode, user, pass }
|
||||
accessToken = null
|
||||
refreshToken = null
|
||||
authSession.clearTokens()
|
||||
}
|
||||
|
||||
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 {
|
||||
login: { accessToken: string; refreshToken: string }
|
||||
}
|
||||
accessToken = data.login.accessToken
|
||||
refreshToken = data.login.refreshToken
|
||||
config.mode = 'UI_LOGIN'
|
||||
config.user = user
|
||||
accessToken = data.login.accessToken
|
||||
refreshToken = data.login.refreshToken
|
||||
accessExpiresAt = parseExpiry(accessToken)
|
||||
refreshExpiresAt = parseExpiry(refreshToken)
|
||||
config.mode = 'UI_LOGIN'
|
||||
config.user = user
|
||||
}
|
||||
|
||||
export async function refreshAccessToken(): Promise<boolean> {
|
||||
@@ -104,9 +163,25 @@ export async function refreshAccessToken(): Promise<boolean> {
|
||||
const data = await gqlRaw(REFRESH_MUTATION, { refreshToken }) as {
|
||||
refreshToken: { accessToken: string }
|
||||
}
|
||||
accessToken = data.refreshToken.accessToken
|
||||
accessToken = data.refreshToken.accessToken
|
||||
accessExpiresAt = parseExpiry(accessToken)
|
||||
return true
|
||||
} catch {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+134
@@ -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;
|
||||
}
|
||||
Vendored
+7
-7
@@ -146,13 +146,13 @@ export const CACHE_GROUPS = {
|
||||
} as const;
|
||||
|
||||
export const CACHE_KEYS = {
|
||||
LIBRARY: "library",
|
||||
LIBRARY: "library",
|
||||
RECENT_UPDATES: "recent_updates",
|
||||
ALL_MANGA: "all_manga_unfiltered",
|
||||
CATEGORIES: "categories",
|
||||
SEARCH: "search_all_manga",
|
||||
SOURCES: "sources",
|
||||
POPULAR: "popular",
|
||||
ALL_MANGA: "all_manga_unfiltered",
|
||||
CATEGORIES: "categories",
|
||||
SEARCH: "search_all_manga",
|
||||
SOURCES: "sources",
|
||||
POPULAR: "popular",
|
||||
GENRE: (genre: string) => `genre:${genre}`,
|
||||
MANGA: (id: number) => `manga:${id}`,
|
||||
CHAPTERS: (id: number) => `chapters:${id}`,
|
||||
@@ -234,7 +234,7 @@ export async function refreshMangaCache(mangaId: number, thumbnailUrl?: string):
|
||||
cache.clear(CACHE_KEYS.ALL_MANGA);
|
||||
|
||||
if (thumbnailUrl) {
|
||||
const { revokeBlobUrl, getBlobUrl } = await import("@core/cache/imageCache");
|
||||
const { revokeBlobUrl, getBlobUrl } = await import("$lib/core/cache/imageCache");
|
||||
revokeBlobUrl(thumbnailUrl);
|
||||
getBlobUrl(thumbnailUrl, 999).catch(() => {});
|
||||
}
|
||||
|
||||
@@ -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] ?? [],
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,29 +1,26 @@
|
||||
interface WorkerMsg {
|
||||
focalTitle: string;
|
||||
focalId: number;
|
||||
allManga: { id: number; title: string }[];
|
||||
linkedIds: number[];
|
||||
focalTitle: string
|
||||
focalId: number
|
||||
allManga: { id: number; title: string }[]
|
||||
linkedIds: number[]
|
||||
}
|
||||
|
||||
function titleSimilarity(a: string, b: string): number {
|
||||
const norm = (s: string) =>
|
||||
s.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter(Boolean);
|
||||
const wa = new Set(norm(a));
|
||||
const wb = new Set(norm(b));
|
||||
if (!wa.size || !wb.size) return 0;
|
||||
const intersection = [...wa].filter(w => wb.has(w)).length;
|
||||
return intersection / new Set([...wa, ...wb]).size;
|
||||
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9\s]/g, '').split(/\s+/).filter(Boolean)
|
||||
const wa = new Set(norm(a))
|
||||
const wb = new Set(norm(b))
|
||||
if (!wa.size || !wb.size) return 0
|
||||
const intersection = [...wa].filter(w => wb.has(w)).length
|
||||
return intersection / new Set([...wa, ...wb]).size
|
||||
}
|
||||
|
||||
self.onmessage = (e: MessageEvent<WorkerMsg>) => {
|
||||
const { focalTitle, focalId, allManga, linkedIds } = e.data;
|
||||
const matches: number[] = [];
|
||||
|
||||
const { focalTitle, focalId, allManga, linkedIds } = e.data
|
||||
const matches: number[] = []
|
||||
for (const m of allManga) {
|
||||
if (m.id === focalId) continue;
|
||||
if (linkedIds.includes(m.id)) continue;
|
||||
if (titleSimilarity(focalTitle, m.title) >= 0.65) matches.push(m.id);
|
||||
if (m.id === focalId) continue
|
||||
if (linkedIds.includes(m.id)) continue
|
||||
if (titleSimilarity(focalTitle, m.title) >= 0.65) matches.push(m.id)
|
||||
}
|
||||
|
||||
self.postMessage(matches);
|
||||
};
|
||||
self.postMessage(matches)
|
||||
}
|
||||
@@ -1,54 +1,53 @@
|
||||
const THUMB_SIZE = 16;
|
||||
const DUPE_THRESH = 0.12;
|
||||
|
||||
const hashCache = new Map<string, Uint8ClampedArray>();
|
||||
const THUMB_SIZE = 16
|
||||
const DUPE_THRESH = 0.12
|
||||
const hashCache = new Map<string, 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++) {
|
||||
const o = i * 4;
|
||||
gray[i] = (data[o] * 299 + data[o + 1] * 587 + data[o + 2] * 114) / 1000;
|
||||
const o = i * 4
|
||||
gray[i] = (data[o] * 299 + data[o + 1] * 587 + data[o + 2] * 114) / 1000
|
||||
}
|
||||
return gray;
|
||||
return gray
|
||||
}
|
||||
|
||||
function loadThumb(url: string): Promise<Uint8ClampedArray> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = canvas.height = THUMB_SIZE;
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
ctx.drawImage(img, 0, 0, THUMB_SIZE, THUMB_SIZE);
|
||||
resolve(toGray(ctx.getImageData(0, 0, THUMB_SIZE, THUMB_SIZE).data, THUMB_SIZE * THUMB_SIZE));
|
||||
};
|
||||
img.onerror = reject;
|
||||
img.src = url;
|
||||
});
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = canvas.height = THUMB_SIZE
|
||||
const ctx = canvas.getContext('2d')!
|
||||
ctx.drawImage(img, 0, 0, THUMB_SIZE, THUMB_SIZE)
|
||||
resolve(toGray(ctx.getImageData(0, 0, THUMB_SIZE, THUMB_SIZE).data, THUMB_SIZE * THUMB_SIZE))
|
||||
}
|
||||
img.onerror = reject
|
||||
img.src = url
|
||||
})
|
||||
}
|
||||
|
||||
function similarity(a: Uint8ClampedArray, b: Uint8ClampedArray): number {
|
||||
let diff = 0;
|
||||
for (let i = 0; i < a.length; i++) diff += Math.abs(a[i] - b[i]);
|
||||
return diff / (a.length * 255);
|
||||
let diff = 0
|
||||
for (let i = 0; i < a.length; i++) diff += Math.abs(a[i] - b[i])
|
||||
return diff / (a.length * 255)
|
||||
}
|
||||
|
||||
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 {
|
||||
const thumb = await loadThumb(url);
|
||||
hashCache.set(url, thumb);
|
||||
return thumb;
|
||||
const thumb = await loadThumb(url)
|
||||
hashCache.set(url, thumb)
|
||||
return thumb
|
||||
} catch {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function areDuplicates(a: Uint8ClampedArray, b: Uint8ClampedArray): boolean {
|
||||
return similarity(a, b) <= DUPE_THRESH;
|
||||
return similarity(a, b) <= DUPE_THRESH
|
||||
}
|
||||
|
||||
export function clearHashCache(): void {
|
||||
hashCache.clear();
|
||||
hashCache.clear()
|
||||
}
|
||||
@@ -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(' · ') }
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { eventToKeybind, matchesKeybind, toggleFullscreen } from './keybindEngine'
|
||||
export { DEFAULT_KEYBINDS, KEYBIND_LABELS } from './defaultBinds'
|
||||
export type { Keybinds } from './defaultBinds'
|
||||
@@ -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";
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,7 @@ export class CapacitorAdapter implements PlatformAdapter {
|
||||
|
||||
async launchServer(_config: ServerLaunchConfig) {}
|
||||
async stopServer() {}
|
||||
async getServerStatus(): Promise<'running' | 'stopped' | 'error'> {
|
||||
return 'stopped'
|
||||
}
|
||||
async getServerStatus(): Promise<'running' | 'stopped' | 'error'> { return 'stopped' }
|
||||
|
||||
async readFile(path: string): Promise<Uint8Array> {
|
||||
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 })
|
||||
}
|
||||
|
||||
async pickFolder(): Promise<string | null> {
|
||||
return null
|
||||
}
|
||||
async pickFolder(): Promise<string | null> { return null }
|
||||
|
||||
async authenticateBiometric(): Promise<boolean> {
|
||||
try {
|
||||
@@ -70,6 +66,7 @@ export class CapacitorAdapter implements PlatformAdapter {
|
||||
async minimize() {}
|
||||
async maximize() {}
|
||||
async close() {}
|
||||
async toggleFullscreen() {}
|
||||
|
||||
async setDiscordPresence(_presence: DiscordPresence) {}
|
||||
async clearDiscordPresence() {}
|
||||
@@ -85,9 +82,6 @@ export class CapacitorAdapter implements PlatformAdapter {
|
||||
await Browser.open({ url })
|
||||
}
|
||||
|
||||
async checkForAppUpdate(): Promise<AppUpdateInfo | null> {
|
||||
return null
|
||||
}
|
||||
|
||||
async checkForAppUpdate(): Promise<AppUpdateInfo | null> { return null }
|
||||
async installAppUpdate(): Promise<void> {}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||
import { open } from '@tauri-apps/plugin-dialog'
|
||||
import { readFile, writeFile } from '@tauri-apps/plugin-fs'
|
||||
import { open as openUrl } from '@tauri-apps/plugin-shell'
|
||||
@@ -83,6 +84,11 @@ export class TauriAdapter implements PlatformAdapter {
|
||||
await invoke('close_window')
|
||||
}
|
||||
|
||||
async toggleFullscreen() {
|
||||
const win = getCurrentWindow()
|
||||
await win.setFullscreen(!await win.isFullscreen())
|
||||
}
|
||||
|
||||
async setDiscordPresence(presence: DiscordPresence) {
|
||||
await invoke('set_discord_presence', { presence })
|
||||
}
|
||||
@@ -104,8 +110,8 @@ export class TauriAdapter implements PlatformAdapter {
|
||||
if (!update?.available) return null
|
||||
return {
|
||||
version: update.version,
|
||||
url: update.body ?? '',
|
||||
notes: update.body,
|
||||
url: 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 {}
|
||||
}
|
||||
@@ -44,6 +44,7 @@ export interface PlatformAdapter {
|
||||
minimize(): Promise<void>
|
||||
maximize(): Promise<void>
|
||||
close(): Promise<void>
|
||||
toggleFullscreen(): Promise<void>
|
||||
|
||||
setDiscordPresence(presence: DiscordPresence): Promise<void>
|
||||
clearDiscordPresence(): Promise<void>
|
||||
|
||||
@@ -15,52 +15,38 @@ export class WebAdapter implements PlatformAdapter {
|
||||
|
||||
async launchServer(_config: ServerLaunchConfig) {}
|
||||
async stopServer() {}
|
||||
async getServerStatus(): Promise<'running' | 'stopped' | 'error'> {
|
||||
return 'stopped'
|
||||
}
|
||||
|
||||
async readFile(_path: string): Promise<Uint8Array> {
|
||||
return new Uint8Array()
|
||||
}
|
||||
async getServerStatus(): Promise<'running' | 'stopped' | 'error'> { return 'stopped' }
|
||||
|
||||
async readFile(_path: string): Promise<Uint8Array> { return new Uint8Array() }
|
||||
async writeFile(_path: string, _data: Uint8Array) {}
|
||||
async pickFolder(): Promise<string | null> { return null }
|
||||
|
||||
async pickFolder(): Promise<string | null> {
|
||||
return null
|
||||
}
|
||||
|
||||
async authenticateBiometric(): Promise<boolean> {
|
||||
return false
|
||||
}
|
||||
|
||||
async authenticateBiometric(): Promise<boolean> { return false }
|
||||
async storeCredential(_key: string, _value: string) {}
|
||||
async getCredential(_key: string): Promise<string | null> { return null }
|
||||
|
||||
async getCredential(_key: string): Promise<string | null> {
|
||||
return null
|
||||
}
|
||||
|
||||
async setTitle(title: string) {
|
||||
document.title = title
|
||||
}
|
||||
|
||||
async setTitle(title: string) { document.title = title }
|
||||
async minimize() {}
|
||||
async maximize() {}
|
||||
async close() {}
|
||||
|
||||
async toggleFullscreen() {
|
||||
if (!document.fullscreenElement) {
|
||||
await document.documentElement.requestFullscreen().catch(() => {})
|
||||
} else {
|
||||
await document.exitFullscreen().catch(() => {})
|
||||
}
|
||||
}
|
||||
|
||||
async setDiscordPresence(_presence: DiscordPresence) {}
|
||||
async clearDiscordPresence() {}
|
||||
|
||||
async getVersion(): Promise<string> {
|
||||
return __APP_VERSION__
|
||||
}
|
||||
async getVersion(): Promise<string> { return __APP_VERSION__ }
|
||||
|
||||
async openExternal(url: string) {
|
||||
window.open(url, '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
|
||||
async checkForAppUpdate(): Promise<AppUpdateInfo | null> {
|
||||
return null
|
||||
}
|
||||
|
||||
async checkForAppUpdate(): Promise<AppUpdateInfo | null> { return null }
|
||||
async installAppUpdate() {}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PlatformAdapter } from '$lib/platform-adapters/types'
|
||||
import type { ServerLaunchConfig, DiscordPresence, AppUpdateInfo, PlatformFeature } from '$lib/platform-adapters/types'
|
||||
|
||||
let adapter: PlatformAdapter
|
||||
|
||||
@@ -6,7 +7,38 @@ export function initPlatformService(a: PlatformAdapter) {
|
||||
adapter = a
|
||||
}
|
||||
|
||||
export function getPlatformService(): PlatformAdapter {
|
||||
function get(): PlatformAdapter {
|
||||
if (!adapter) throw new Error('PlatformService not initialized')
|
||||
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(),
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getAdapter } from '$lib/request-manager'
|
||||
import { extensionsState } from '$lib/state/extensions.svelte'
|
||||
import type { SetServerAuthInput, SetSocksProxyInput, SetFlareSolverrInput } from '$lib/server-adapters/types'
|
||||
|
||||
export async function loadExtensions() {
|
||||
extensionsState.loading = true
|
||||
@@ -60,4 +61,20 @@ export async function browseSource(sourceId: string, page: number) {
|
||||
} finally {
|
||||
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)
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
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
|
||||
|
||||
@@ -9,4 +14,16 @@ export function initRequestManager(a: ServerAdapter) {
|
||||
export function getAdapter(): ServerAdapter {
|
||||
if (!adapter) throw new Error('RequestManager not initialized')
|
||||
return adapter
|
||||
}
|
||||
|
||||
export function clearPageCache(chapterId?: number): void {
|
||||
getAdapter().clearPageCache(chapterId)
|
||||
}
|
||||
|
||||
export const requestManager = {
|
||||
extensions,
|
||||
chapters,
|
||||
downloads,
|
||||
manga,
|
||||
tracking,
|
||||
}
|
||||
@@ -78,9 +78,9 @@ export async function refreshLibrary() {
|
||||
try {
|
||||
await getAdapter().checkForUpdates()
|
||||
await loadLibrary()
|
||||
toast('success', 'Library updated')
|
||||
toast({ kind: 'success', message: 'Library updated' })
|
||||
} catch (e) {
|
||||
toast('error', 'Update failed', String(e))
|
||||
toast({ kind: 'error', message: 'Update failed', detail: String(e) })
|
||||
} finally {
|
||||
libraryState.refreshing = false
|
||||
}
|
||||
|
||||
@@ -8,8 +8,9 @@ import type {
|
||||
Page,
|
||||
DownloadItem,
|
||||
UpdateResult,
|
||||
LibraryUpdateProgress,
|
||||
} 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 {
|
||||
throw new Error('MokuAdapter: not implemented')
|
||||
@@ -17,37 +18,69 @@ function notImplemented(): never {
|
||||
|
||||
export class MokuAdapter implements ServerAdapter {
|
||||
async connect(_config: ServerConfig): Promise<void> { notImplemented() }
|
||||
getServerUrl(): string { return notImplemented() }
|
||||
async getStatus(): Promise<ServerStatus> { return notImplemented() }
|
||||
|
||||
async getManga(_id: string): Promise<Manga> { return notImplemented() }
|
||||
async getMangaList(_filters: MangaFilters): Promise<PaginatedResult<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 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 deleteMangaMeta(_id: string, _key: string): Promise<void> { notImplemented() }
|
||||
|
||||
async getChapters(_mangaId: string): Promise<Chapter[]> { return notImplemented() }
|
||||
async getChapter(_id: string): Promise<Chapter> { 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 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 enqueueDownload(_chapterId: string): Promise<void> { notImplemented() }
|
||||
async enqueueDownloads(_chapterIds: string[]): Promise<void> { notImplemented() }
|
||||
async dequeueDownload(_chapterId: string): Promise<void> { notImplemented() }
|
||||
async dequeueDownloads(_chapterIds: string[]): 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 installExtension(_id: string): Promise<void> { notImplemented() }
|
||||
async uninstallExtension(_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 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 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 unlinkTracker(_recordId: string): Promise<void> { notImplemented() }
|
||||
async fetchTrackRecord(_recordId: string): Promise<void> { notImplemented() }
|
||||
async syncTracking(_mangaId: string): Promise<void> { notImplemented() }
|
||||
|
||||
async checkForUpdates(_mangaIds?: string[]): Promise<UpdateResult[]> { return notImplemented() }
|
||||
async stopLibraryUpdate(): Promise<void> { notImplemented() }
|
||||
async getLibraryUpdateStatus(): Promise<LibraryUpdateProgress> { return notImplemented() }
|
||||
|
||||
clearPageCache(_chapterId?: number): void {}
|
||||
}
|
||||
@@ -9,6 +9,10 @@ import type {
|
||||
DownloadItem,
|
||||
UpdateResult,
|
||||
LibraryUpdateProgress,
|
||||
ServerSecurity,
|
||||
SetServerAuthInput,
|
||||
SetSocksProxyInput,
|
||||
SetFlareSolverrInput,
|
||||
} from '$lib/server-adapters/types'
|
||||
import type { Manga, Chapter, Extension, Source, Tracker, Category } from '$lib/types'
|
||||
import {
|
||||
@@ -58,10 +62,12 @@ import {
|
||||
import {
|
||||
GET_EXTENSIONS,
|
||||
GET_SOURCES,
|
||||
GET_SERVER_SECURITY,
|
||||
FETCH_EXTENSIONS,
|
||||
UPDATE_EXTENSION,
|
||||
UPDATE_EXTENSIONS,
|
||||
INSTALL_EXTERNAL_EXTENSION,
|
||||
SET_SERVER_AUTH,
|
||||
} from './extensions'
|
||||
import {
|
||||
GET_TRACKERS,
|
||||
@@ -80,6 +86,51 @@ import {
|
||||
mapDownloadItem,
|
||||
mapCategory,
|
||||
} 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 {
|
||||
private baseUrl = 'http://127.0.0.1:4567'
|
||||
@@ -133,8 +184,6 @@ export class SuwayomiAdapter implements ServerAdapter {
|
||||
return json.data
|
||||
}
|
||||
|
||||
// ─── Manga ───────────────────────────────────────────────────────────────
|
||||
|
||||
async getManga(id: string): Promise<Manga> {
|
||||
const data = await this.gql<{ manga: Record<string, unknown> }>(GET_MANGA, { id: Number(id) })
|
||||
return mapManga(data.manga)
|
||||
@@ -150,10 +199,10 @@ export class SuwayomiAdapter implements ServerAdapter {
|
||||
async getMangaList(filters: MangaFilters): Promise<PaginatedResult<Manga>> {
|
||||
const data = await this.gql<{ mangas: { nodes: Record<string, unknown>[] } }>(GET_LIBRARY)
|
||||
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.unread) items = items.filter(m => (m.unreadCount ?? 0) > 0)
|
||||
if (filters.sourceId) items = items.filter(m => String(m.source?.id) === filters.sourceId)
|
||||
if (filters.unread) items = items.filter(m => (m.unreadCount ?? 0) > 0)
|
||||
if (filters.sourceId) items = items.filter(m => String(m.source?.id) === filters.sourceId)
|
||||
return { items, hasNextPage: false }
|
||||
}
|
||||
|
||||
@@ -188,8 +237,6 @@ export class SuwayomiAdapter implements ServerAdapter {
|
||||
await this.gql(DELETE_MANGA_META, { mangaId: Number(id), key })
|
||||
}
|
||||
|
||||
// ─── Chapters ────────────────────────────────────────────────────────────
|
||||
|
||||
async getChapters(mangaId: string): Promise<Chapter[]> {
|
||||
const data = await this.gql<{ chapters: { nodes: Record<string, unknown>[] } }>(
|
||||
GET_CHAPTERS, { mangaId: Number(mangaId) }
|
||||
@@ -252,8 +299,6 @@ export class SuwayomiAdapter implements ServerAdapter {
|
||||
await this.gql(DELETE_CHAPTER_META, { chapterId: Number(chapterId), key })
|
||||
}
|
||||
|
||||
// ─── Downloads ───────────────────────────────────────────────────────────
|
||||
|
||||
async getDownloads(): Promise<DownloadItem[]> {
|
||||
const data = await this.gql<{ downloadStatus: { queue: Record<string, unknown>[] } }>(
|
||||
GET_DOWNLOAD_STATUS
|
||||
@@ -289,8 +334,6 @@ export class SuwayomiAdapter implements ServerAdapter {
|
||||
await this.gql(STOP_DOWNLOADER)
|
||||
}
|
||||
|
||||
// ─── Extensions ──────────────────────────────────────────────────────────
|
||||
|
||||
async getExtensions(): Promise<Extension[]> {
|
||||
await this.gql(FETCH_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[]> {
|
||||
const data = await this.gql<{ categories: { nodes: Record<string, unknown>[] } }>(GET_CATEGORIES)
|
||||
return data.categories.nodes.map(mapCategory)
|
||||
@@ -369,24 +410,22 @@ export class SuwayomiAdapter implements ServerAdapter {
|
||||
await this.gql(UPDATE_CATEGORY_MANGA, { categoryId })
|
||||
}
|
||||
|
||||
// ─── Tracking ────────────────────────────────────────────────────────────
|
||||
|
||||
async getTrackers(): Promise<Tracker[]> {
|
||||
const data = await this.gql<{ trackers: { nodes: Tracker[] } }>(GET_TRACKERS)
|
||||
return data.trackers.nodes
|
||||
}
|
||||
|
||||
async getMangaTrackRecords(mangaId: string): Promise<unknown[]> {
|
||||
const data = await this.gql<{
|
||||
manga: { trackRecords: { nodes: unknown[] } }
|
||||
}>(GET_MANGA_TRACK_RECORDS, { mangaId: Number(mangaId) })
|
||||
const data = await this.gql<{ manga: { trackRecords: { nodes: unknown[] } } }>(
|
||||
GET_MANGA_TRACK_RECORDS, { mangaId: Number(mangaId) }
|
||||
)
|
||||
return data.manga.trackRecords.nodes
|
||||
}
|
||||
|
||||
async searchTracker(trackerId: string, query: string): Promise<unknown[]> {
|
||||
const data = await this.gql<{
|
||||
searchTracker: { trackSearches: unknown[] }
|
||||
}>(SEARCH_TRACKER, { trackerId: Number(trackerId), query })
|
||||
const data = await this.gql<{ searchTracker: { trackSearches: unknown[] } }>(
|
||||
SEARCH_TRACKER, { trackerId: Number(trackerId), query }
|
||||
)
|
||||
return data.searchTracker.trackSearches
|
||||
}
|
||||
|
||||
@@ -410,7 +449,26 @@ export class SuwayomiAdapter implements ServerAdapter {
|
||||
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[]> {
|
||||
if (mangaIds?.length) {
|
||||
@@ -440,4 +498,8 @@ export class SuwayomiAdapter implements ServerAdapter {
|
||||
const { isRunning, finishedJobs, totalJobs } = data.libraryUpdateStatus.jobsInfo
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,46 @@ export interface LibraryUpdateProgress {
|
||||
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 {
|
||||
connect(config: ServerConfig): Promise<void>
|
||||
getStatus(): Promise<ServerStatus>
|
||||
@@ -80,7 +120,7 @@ export interface ServerAdapter {
|
||||
|
||||
getChapters(mangaId: 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[]>
|
||||
getRecentlyUpdated(): Promise<Chapter[]>
|
||||
markChapterRead(id: string, read: boolean): Promise<void>
|
||||
@@ -125,7 +165,13 @@ export interface ServerAdapter {
|
||||
fetchTrackRecord(recordId: 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[]>
|
||||
stopLibraryUpdate(): Promise<void>
|
||||
getLibraryUpdateStatus(): Promise<LibraryUpdateProgress>
|
||||
clearPageCache(chapterId?: number): void
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
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({
|
||||
status: 'booting' as AppStatus,
|
||||
error: null as string | null,
|
||||
serverUrl: '',
|
||||
authenticated: false,
|
||||
authMode: 'NONE' as 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN',
|
||||
platform: 'web' as 'web' | 'tauri' | 'capacitor',
|
||||
version: '',
|
||||
})
|
||||
status: 'booting' as AppStatus,
|
||||
error: null as string | null,
|
||||
serverUrl: '',
|
||||
authenticated: false,
|
||||
authMode: 'NONE' as 'NONE' | 'BASIC_AUTH' | 'UI_LOGIN',
|
||||
platform: 'web' as 'web' | 'tauri' | 'capacitor',
|
||||
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) }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -5,12 +5,14 @@ export const downloadsState = $state({
|
||||
error: null as string | null,
|
||||
})
|
||||
|
||||
export const activeDownloads = $derived(
|
||||
downloadsState.items.filter(d => d.state === 'downloading')
|
||||
)
|
||||
export function activeDownloads() {
|
||||
return downloadsState.items.filter(d => d.state === 'downloading')
|
||||
}
|
||||
|
||||
export const queuedDownloads = $derived(
|
||||
downloadsState.items.filter(d => d.state === 'queued')
|
||||
)
|
||||
export function queuedDownloads() {
|
||||
return downloadsState.items.filter(d => d.state === 'queued')
|
||||
}
|
||||
|
||||
export const downloadCount = $derived(downloadsState.items.length)
|
||||
export function downloadCount() {
|
||||
return downloadsState.items.length
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export const extensionsState = $state({
|
||||
browseHasMore: false,
|
||||
})
|
||||
|
||||
export const filteredExtensions = $derived.by(() => {
|
||||
export function filteredExtensions() {
|
||||
let result = extensionsState.items
|
||||
|
||||
if (extensionsState.filter.installed) {
|
||||
@@ -33,4 +33,4 @@ export const filteredExtensions = $derived.by(() => {
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
}
|
||||
@@ -1,25 +1,38 @@
|
||||
export type ToastKind = 'info' | 'success' | 'error' | 'download'
|
||||
|
||||
export interface Toast {
|
||||
id: string
|
||||
kind: ToastKind
|
||||
message: string
|
||||
detail?: string
|
||||
id: string
|
||||
kind: ToastKind
|
||||
message: string
|
||||
detail?: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export const notificationsState = $state({
|
||||
toasts: [] as Toast[],
|
||||
})
|
||||
export interface ActiveDownload {
|
||||
chapterId: number
|
||||
mangaId: number
|
||||
progress: number
|
||||
}
|
||||
|
||||
export function toast(kind: ToastKind, message: string, detail?: string, duration = 4000) {
|
||||
const id = crypto.randomUUID()
|
||||
notificationsState.toasts.push({ id, kind, message, detail, duration })
|
||||
if (duration > 0) {
|
||||
setTimeout(() => dismissToast(id), duration)
|
||||
class NotificationStore {
|
||||
toasts: Toast[] = $state([])
|
||||
activeDownloads: ActiveDownload[] = $state([])
|
||||
|
||||
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) {
|
||||
notificationsState.toasts = notificationsState.toasts.filter(t => t.id !== id)
|
||||
}
|
||||
export const notifications = new NotificationStore()
|
||||
|
||||
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) }
|
||||
@@ -25,17 +25,20 @@ export const readerState = $state({
|
||||
fullscreen: false,
|
||||
})
|
||||
|
||||
export const currentPageData = $derived(
|
||||
readerState.pages[readerState.currentPage] ?? null
|
||||
)
|
||||
export function currentPageData() {
|
||||
return readerState.pages[readerState.currentPage] ?? null
|
||||
}
|
||||
|
||||
export const progress = $derived(
|
||||
readerState.pages.length > 0
|
||||
export function progress() {
|
||||
return readerState.pages.length > 0
|
||||
? (readerState.currentPage + 1) / readerState.pages.length
|
||||
: 0
|
||||
)
|
||||
}
|
||||
|
||||
export const hasPrev = $derived(readerState.currentPage > 0)
|
||||
export const hasNext = $derived(
|
||||
readerState.currentPage < readerState.pages.length - 1
|
||||
)
|
||||
export function hasPrev() {
|
||||
return readerState.currentPage > 0
|
||||
}
|
||||
|
||||
export function hasNext() {
|
||||
return readerState.currentPage < readerState.pages.length - 1
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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({
|
||||
trackers: [] as Tracker[],
|
||||
@@ -13,4 +15,42 @@ export const trackingState = $state({
|
||||
searchResults: [] as unknown[],
|
||||
searchLoading: false,
|
||||
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
@@ -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 FitMode = "width" | "height" | "screen" | "original";
|
||||
export type LibraryFilter = "all" | "library" | "downloaded" | string;
|
||||
export type ReadingDirection = "ltr" | "rtl";
|
||||
export type ChapterSortDir = "desc" | "asc";
|
||||
export type ChapterSortMode = "source" | "chapterNumber" | "uploadDate";
|
||||
export type ContentLevel = "strict" | "moderate" | "unrestricted";
|
||||
export type PageStyle = 'single' | 'double' | 'longstrip'
|
||||
export type FitMode = 'width' | 'height' | 'screen' | 'original'
|
||||
export type LibraryFilter = 'all' | 'library' | 'downloaded' | string
|
||||
export type ReadingDirection = 'ltr' | 'rtl'
|
||||
export type ChapterSortDir = 'desc' | 'asc'
|
||||
export type ChapterSortMode = 'source' | 'chapterNumber' | 'uploadDate'
|
||||
export type ContentLevel = 'strict' | 'moderate' | 'unrestricted'
|
||||
|
||||
export type LibrarySortMode =
|
||||
| "az" | "unreadCount" | "totalChapters"
|
||||
| "recentlyAdded" | "recentlyRead" | "latestFetched" | "latestUploaded";
|
||||
| 'az' | 'unreadCount' | 'totalChapters'
|
||||
| 'recentlyAdded' | 'recentlyRead' | 'latestFetched' | 'latestUploaded'
|
||||
|
||||
export type LibrarySortDir = "asc" | "desc";
|
||||
|
||||
export type LibraryStatusFilter = "ALL" | "ONGOING" | "COMPLETED" | "CANCELLED" | "HIATUS" | "UNKNOWN";
|
||||
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 LibrarySortDir = 'asc' | 'desc'
|
||||
export type LibraryStatusFilter = 'ALL' | 'ONGOING' | 'COMPLETED' | 'CANCELLED' | 'HIATUS' | 'UNKNOWN'
|
||||
export type LibraryContentFilter = 'unread' | 'started' | 'downloaded' | 'bookmarked' | 'marked'
|
||||
export type BuiltinTheme = 'original' | 'dark' | 'light' | 'light-contrast' | 'midnight' | 'warm'
|
||||
export type Theme = BuiltinTheme | string
|
||||
|
||||
export interface ThemeTokens {
|
||||
"bg-void": string;
|
||||
"bg-base": string;
|
||||
"bg-surface": string;
|
||||
"bg-raised": string;
|
||||
"bg-overlay": string;
|
||||
"bg-subtle": string;
|
||||
"border-dim": string;
|
||||
"border-base": string;
|
||||
"border-strong": 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;
|
||||
'bg-void': string; 'bg-base': string; 'bg-surface': string
|
||||
'bg-raised': string; 'bg-overlay': string; 'bg-subtle': string
|
||||
'border-dim': string; 'border-base': string; 'border-strong': 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 {
|
||||
id: string;
|
||||
name: string;
|
||||
tokens: ThemeTokens;
|
||||
}
|
||||
export interface CustomTheme { id: string; name: string; tokens: ThemeTokens }
|
||||
|
||||
export const DEFAULT_THEME_TOKENS: ThemeTokens = {
|
||||
"bg-void": "#080808",
|
||||
"bg-base": "#0c0c0c",
|
||||
"bg-surface": "#101010",
|
||||
"bg-raised": "#151515",
|
||||
"bg-overlay": "#1a1a1a",
|
||||
"bg-subtle": "#202020",
|
||||
"border-dim": "#1c1c1c",
|
||||
"border-base": "#242424",
|
||||
"border-strong": "#2e2e2e",
|
||||
"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",
|
||||
};
|
||||
'bg-void': '#080808', 'bg-base': '#0c0c0c', 'bg-surface': '#101010',
|
||||
'bg-raised': '#151515', 'bg-overlay': '#1a1a1a', 'bg-subtle': '#202020',
|
||||
'border-dim': '#1c1c1c', 'border-base': '#242424', 'border-strong': '#2e2e2e', '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 {
|
||||
autoDownload: boolean;
|
||||
downloadAhead: number;
|
||||
deleteOnRead: boolean;
|
||||
deleteDelayHours: number;
|
||||
maxKeepChapters: number;
|
||||
pauseUpdates: boolean;
|
||||
refreshInterval: "global" | "daily" | "weekly" | "manual";
|
||||
preferredScanlator: string;
|
||||
scanlatorFilter: string[];
|
||||
scanlatorBlacklist: string[];
|
||||
scanlatorForce: boolean;
|
||||
autoDownloadScanlators: string[];
|
||||
coverUrl?: string;
|
||||
autoDownload: boolean; downloadAhead: number; deleteOnRead: boolean
|
||||
deleteDelayHours: number; maxKeepChapters: number; pauseUpdates: boolean
|
||||
refreshInterval: 'global' | 'daily' | 'weekly' | 'manual'
|
||||
preferredScanlator: string; scanlatorFilter: string[]
|
||||
scanlatorBlacklist: string[]; scanlatorForce: boolean
|
||||
autoDownloadScanlators: string[]
|
||||
coverUrl?: string
|
||||
}
|
||||
|
||||
export const DEFAULT_MANGA_PREFS: MangaPrefs = {
|
||||
autoDownload: false,
|
||||
downloadAhead: 0,
|
||||
deleteOnRead: false,
|
||||
deleteDelayHours: 0,
|
||||
maxKeepChapters: 0,
|
||||
pauseUpdates: false,
|
||||
refreshInterval: "global",
|
||||
preferredScanlator: "",
|
||||
scanlatorFilter: [],
|
||||
scanlatorBlacklist: [],
|
||||
scanlatorForce: false,
|
||||
autoDownload: false, downloadAhead: 0, deleteOnRead: false,
|
||||
deleteDelayHours: 0, maxKeepChapters: 0, pauseUpdates: false,
|
||||
refreshInterval: 'global', preferredScanlator: '', scanlatorFilter: [],
|
||||
scanlatorBlacklist: [], scanlatorForce: false,
|
||||
autoDownloadScanlators: [],
|
||||
};
|
||||
}
|
||||
|
||||
export interface ReaderSettings {
|
||||
pageStyle: PageStyle;
|
||||
fitMode: FitMode;
|
||||
readingDirection: ReadingDirection;
|
||||
readerZoom: number;
|
||||
pageGap: boolean;
|
||||
optimizeContrast: boolean;
|
||||
offsetDoubleSpreads: boolean;
|
||||
barPosition?: "top" | "left" | "right";
|
||||
pageStyle: PageStyle
|
||||
fitMode: FitMode
|
||||
readingDirection: ReadingDirection
|
||||
readerZoom: number
|
||||
pageGap: boolean
|
||||
optimizeContrast: boolean
|
||||
offsetDoubleSpreads: boolean
|
||||
barPosition?: 'top' | 'left' | 'right'
|
||||
}
|
||||
|
||||
export interface ReaderPreset {
|
||||
id: string;
|
||||
name: string;
|
||||
settings: ReaderSettings;
|
||||
id: string
|
||||
name: string
|
||||
settings: ReaderSettings
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
pageStyle: PageStyle;
|
||||
readingDirection: ReadingDirection;
|
||||
fitMode: FitMode;
|
||||
readerZoom: number;
|
||||
pageGap: boolean;
|
||||
optimizeContrast: boolean;
|
||||
offsetDoubleSpreads: boolean;
|
||||
preloadPages: number;
|
||||
autoMarkRead: boolean;
|
||||
autoNextChapter: boolean;
|
||||
libraryCropCovers: boolean;
|
||||
libraryPageSize: number;
|
||||
contentLevel: ContentLevel;
|
||||
sourceOverridesEnabled: boolean;
|
||||
nsfwAllowedSourceIds: string[];
|
||||
nsfwBlockedSourceIds: string[];
|
||||
discordRpc: boolean;
|
||||
chapterSortDir: ChapterSortDir;
|
||||
chapterSortMode: ChapterSortMode;
|
||||
chapterPageSize: number;
|
||||
uiZoom: number;
|
||||
compactSidebar: boolean;
|
||||
gpuAcceleration: boolean;
|
||||
serverUrl: string;
|
||||
serverBinary: string;
|
||||
serverBinaryArgs: string;
|
||||
autoStartServer: boolean;
|
||||
suwayomiWebUI: boolean;
|
||||
preferredExtensionLang: string;
|
||||
keybinds: Keybinds;
|
||||
idleTimeoutMin?: number;
|
||||
splashCards?: boolean;
|
||||
storageLimitGb: number | null;
|
||||
markReadOnNext: boolean;
|
||||
readerDebounceMs: number;
|
||||
autoBookmark: boolean;
|
||||
theme: Theme;
|
||||
libraryBranches: boolean;
|
||||
renderLimit: number;
|
||||
heroSlots: (number | null)[];
|
||||
mangaLinks: Record<number, number[]>;
|
||||
mangaPrefs: Record<number, Partial<MangaPrefs>>;
|
||||
serverAuthUser: string;
|
||||
serverAuthPass: string;
|
||||
serverAuthMode: "NONE" | "BASIC_AUTH" | "SIMPLE_LOGIN" | "UI_LOGIN";
|
||||
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;
|
||||
pageStyle: PageStyle; readingDirection: ReadingDirection; fitMode: FitMode
|
||||
readerZoom: number; pageGap: boolean; optimizeContrast: boolean
|
||||
offsetDoubleSpreads: boolean; preloadPages: number
|
||||
autoMarkRead: boolean; autoNextChapter: boolean
|
||||
libraryCropCovers: boolean; libraryPageSize: number
|
||||
contentLevel: ContentLevel; sourceOverridesEnabled: boolean
|
||||
nsfwAllowedSourceIds: string[]; nsfwBlockedSourceIds: string[]
|
||||
discordRpc: boolean
|
||||
chapterSortDir: ChapterSortDir; chapterSortMode: ChapterSortMode; chapterPageSize: number
|
||||
uiZoom: number; compactSidebar: boolean; gpuAcceleration: boolean
|
||||
serverUrl: string; serverBinary: string; serverBinaryArgs: string; autoStartServer: boolean; suwayomiWebUI: boolean
|
||||
preferredExtensionLang: string; keybinds: Keybinds
|
||||
idleTimeoutMin?: number; splashCards?: boolean
|
||||
storageLimitGb: number | null; markReadOnNext: boolean; readerDebounceMs: number
|
||||
autoBookmark: boolean; theme: Theme; libraryBranches: boolean; renderLimit: number
|
||||
heroSlots: (number | null)[]; mangaLinks: Record<number, number[]>
|
||||
mangaPrefs: Record<number, Partial<MangaPrefs>>
|
||||
serverAuthUser: string; serverAuthPass: string
|
||||
serverAuthMode: 'NONE' | 'BASIC_AUTH' | 'SIMPLE_LOGIN' | 'UI_LOGIN'
|
||||
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
|
||||
systemThemeSync?: boolean; systemThemeDark?: string; systemThemeLight?: string
|
||||
closeAction?: 'ask' | 'tray' | 'quit'
|
||||
overlayBars?: boolean; tapToToggleBar?: boolean
|
||||
automationEnabled?: boolean; automationEnforceGlobal?: boolean
|
||||
automationDefaults?: Partial<MangaPrefs>
|
||||
libraryShowAllInSaved?: boolean; libraryHideCompletedInSaved?: boolean
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: Settings = {
|
||||
pageStyle: "longstrip",
|
||||
readingDirection: "ltr",
|
||||
fitMode: "width",
|
||||
readerZoom: 1.0,
|
||||
pageGap: true,
|
||||
optimizeContrast: false,
|
||||
offsetDoubleSpreads: false,
|
||||
preloadPages: 3,
|
||||
autoMarkRead: true,
|
||||
autoNextChapter: true,
|
||||
libraryCropCovers: true,
|
||||
libraryPageSize: 48,
|
||||
contentLevel: "strict",
|
||||
sourceOverridesEnabled: false,
|
||||
nsfwAllowedSourceIds: [],
|
||||
nsfwBlockedSourceIds: [],
|
||||
pageStyle: 'longstrip', readingDirection: 'ltr', fitMode: 'width',
|
||||
readerZoom: 1.0, pageGap: true, optimizeContrast: false, offsetDoubleSpreads: false,
|
||||
preloadPages: 3, autoMarkRead: true, autoNextChapter: true,
|
||||
libraryCropCovers: true, libraryPageSize: 48,
|
||||
contentLevel: 'strict', sourceOverridesEnabled: false,
|
||||
nsfwAllowedSourceIds: [], nsfwBlockedSourceIds: [],
|
||||
discordRpc: false,
|
||||
chapterSortDir: "desc",
|
||||
chapterSortMode: "source",
|
||||
chapterPageSize: 25,
|
||||
uiZoom: 1.0,
|
||||
compactSidebar: false,
|
||||
gpuAcceleration: true,
|
||||
serverUrl: "http://localhost:4567",
|
||||
serverBinary: "",
|
||||
serverBinaryArgs: "",
|
||||
autoStartServer: true,
|
||||
suwayomiWebUI: false,
|
||||
preferredExtensionLang: "en",
|
||||
keybinds: {} as Keybinds,
|
||||
idleTimeoutMin: 5,
|
||||
splashCards: true,
|
||||
storageLimitGb: 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,
|
||||
chapterSortDir: 'desc', chapterSortMode: 'source', chapterPageSize: 25,
|
||||
uiZoom: 1.0, compactSidebar: false, gpuAcceleration: true,
|
||||
serverUrl: 'http://localhost:4567', serverBinary: '', serverBinaryArgs: '', autoStartServer: true, suwayomiWebUI: false,
|
||||
preferredExtensionLang: 'en', keybinds: DEFAULT_KEYBINDS,
|
||||
idleTimeoutMin: 5, splashCards: true, storageLimitGb: 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,
|
||||
libraryTabSort: {},
|
||||
libraryTabStatus: {},
|
||||
libraryTabFilters: {},
|
||||
extraScanDirs: [],
|
||||
serverDownloadsPath: "",
|
||||
serverLocalSourcePath: "",
|
||||
qolAnimations: true,
|
||||
libraryStatsAlways: false,
|
||||
pinnedSourceIds: [],
|
||||
readerPresets: [],
|
||||
mangaReaderSettings: {},
|
||||
trackerSyncBack: false,
|
||||
trackerSyncBackThreshold: 20,
|
||||
trackerRespectScanlatorFilter: true,
|
||||
pinchZoom: false,
|
||||
autoLinkOnOpen: false,
|
||||
downloadToastsEnabled: true,
|
||||
downloadAutoRetry: false,
|
||||
hiddenLibraryTabs: [],
|
||||
libraryPinnedTabOrder: [],
|
||||
autoScroll: false,
|
||||
autoScrollSpeed: 5,
|
||||
disableAutoComplete: false,
|
||||
};
|
||||
libraryTabSort: {}, libraryTabStatus: {}, libraryTabFilters: {},
|
||||
extraScanDirs: [], serverDownloadsPath: '', serverLocalSourcePath: '',
|
||||
qolAnimations: true, libraryStatsAlways: false, pinnedSourceIds: [],
|
||||
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
@@ -1,11 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { appState } from '$lib/state/app.svelte'
|
||||
import { notificationsState } from '$lib/state/notifications.svelte'
|
||||
import SplashScreen from '$lib/ui/chrome/SplashScreen.svelte'
|
||||
import AuthGate from '$lib/ui/chrome/AuthGate.svelte'
|
||||
import Sidebar from '$lib/ui/chrome/Sidebar.svelte'
|
||||
import TitleBar from '$lib/ui/chrome/TitleBar.svelte'
|
||||
import Toaster from '$lib/ui/chrome/Toaster.svelte'
|
||||
import { appState, app } from '$lib/state/app.svelte'
|
||||
import { notifications } from '$lib/state/notifications.svelte'
|
||||
import SplashScreen from '$lib/components/chrome/SplashScreen.svelte'
|
||||
import AuthGate from '$lib/components/chrome/AuthGate.svelte'
|
||||
import Sidebar from '$lib/components/chrome/Sidebar.svelte'
|
||||
import TitleBar from '$lib/components/chrome/TitleBar.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'
|
||||
|
||||
let { children } = $props()
|
||||
@@ -13,8 +15,10 @@
|
||||
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
|
||||
const ringFull = $derived(appState.status !== 'booting')
|
||||
|
||||
let splashVisible = $state(true)
|
||||
let bypassed = $state(false)
|
||||
let splashVisible = $state(true)
|
||||
let bypassed = $state(false)
|
||||
let themeEditorOpen = $state(false)
|
||||
let themeEditorId = $state<string | null>(null)
|
||||
|
||||
const showApp = $derived(
|
||||
appState.status === 'ready' ||
|
||||
@@ -22,13 +26,12 @@
|
||||
bypassed
|
||||
)
|
||||
|
||||
function onSplashReady() {
|
||||
splashVisible = false
|
||||
}
|
||||
function onSplashReady() { splashVisible = false }
|
||||
function onSplashBypass() { bypassed = true; splashVisible = false }
|
||||
|
||||
function onSplashBypass() {
|
||||
bypassed = true
|
||||
splashVisible = false
|
||||
function openThemeEditor(id?: string | null) {
|
||||
themeEditorId = id ?? null
|
||||
themeEditorOpen = true
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -59,8 +62,19 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if app.settingsOpen}
|
||||
<Settings
|
||||
onclose={() => app.setSettingsOpen(false)}
|
||||
onOpenThemeEditor={openThemeEditor}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if themeEditorOpen}
|
||||
<ThemeEditor onclose={() => themeEditorOpen = false} editId={themeEditorId} />
|
||||
{/if}
|
||||
|
||||
<AuthGate />
|
||||
<Toaster toasts={notificationsState.toasts} />
|
||||
<Toaster toasts={notifications.toasts} />
|
||||
|
||||
<style>
|
||||
.frame {
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
import { libraryState } from '$lib/state/library.svelte'
|
||||
import { homeState, setHeroSlot } from '$lib/state/home.svelte'
|
||||
import type { HistoryEntry } from '$lib/state/home.svelte'
|
||||
import HeroStage from '$lib/ui/home/HeroStage.svelte'
|
||||
import HeroSlotPicker from '$lib/ui/home/HeroSlotPicker.svelte'
|
||||
import ActivityFeed from '$lib/ui/home/ActivityFeed.svelte'
|
||||
import ActivityHeatmap from '$lib/ui/home/ActivityHeatmap.svelte'
|
||||
import RecsRow from '$lib/ui/home/RecsRow.svelte'
|
||||
import StatsGrid from '$lib/ui/home/StatsGrid.svelte'
|
||||
import HeroStage from '$lib/components/home/HeroStage.svelte'
|
||||
import HeroSlotPicker from '$lib/components/home/HeroSlotPicker.svelte'
|
||||
import ActivityFeed from '$lib/components/home/ActivityFeed.svelte'
|
||||
import ActivityHeatmap from '$lib/components/home/ActivityHeatmap.svelte'
|
||||
import RecsRow from '$lib/components/home/RecsRow.svelte'
|
||||
import StatsGrid from '$lib/components/home/StatsGrid.svelte'
|
||||
import type { Manga, Chapter } from '$lib/types'
|
||||
|
||||
const TOTAL_SLOTS = 4
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
import { goto } from '$app/navigation'
|
||||
import { libraryState } from '$lib/state/library.svelte'
|
||||
import { loadLibrary, refreshLibrary, removeFromLibrary, bulkRemoveFromLibrary } from '$lib/request-manager/manga'
|
||||
import LibraryToolbar from '$lib/ui/library/LibraryToolbar.svelte'
|
||||
import LibraryGrid from '$lib/ui/library/LibraryGrid.svelte'
|
||||
import LibraryToolbar from '$lib/components/library/LibraryToolbar.svelte'
|
||||
import LibraryGrid from '$lib/components/library/LibraryGrid.svelte'
|
||||
import type { Manga } from '$lib/types'
|
||||
|
||||
const saved = $derived(libraryState.items.filter(m => m.inLibrary).length)
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { appState } from '$lib/state/app.svelte'
|
||||
</script>
|
||||
import { goto } from '$app/navigation'
|
||||
import { app } from '$lib/state/app.svelte'
|
||||
import { onMount } from 'svelte'
|
||||
|
||||
<p>Settings — stub</p>
|
||||
onMount(() => {
|
||||
app.setSettingsOpen(true)
|
||||
goto('/', { replaceState: true })
|
||||
})
|
||||
</script>
|
||||
Reference in New Issue
Block a user